Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions InterlinedList/LoginWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,26 @@
BorderThickness="1"
Foreground="{DynamicResource TextBrush}"/>

<!-- Registration-only fields (toggled from code-behind) -->
<StackPanel x:Name="RegisterFields" Visibility="Collapsed">
<TextBlock Text="USERNAME"
FontSize="10" FontWeight="SemiBold"
Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Username, UpdateSourceTrigger=PropertyChanged}"
FontSize="13" Padding="8,6" Margin="0,0,0,14"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
Foreground="{DynamicResource TextBrush}"/>
<TextBlock Text="DISPLAY NAME (OPTIONAL)"
FontSize="10" FontWeight="SemiBold"
Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,4"/>
<TextBox Text="{Binding DisplayName, UpdateSourceTrigger=PropertyChanged}"
FontSize="13" Padding="8,6" Margin="0,0,0,14"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
Foreground="{DynamicResource TextBrush}"/>
</StackPanel>

<TextBlock Text="PASSWORD"
FontSize="10" FontWeight="SemiBold"
Foreground="{DynamicResource TextMutedBrush}"
Expand Down Expand Up @@ -139,6 +159,18 @@
HorizontalAlignment="Stretch"
Margin="0,10,0,0"/>

<Button x:Name="BtnForgot"
Content="Forgot password?"
Click="BtnForgot_Click"
Style="{StaticResource LinkBtnStyle}"
Margin="0,14,0,0"/>

<Button x:Name="BtnToggleMode"
Content="{Binding ToggleModeText}"
Click="BtnToggleMode_Click"
Style="{StaticResource LinkBtnStyle}"
Margin="0,10,0,0"/>

</StackPanel>
</Border>
</Grid>
Expand Down Expand Up @@ -215,6 +247,29 @@
</Setter>
</Style>

<!-- Text link (forgot password / toggle register) -->
<Style x:Key="LinkBtnStyle" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource LinkBrush}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<ContentPresenter HorizontalAlignment="Center"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="TextBlock.TextDecorations" Value="Underline"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>

</Window.Resources>

</Window>
18 changes: 16 additions & 2 deletions InterlinedList/LoginWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,23 @@ private async void PasswordInput_KeyDown(object sender, KeyEventArgs e)

private async Task SubmitAsync()
{
var succeeded = await _viewModel.LoginAsync(PasswordInput.Password);
var succeeded = _viewModel.IsRegisterMode
? await _viewModel.RegisterAsync(PasswordInput.Password)
: await _viewModel.LoginAsync(PasswordInput.Password);
if (succeeded)
LoginSucceeded?.Invoke(this, EventArgs.Empty);
}

private async void BtnForgot_Click(object sender, RoutedEventArgs e)
=> await _viewModel.ForgotPasswordAsync();

private void BtnToggleMode_Click(object sender, RoutedEventArgs e)
{
_viewModel.IsRegisterMode = !_viewModel.IsRegisterMode;
RegisterFields.Visibility = _viewModel.IsRegisterMode ? Visibility.Visible : Visibility.Collapsed;
BtnLogin.Content = _viewModel.PrimaryButtonText;
}

private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
Expand All @@ -54,7 +66,9 @@ private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs

case nameof(LoginViewModel.IsBusy):
BtnLogin.IsEnabled = !_viewModel.IsBusy;
BtnLogin.Content = _viewModel.IsBusy ? "Logging in…" : "Log In";
BtnLogin.Content = _viewModel.IsBusy
? "Working…"
: _viewModel.PrimaryButtonText;
break;
}
}
Expand Down
19 changes: 19 additions & 0 deletions InterlinedList/Models/ShareLink.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace InterlinedList.Models;

/// <summary>
/// A tokenized public share link for a list or document. Shape verified live
/// 2026-08-01: POST returns { token, url, role, expiresAt }; GET adds
/// createdAt / revokedAt. Revoke with DELETE …/share-links/{token}.
/// </summary>
public sealed class ShareLink
{
public required string Token { get; init; }
public string? Url { get; init; }
public string? Role { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public DateTimeOffset? CreatedAt { get; init; }
public DateTimeOffset? RevokedAt { get; init; }

public bool IsActive => RevokedAt is null;
public string RoleLabel => string.IsNullOrEmpty(Role) ? "viewer" : Role;
}
20 changes: 20 additions & 0 deletions InterlinedList/Services/InterlinedApiClient.Auth.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Net.Http;

namespace InterlinedList.Services;

/// <summary>
/// Pre-login self-service: account registration and password reset. Both are
/// public (no bearer). Request shapes verified against the OpenAPI spec
/// 2026-08-01: register { email, username, password, displayName }, forgot
/// { email }. Responses aren't parsed — on success the caller either logs in
/// with the new credentials or tells the user to check their inbox.
/// </summary>
public sealed partial class InterlinedApiClient
{
public Task RegisterAsync(string email, string username, string password, string? displayName, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Post, "api/auth/register",
new { email, username, password, displayName }, ct);

public Task ForgotPasswordAsync(string email, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Post, "api/auth/forgot-password", new { email }, ct);
}
17 changes: 17 additions & 0 deletions InterlinedList/Services/InterlinedApiClient.Documents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,21 @@ public Task DeleteDocumentFolderAsync(string id, CancellationToken ct = default)
public Task CreateDocumentInFolderAsync(string folderId, string title, string content, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Post, $"api/documents/folders/{folderId}/documents",
new { title, content, isPublic = false }, ct);

// ── Share links (create a public read link for a document) ──────────────────
// Same shape as list share-links (verified live 2026-08-01).

public async Task<List<ShareLink>> GetDocumentShareLinksAsync(string documentId, CancellationToken ct = default)
{
var json = await GetElementAsync($"api/documents/{documentId}/share-links", ct);
return json.TryGetProperty("shareLinks", out var arr) && arr.ValueKind == JsonValueKind.Array
? arr.Deserialize<List<ShareLink>>(JsonOptions) ?? new()
: new();
}

public Task<ShareLink> CreateDocumentShareLinkAsync(string documentId, CancellationToken ct = default)
=> SendJsonAsync<ShareLink>(HttpMethod.Post, $"api/documents/{documentId}/share-links", new { }, ct);

public Task DeleteDocumentShareLinkAsync(string documentId, string token, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Delete, $"api/documents/{documentId}/share-links/{token}", null, ct);
}
18 changes: 18 additions & 0 deletions InterlinedList/Services/InterlinedApiClient.Lists.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,22 @@ public async Task<List<WatchedList>> GetWatchingListsAsync(CancellationToken ct
? arr.Deserialize<List<WatchedList>>(JsonOptions) ?? new()
: new();
}

// ── Share links (create a public read link for a list) ──────────────────────
// Shape verified live 2026-08-01: GET → { shareLinks }, POST → the new link,
// DELETE …/{token} revokes it.

public async Task<List<ShareLink>> GetListShareLinksAsync(string listId, CancellationToken ct = default)
{
var json = await GetElementAsync($"api/lists/{listId}/share-links", ct);
return json.TryGetProperty("shareLinks", out var arr) && arr.ValueKind == JsonValueKind.Array
? arr.Deserialize<List<ShareLink>>(JsonOptions) ?? new()
: new();
}

public Task<ShareLink> CreateListShareLinkAsync(string listId, CancellationToken ct = default)
=> SendJsonAsync<ShareLink>(HttpMethod.Post, $"api/lists/{listId}/share-links", new { }, ct);

public Task DeleteListShareLinkAsync(string listId, string token, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Delete, $"api/lists/{listId}/share-links/{token}", null, ct);
}
80 changes: 78 additions & 2 deletions InterlinedList/ViewModels/DocumentsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public partial class DocumentsViewModel : ObservableObject
public ObservableCollection<DocumentFolder> Folders { get; } = new();
public ObservableCollection<DocumentTemplate> Templates { get; } = new();

// Public share links for the currently-open document (empty when none open).
public ObservableCollection<ShareLink> ShareLinks { get; } = new();

[ObservableProperty]
private bool isLoading;

Expand Down Expand Up @@ -112,11 +115,79 @@ private async Task CreateDocumentAsync()
}

[RelayCommand]
private void SelectDocument(DocumentSummary doc)
private async Task SelectDocumentAsync(DocumentSummary doc)
{
SelectedDocument = doc;
EditTitle = doc.Title;
EditContent = doc.Content;
await ReloadShareLinksAsync(doc.Id);
}

// Refresh ShareLinks from the server for the given document (read-after-write).
private async Task ReloadShareLinksAsync(string documentId)
{
try
{
var links = await _session.Api.GetDocumentShareLinksAsync(documentId);
ShareLinks.Clear();
foreach (var link in links)
ShareLinks.Add(link);
}
catch (InterlinedApiException ex)
{
ErrorMessage = ex.Message;
}
}

private bool CanShareSelectedDocument() => SelectedDocument is not null;

[RelayCommand(CanExecute = nameof(CanShareSelectedDocument))]
private async Task CreateShareLinkAsync()
{
if (SelectedDocument is not { } doc) return;

try
{
await _session.Api.CreateDocumentShareLinkAsync(doc.Id);
ErrorMessage = null;
await ReloadShareLinksAsync(doc.Id);
}
catch (InterlinedApiException ex)
{
ErrorMessage = ex.Message;
}
}

[RelayCommand]
private async Task RevokeShareLinkAsync(ShareLink link)
{
if (SelectedDocument is not { } doc) return;

try
{
await _session.Api.DeleteDocumentShareLinkAsync(doc.Id, link.Token);
ErrorMessage = null;
await ReloadShareLinksAsync(doc.Id);
}
catch (InterlinedApiException ex)
{
ErrorMessage = ex.Message;
}
}

[RelayCommand]
private void CopyShareLink(ShareLink link)
{
if (string.IsNullOrEmpty(link.Url)) return;

try
{
System.Windows.Clipboard.SetText(link.Url);
}
catch (InterlinedApiException ex)
{
ErrorMessage = ex.Message;
}
}

private bool CanSaveDocument() => SelectedDocument is not null;
Expand Down Expand Up @@ -149,6 +220,7 @@ private async Task DeleteDocumentAsync(DocumentSummary doc)
SelectedDocument = null;
EditTitle = "";
EditContent = "";
ShareLinks.Clear();
}
ErrorMessage = null;
await LoadAsync();
Expand Down Expand Up @@ -291,7 +363,11 @@ private async Task SaveDocToFolderAsync()

partial void OnNewDocTitleChanged(string value) => CreateDocumentCommand.NotifyCanExecuteChanged();

partial void OnSelectedDocumentChanged(DocumentSummary? value) => SaveDocumentCommand.NotifyCanExecuteChanged();
partial void OnSelectedDocumentChanged(DocumentSummary? value)
{
SaveDocumentCommand.NotifyCanExecuteChanged();
CreateShareLinkCommand.NotifyCanExecuteChanged();
}

partial void OnNewFolderNameChanged(string value) => CreateFolderCommand.NotifyCanExecuteChanged();

Expand Down
Loading
Loading