diff --git a/InterlinedList/LoginWindow.xaml b/InterlinedList/LoginWindow.xaml
index 916a5c5..2031eb9 100644
--- a/InterlinedList/LoginWindow.xaml
+++ b/InterlinedList/LoginWindow.xaml
@@ -110,6 +110,26 @@
BorderThickness="1"
Foreground="{DynamicResource TextBrush}"/>
+
+
+
+
+
+
+
+
+
+
+
+
@@ -215,6 +247,29 @@
+
+
+
diff --git a/InterlinedList/LoginWindow.xaml.cs b/InterlinedList/LoginWindow.xaml.cs
index dd5ca50..3fb2fd9 100644
--- a/InterlinedList/LoginWindow.xaml.cs
+++ b/InterlinedList/LoginWindow.xaml.cs
@@ -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)
@@ -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;
}
}
diff --git a/InterlinedList/Models/ShareLink.cs b/InterlinedList/Models/ShareLink.cs
new file mode 100644
index 0000000..ba33c80
--- /dev/null
+++ b/InterlinedList/Models/ShareLink.cs
@@ -0,0 +1,19 @@
+namespace InterlinedList.Models;
+
+///
+/// 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}.
+///
+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;
+}
diff --git a/InterlinedList/Services/InterlinedApiClient.Auth.cs b/InterlinedList/Services/InterlinedApiClient.Auth.cs
new file mode 100644
index 0000000..4838bf3
--- /dev/null
+++ b/InterlinedList/Services/InterlinedApiClient.Auth.cs
@@ -0,0 +1,20 @@
+using System.Net.Http;
+
+namespace InterlinedList.Services;
+
+///
+/// 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.
+///
+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);
+}
diff --git a/InterlinedList/Services/InterlinedApiClient.Documents.cs b/InterlinedList/Services/InterlinedApiClient.Documents.cs
index 6b83da8..d12454e 100644
--- a/InterlinedList/Services/InterlinedApiClient.Documents.cs
+++ b/InterlinedList/Services/InterlinedApiClient.Documents.cs
@@ -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> 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>(JsonOptions) ?? new()
+ : new();
+ }
+
+ public Task CreateDocumentShareLinkAsync(string documentId, CancellationToken ct = default)
+ => SendJsonAsync(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);
}
diff --git a/InterlinedList/Services/InterlinedApiClient.Lists.cs b/InterlinedList/Services/InterlinedApiClient.Lists.cs
index 05fd413..55edfb4 100644
--- a/InterlinedList/Services/InterlinedApiClient.Lists.cs
+++ b/InterlinedList/Services/InterlinedApiClient.Lists.cs
@@ -83,4 +83,22 @@ public async Task> GetWatchingListsAsync(CancellationToken ct
? arr.Deserialize>(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> 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>(JsonOptions) ?? new()
+ : new();
+ }
+
+ public Task CreateListShareLinkAsync(string listId, CancellationToken ct = default)
+ => SendJsonAsync(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);
}
diff --git a/InterlinedList/ViewModels/DocumentsViewModel.cs b/InterlinedList/ViewModels/DocumentsViewModel.cs
index 4aa2c97..27ea094 100644
--- a/InterlinedList/ViewModels/DocumentsViewModel.cs
+++ b/InterlinedList/ViewModels/DocumentsViewModel.cs
@@ -14,6 +14,9 @@ public partial class DocumentsViewModel : ObservableObject
public ObservableCollection Folders { get; } = new();
public ObservableCollection Templates { get; } = new();
+ // Public share links for the currently-open document (empty when none open).
+ public ObservableCollection ShareLinks { get; } = new();
+
[ObservableProperty]
private bool isLoading;
@@ -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;
@@ -149,6 +220,7 @@ private async Task DeleteDocumentAsync(DocumentSummary doc)
SelectedDocument = null;
EditTitle = "";
EditContent = "";
+ ShareLinks.Clear();
}
ErrorMessage = null;
await LoadAsync();
@@ -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();
diff --git a/InterlinedList/ViewModels/ListsViewModel.cs b/InterlinedList/ViewModels/ListsViewModel.cs
index 39a730f..c31dd76 100644
--- a/InterlinedList/ViewModels/ListsViewModel.cs
+++ b/InterlinedList/ViewModels/ListsViewModel.cs
@@ -16,6 +16,7 @@ public partial class ListsViewModel : ObservableObject
public ObservableCollection Lists { get; } = new();
public ObservableCollection Rows { get; } = new();
public ObservableCollection SharedWithMe { get; } = new();
+ public ObservableCollection ShareLinks { get; } = new();
[ObservableProperty]
private bool isLoading;
@@ -116,6 +117,7 @@ private async Task DeleteListAsync(ListSummary list)
{
SelectedList = null;
Rows.Clear();
+ ShareLinks.Clear();
}
await LoadListsAsync();
}
@@ -151,6 +153,7 @@ private async Task SelectListAsync(ListSummary list)
SelectedSharedList = null;
SelectedList = list;
await LoadRowsAsync(list.Id);
+ await LoadShareLinksAsync(list.Id);
}
[RelayCommand]
@@ -161,9 +164,65 @@ private async Task SelectSharedListAsync(WatchedList watched)
SelectedSharedList = watched;
EditingRow = null;
EditRowJson = "";
+ ShareLinks.Clear();
await LoadRowsAsync(watched.Id);
}
+ private async Task LoadShareLinksAsync(string listId)
+ {
+ try
+ {
+ var links = await _session.Api.GetListShareLinksAsync(listId);
+
+ ShareLinks.Clear();
+ foreach (var link in links)
+ ShareLinks.Add(link);
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ private bool CanManageShareLinks() => SelectedList is not null && !IsViewingShared;
+
+ [RelayCommand(CanExecute = nameof(CanManageShareLinks))]
+ private async Task CreateShareLinkAsync()
+ {
+ if (SelectedList is not { } list) return;
+ try
+ {
+ await _session.Api.CreateListShareLinkAsync(list.Id);
+ await LoadShareLinksAsync(list.Id);
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task RevokeShareLinkAsync(ShareLink link)
+ {
+ if (SelectedList is not { } list) return;
+ try
+ {
+ await _session.Api.DeleteListShareLinkAsync(list.Id, link.Token);
+ await LoadShareLinksAsync(list.Id);
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private void CopyShareLink(ShareLink link)
+ {
+ if (string.IsNullOrEmpty(link.Url)) return;
+ System.Windows.Clipboard.SetText(link.Url);
+ }
+
private async Task LoadRowsAsync(string listId)
{
IsLoadingRows = true;
@@ -282,7 +341,13 @@ private async Task DeleteRowAsync(ListDataRow row)
partial void OnNewListTitleChanged(string value) => CreateListCommand.NotifyCanExecuteChanged();
- partial void OnSelectedListChanged(ListSummary? value) => AddRowCommand.NotifyCanExecuteChanged();
+ partial void OnSelectedListChanged(ListSummary? value)
+ {
+ AddRowCommand.NotifyCanExecuteChanged();
+ CreateShareLinkCommand.NotifyCanExecuteChanged();
+ }
+
+ partial void OnIsViewingSharedChanged(bool value) => CreateShareLinkCommand.NotifyCanExecuteChanged();
partial void OnNewRowJsonChanged(string value) => AddRowCommand.NotifyCanExecuteChanged();
}
diff --git a/InterlinedList/ViewModels/LoginViewModel.cs b/InterlinedList/ViewModels/LoginViewModel.cs
index 2c03eb1..09d084b 100644
--- a/InterlinedList/ViewModels/LoginViewModel.cs
+++ b/InterlinedList/ViewModels/LoginViewModel.cs
@@ -10,17 +10,93 @@ public partial class LoginViewModel : ObservableObject
[ObservableProperty]
private string email = "";
+ [ObservableProperty]
+ private string username = "";
+
+ [ObservableProperty]
+ private string displayName = "";
+
[ObservableProperty]
private string? errorMessage;
[ObservableProperty]
private bool isBusy;
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(PrimaryButtonText))]
+ [NotifyPropertyChangedFor(nameof(ToggleModeText))]
+ private bool isRegisterMode;
+
+ public string PrimaryButtonText => IsRegisterMode ? "Create account" : "Log In";
+ public string ToggleModeText => IsRegisterMode ? "Have an account? Log in" : "Create an account";
+
public LoginViewModel(SessionService session)
{
_session = session;
}
+ /// Register, then attempt an immediate login. Returns true only if the login succeeds.
+ public async Task RegisterAsync(string password)
+ {
+ if (string.IsNullOrWhiteSpace(Email) || string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(password))
+ {
+ ErrorMessage = "Enter an email, username, and password.";
+ return false;
+ }
+
+ IsBusy = true;
+ ErrorMessage = null;
+ try
+ {
+ await _session.Api.RegisterAsync(Email.Trim(), Username.Trim(), password,
+ string.IsNullOrWhiteSpace(DisplayName) ? null : DisplayName.Trim());
+ try
+ {
+ await _session.LoginAsync(Email.Trim(), password);
+ return true;
+ }
+ catch (InterlinedApiException)
+ {
+ ErrorMessage = "Account created. Verify your email, then log in.";
+ IsRegisterMode = false;
+ return false;
+ }
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ return false;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ public async Task ForgotPasswordAsync()
+ {
+ if (string.IsNullOrWhiteSpace(Email))
+ {
+ ErrorMessage = "Enter your email above first.";
+ return;
+ }
+
+ IsBusy = true;
+ try
+ {
+ await _session.Api.ForgotPasswordAsync(Email.Trim());
+ ErrorMessage = "If that email has an account, a reset link is on its way.";
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
// Password isn't an [ObservableProperty]: PasswordBox.Password can't be safely
// data-bound in WPF, so the code-behind passes the plaintext value in directly.
public async Task LoginAsync(string password)
diff --git a/InterlinedList/Views/DocumentsView.xaml b/InterlinedList/Views/DocumentsView.xaml
index 89c55a0..5880ce1 100644
--- a/InterlinedList/Views/DocumentsView.xaml
+++ b/InterlinedList/Views/DocumentsView.xaml
@@ -101,6 +101,16 @@
+
+
+
@@ -369,6 +379,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/ListsView.xaml b/InterlinedList/Views/ListsView.xaml
index 3528f01..18665f2 100644
--- a/InterlinedList/Views/ListsView.xaml
+++ b/InterlinedList/Views/ListsView.xaml
@@ -345,6 +345,96 @@
HorizontalScrollBarVisibility="Disabled"
Padding="20,12">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/the-gaps.md b/the-gaps.md
index 120cc5e..baad0d8 100644
--- a/the-gaps.md
+++ b/the-gaps.md
@@ -141,6 +141,27 @@ register/forgot-password, account-deletion UI, DM inbox-folder view.
---
+## Progress — Session 5 (2026-08-01) — sharing + auth self-service
+
+Live-verified share-link shape (POST → `{token,url,role,expiresAt}`, GET →
+`{shareLinks:[…]}`, DELETE by token; created+deleted a real test link). App
+builds clean in Debug + Release.
+
+**Shipped this session:**
+- ✅ **Public share links** for **Lists** and **Documents** — create / list /
+ revoke / copy-URL, gated to items you own.
+- ✅ **Auth self-service** — **Register** (email/username/password/display name)
+ and **Forgot password** in the login window (mode toggle + reset request).
+- Services: `RegisterAsync`, `ForgotPasswordAsync`, list + document share-link
+ create/list/delete, `ShareLink` model.
+
+**Remaining post-v1:** watcher/collaborator *member* management (undocumented
+request bodies + empty data — needs a second account), Materialize, GitHub
+(needs GitHub linked), billing handoff, account-deletion UI, DM inbox-folder,
+mutual-follows display, per-list schema/columns.
+
+---
+
## 1. Parity snapshot by domain
| Domain (product's name) | Web/API has | App has today | Status |