From ac6b8aa28936869a11ea9b468ba8fcec6da39f19 Mon Sep 17 00:00:00 2001 From: Emil Haukeland Date: Mon, 6 Apr 2026 10:37:52 +0200 Subject: [PATCH 1/2] Fix flaky browser tests and test-e2e timeout - Update theme tests to use new [data-theme-value] switcher UI (old [data-theme-toggle] selector was removed, causing MustElement to hang) - Add MustWaitStable after navigation in TestNewRoomModal to prevent stale execution context panic (~5% repro rate) - Remove -parallel 4 from Makefile test-e2e target (27% slower than default GOMAXPROCS parallelism) Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 2 +- internal/browser/room_panel_test.go | 1 + internal/browser/theme_test.go | 24 ++++++++++++------------ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index 84c39a5..eaf6979 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ test-go: # Browser E2E tests using go-rod + headless Chromium. # Requires Chromium to be installed (rod auto-downloads if not found). test-e2e: - go test ./internal/browser/... -v -timeout 120s -parallel 4 + go test ./internal/browser/... -v -timeout 120s # JS lint via Biome. lint: diff --git a/internal/browser/room_panel_test.go b/internal/browser/room_panel_test.go index bee7188..ea8b6c6 100644 --- a/internal/browser/room_panel_test.go +++ b/internal/browser/room_panel_test.go @@ -183,6 +183,7 @@ func TestNewRoomModal(t *testing.T) { nav := page.WaitNavigation(proto.PageLifecycleEventNameLoad) page.MustElement("#new-room-dialog form [type=submit]").MustClick() nav() + page.MustWaitStable() // Should be redirected to the new room page with the correct title. assert.Contains(t, page.MustInfo().URL, "/rooms/", "should be redirected to the new room") diff --git a/internal/browser/theme_test.go b/internal/browser/theme_test.go index b046fb8..9428ded 100644 --- a/internal/browser/theme_test.go +++ b/internal/browser/theme_test.go @@ -16,12 +16,12 @@ import ( const themeRoom = "room-theme" -// clickThemeToggle opens the profile popover then clicks the theme-toggle -// button using real pointer events, so the test fails if an overlay is -// blocking pointer input. -func clickThemeToggle(page *rod.Page) { +// clickThemeOption opens the settings dialog and clicks the theme switcher +// button for the given value ("auto", "light", or "dark"). +func clickThemeOption(page *rod.Page, value string) { page.MustElement("#profile-btn").MustClick() - page.MustElement("[data-theme-toggle]").MustClick() + page.MustElement("#open-settings-btn").MustClick() + page.MustElement("[data-theme-value=\"" + value + "\"]").MustClick() } // TestThemeToggle_DarkOS verifies that on a dark-mode OS the first click on the @@ -64,14 +64,14 @@ func TestThemeToggle_DarkOS(t *testing.T) { initial := page.MustEval(`() => document.documentElement.getAttribute('data-theme')`).Str() assert.Equal(t, "auto", initial, "initial data-theme should be 'auto' with no stored preference") - // One click on a dark-mode OS should go to 'light', not 'dark'. - clickThemeToggle(page) + // Clicking 'light' on a dark-mode OS should set data-theme to 'light'. + clickThemeOption(page, "light") theme := page.MustEval(`() => document.documentElement.getAttribute('data-theme')`).Str() - assert.Equal(t, "light", theme, "first click on dark OS should set data-theme to 'light'") + assert.Equal(t, "light", theme, "clicking light should set data-theme to 'light'") stored := page.MustEval(`() => localStorage.getItem('theme')`).Str() - assert.Equal(t, "light", stored, "localStorage should store 'light' after first click on dark OS") + assert.Equal(t, "light", stored, "localStorage should store 'light'") } // TestThemeToggle_LightOS verifies that on a light-mode OS the first click on @@ -109,9 +109,9 @@ func TestThemeToggle_LightOS(t *testing.T) { page.MustNavigate(ts.Server.URL + "/rooms/" + themeRoom) page.MustWaitLoad() - // One click on a light-mode OS should go to 'dark'. - clickThemeToggle(page) + // Clicking 'dark' on a light-mode OS should set data-theme to 'dark'. + clickThemeOption(page, "dark") theme := page.MustEval(`() => document.documentElement.getAttribute('data-theme')`).Str() - assert.Equal(t, "dark", theme, "first click on light OS should set data-theme to 'dark'") + assert.Equal(t, "dark", theme, "clicking dark should set data-theme to 'dark'") } From b452be86979d602b44c0170799d9d8c27671737a Mon Sep 17 00:00:00 2001 From: Emil Haukeland Date: Mon, 6 Apr 2026 11:06:23 +0200 Subject: [PATCH 2/2] Add user profile and settings dialogs Profile dialog (lazy-loaded via HTMX) lets users edit their display name (with server-side uniqueness), disconnect OAuth providers, and delete their account with a safety guard. Settings dialog replaces the old single-button theme toggle with a three-option switcher (auto/light/dark). Supporting changes: - OAuth callback supports "connect provider" flow for logged-in users - UpsertUser no longer overwrites display name from provider - Redis: name index, session tracking, identity unlink, account cascade delete - Seed missing name indexes at startup for existing users Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 8 + internal/auth/oauth.go | 34 ++- internal/browser/profile_test.go | 506 +++++++++++++++++++++++++++++++ internal/handler/profile.go | 206 +++++++++++++ internal/handler/rooms.go | 6 +- internal/redis/client.go | 201 +++++++++++- internal/testutil/testutil.go | 14 + internal/tmpl/render.go | 10 + main.go | 20 ++ web/static/app/theme.js | 26 +- web/static/style.css | 188 ++++++++++++ web/templates/profile.html | 88 ++++++ web/templates/room.html | 159 +++++++++- 13 files changed, 1439 insertions(+), 27 deletions(-) create mode 100644 internal/browser/profile_test.go create mode 100644 internal/handler/profile.go create mode 100644 web/templates/profile.html diff --git a/CLAUDE.md b/CLAUDE.md index 27225d0..b2d5f54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,7 @@ internal/ # hydrateMessages() — user+unfurl+reactions per message # sendPushNotifications() — async Web Push after save notifications.go # Push subscribe/unsubscribe, mute, VAPID key, room members + profile.go # GET/PATCH/DELETE /user/profile; POST /user/identities/{provider}/disconnect reactions.go # POST /rooms/{id}/messages/{msgID}/reactions — toggle, SSE broadcast upload.go # GET /rooms/{id}/upload-url — presigned S3 PUT (optional) sse.go # GET /rooms/{id}/events — Redis Pub/Sub → SSE fan-out @@ -73,6 +74,7 @@ web/ reactions.html # Reactions bar partial (used standalone for SSE reaction events) history.html # Infinite-scroll history partial (sentinel + messages) unfurl.html # Link preview card partial + profile.html # Profile section partial (lazy-loaded in settings dialog via HTMX) login.html # Login page (GitHub button; optional password form via PasswordAuthEnabled) error.html # Error page static/ @@ -120,6 +122,10 @@ POST /settings/mute — set mute duration (1h/8h/24h/168h DELETE /settings/mute — clear mute GET /user/events — user-level SSE stream (unread badges, future cross-room events) +GET /user/profile — profile section partial (HTMX, lazy-loaded in settings dialog) +PATCH /user/profile — update display name (unique; re-renders profile partial) +POST /user/profile/delete — delete account (requires confirmation="DELETE"); HX-Redirect → /login +POST /user/identities/{provider}/disconnect — unlink OAuth provider (refused if last auth method) GET /sw.js — Service Worker (root scope; no-cache) GET /static/* — embedded static files (immutable cache) @@ -137,7 +143,9 @@ users:{uuid} Hash id, name, avatar_url, email, cre users:{uuid}:identities Set "{provider}:{providerUserID}" members (no TTL) users:{uuid}:push_subscriptions Hash endpoint → subscriptionJSON (no TTL) users:{uuid}:mute_until String unix ms timestamp or "forever"; TTL = mute duration (or none) +users:{uuid}:sessions Set session tokens for this user (no TTL; for cascade delete) identities:{provider}:{providerUserID} String canonical uuid (no TTL) +name_index:{lowercase_name} String canonical uuid (no TTL; for display name uniqueness) rooms ZSet room IDs scored by creation time (unix seconds) rooms:{id} Hash id, name rooms:{id}:messages ZSet message IDs scored by created_at (unix ms); cleaned on write diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 8d2b7a5..555ccda 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -210,6 +210,23 @@ func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) { return } + // If the user is already logged in, this is a "connect provider" flow. + if existingUser := h.resolveExistingSession(r); existingUser != nil { + identityOwner, _ := h.Redis.GetUserByIdentity(r.Context(), identity.Provider, identity.ProviderUserID) + if identityOwner != nil && identityOwner.ID != existingUser.ID { + http.Redirect(w, r, "/?error=identity_taken", http.StatusFound) + return + } + if identityOwner == nil { + if err := h.Redis.LinkIdentity(r.Context(), existingUser.ID, identity.Provider, identity.ProviderUserID); err != nil { + http.Error(w, "failed to link identity", http.StatusInternalServerError) + return + } + } + http.Redirect(w, r, "/?settings=profile", http.StatusFound) + return + } + if err := h.createSession(r.Context(), w, identity); err != nil { http.Error(w, "failed to create session", http.StatusInternalServerError) return @@ -217,6 +234,18 @@ func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/rooms/bemro", http.StatusFound) } +// resolveExistingSession checks if the request has a valid session cookie. +// Returns the user if logged in, nil otherwise. Used to detect the "connect +// provider" flow in HandleCallback. +func (h *Handler) resolveExistingSession(r *http.Request) *model.User { + token, err := TokenFromRequest(r, h.SessionSecret) + if err != nil { + return nil + } + user, _ := h.Redis.GetSession(r.Context(), token) + return user +} + // HandleLogout deletes the session and clears the cookie. func (h *Handler) HandleLogout(w http.ResponseWriter, r *http.Request) { token, err := TokenFromRequest(r, h.SessionSecret) @@ -252,9 +281,8 @@ func (h *Handler) createSession(ctx context.Context, w http.ResponseWriter, iden return fmt.Errorf("link identity: %w", err) } } else { - // Known identity — refresh display name and avatar from the provider - // in case the user has updated their profile since last login. - user.Name = identity.Name + // Known identity — refresh avatar from the provider but preserve the + // user-chosen display name (editable via profile settings). user.AvatarURL = identity.AvatarURL if err := h.Redis.UpsertUser(ctx, *user); err != nil { return fmt.Errorf("upsert user: %w", err) diff --git a/internal/browser/profile_test.go b/internal/browser/profile_test.go new file mode 100644 index 0000000..a9a9d8b --- /dev/null +++ b/internal/browser/profile_test.go @@ -0,0 +1,506 @@ +//go:build !short + +package browser_test + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/emilhauk/msg/internal/model" + "github.com/emilhauk/msg/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const profileRoomBase = "room-profile" + +// --------------------------------------------------------------------------- +// Settings dialog (theme) +// --------------------------------------------------------------------------- + +// TestSettings_OpenClose verifies the settings dialog opens from the profile +// popover gear item and closes via the X button. +func TestSettings_OpenClose(t *testing.T) { + t.Parallel() + + room := profileRoomBase + "-settings-oc" + ts := testutil.NewTestServer(t) + ts.SeedRoom(t, model.Room{ID: room, Name: "Settings OC"}) + require.NoError(t, ts.Redis.CreateUser(context.Background(), alice)) + + b := newBrowser(t) + page := authPage(t, b, ts, alice, room) + + // Open profile popover, click Settings. + page.MustElement("#profile-btn").MustClick() + page.MustElement("#open-settings-btn").MustClick() + + page.Timeout(3 * time.Second).MustElement("#settings-dialog[open]") + + // Close via X button. + page.MustElement("#settings-close").MustClick() + time.Sleep(300 * time.Millisecond) + open := page.MustEval(`() => document.getElementById('settings-dialog').open`).Bool() + assert.False(t, open, "settings dialog should be closed") +} + +// TestSettings_ThemeSwitcher verifies the three-point pill selector persists +// the chosen theme to localStorage and updates the data-theme attribute. +func TestSettings_ThemeSwitcher(t *testing.T) { + t.Parallel() + + room := profileRoomBase + "-theme" + ts := testutil.NewTestServer(t) + ts.SeedRoom(t, model.Room{ID: room, Name: "Theme"}) + require.NoError(t, ts.Redis.CreateUser(context.Background(), alice)) + + b := newBrowser(t) + page := authPage(t, b, ts, alice, room) + + // Open settings dialog. + page.MustElement("#profile-btn").MustClick() + page.MustElement("#open-settings-btn").MustClick() + page.Timeout(3 * time.Second).MustElement("#settings-dialog[open]") + + // Click "Dark". + page.MustElement(`[data-theme-value="dark"]`).MustClick() + theme := page.MustEval(`() => document.documentElement.getAttribute('data-theme')`).Str() + assert.Equal(t, "dark", theme) + stored := page.MustEval(`() => localStorage.getItem('theme')`).Str() + assert.Equal(t, "dark", stored) + darkChecked := page.MustEval(`() => document.querySelector('[data-theme-value="dark"]').getAttribute('aria-checked')`).Str() + assert.Equal(t, "true", darkChecked) + + // Click "Light". + page.MustElement(`[data-theme-value="light"]`).MustClick() + theme = page.MustEval(`() => document.documentElement.getAttribute('data-theme')`).Str() + assert.Equal(t, "light", theme) + + // Click "Auto". + page.MustElement(`[data-theme-value="auto"]`).MustClick() + theme = page.MustEval(`() => document.documentElement.getAttribute('data-theme')`).Str() + assert.Equal(t, "auto", theme) +} + +// --------------------------------------------------------------------------- +// Profile dialog +// --------------------------------------------------------------------------- + +// TestProfile_OpenAndLoadsContent verifies the profile dialog opens from the +// popover, lazy-loads the profile section via HTMX, and shows the user's name. +func TestProfile_OpenAndLoadsContent(t *testing.T) { + t.Parallel() + + room := profileRoomBase + "-open" + ts := testutil.NewTestServer(t) + ts.SeedRoom(t, model.Room{ID: room, Name: "Profile Open"}) + require.NoError(t, ts.Redis.CreateUser(context.Background(), alice)) + + b := newBrowser(t) + page := authPage(t, b, ts, alice, room) + + // Open profile dialog. + page.MustElement("#profile-btn").MustClick() + page.MustElement("#open-profile-btn").MustClick() + page.Timeout(3 * time.Second).MustElement("#profile-dialog[open]") + + // Wait for HTMX to load the profile section. + nameInput := page.Timeout(5 * time.Second).MustElement("#display-name") + val := nameInput.MustProperty("value").Str() + assert.Equal(t, "Alice", val) +} + +// TestProfile_EditName verifies that changing the display name updates the +// backend and the profile popover name (via OOB swap). +func TestProfile_EditName(t *testing.T) { + t.Parallel() + + room := profileRoomBase + "-editname" + ts := testutil.NewTestServer(t) + ts.SeedRoom(t, model.Room{ID: room, Name: "Edit Name"}) + require.NoError(t, ts.Redis.CreateUser(context.Background(), alice)) + + b := newBrowser(t) + page := authPage(t, b, ts, alice, room) + + // Open profile dialog. + page.MustElement("#profile-btn").MustClick() + page.MustElement("#open-profile-btn").MustClick() + page.Timeout(5 * time.Second).MustElement("#display-name") + + // Clear and type new name. + nameInput := page.MustElement("#display-name") + nameInput.MustSelectAllText().MustInput("Alice Renamed") + + // Submit. + page.MustElement(`#profile-section form[hx-patch] button[type="submit"]`).MustClick() + + // Wait for HTMX to re-render profile section with success message. + page.Timeout(5 * time.Second).MustElement(".settings-field__success") + + // Verify the input shows the new name. + val := page.MustElement("#display-name").MustProperty("value").Str() + assert.Equal(t, "Alice Renamed", val) + + // Verify the popover name was updated via OOB swap. + popoverName := page.MustElement("#profile-popover-name").MustText() + assert.Equal(t, "Alice Renamed", popoverName) + + // Verify backend state. + u, err := ts.Redis.GetUser(context.Background(), alice.ID) + require.NoError(t, err) + assert.Equal(t, "Alice Renamed", u.Name) +} + +// TestProfile_EditNameDuplicate verifies that submitting a name that is already +// taken by another user shows an error. +func TestProfile_EditNameDuplicate(t *testing.T) { + t.Parallel() + + room := profileRoomBase + "-dupname" + ts := testutil.NewTestServer(t) + ts.SeedRoom(t, model.Room{ID: room, Name: "Dup Name"}) + require.NoError(t, ts.Redis.CreateUser(context.Background(), alice)) + require.NoError(t, ts.Redis.CreateUser(context.Background(), bob)) + + b := newBrowser(t) + page := authPage(t, b, ts, alice, room) + + // Open profile dialog. + page.MustElement("#profile-btn").MustClick() + page.MustElement("#open-profile-btn").MustClick() + page.Timeout(5 * time.Second).MustElement("#display-name") + + // Try to change name to Bob's name. + nameInput := page.MustElement("#display-name") + nameInput.MustSelectAllText().MustInput("Bob") + page.MustElement(`#profile-section form[hx-patch] button[type="submit"]`).MustClick() + + // Error message should appear. + errEl := page.Timeout(5 * time.Second).MustElement(".settings-field__error") + assert.Contains(t, errEl.MustText(), "already taken") + + // Backend should NOT have changed. + u, err := ts.Redis.GetUser(context.Background(), alice.ID) + require.NoError(t, err) + assert.Equal(t, "Alice", u.Name) +} + +// TestProfile_DeleteAccount verifies the full delete flow: checkbox + typing +// DELETE enables the button, submitting deletes the account and redirects. +func TestProfile_DeleteAccount(t *testing.T) { + t.Parallel() + + room := profileRoomBase + "-delete" + ts := testutil.NewTestServer(t) + ts.SeedRoom(t, model.Room{ID: room, Name: "Delete"}) + + victim := model.User{ID: "user-victim", Name: "Victim", Email: "victim@example.com"} + require.NoError(t, ts.Redis.CreateUser(context.Background(), victim)) + + b := newBrowser(t) + page := authPage(t, b, ts, victim, room) + + // Open profile dialog. + page.MustElement("#profile-btn").MustClick() + page.MustElement("#open-profile-btn").MustClick() + page.Timeout(5 * time.Second).MustElement("#display-name") + + // Delete button should be disabled initially. + disabled := page.MustEval(`() => document.getElementById('delete-account-btn').disabled`).Bool() + assert.True(t, disabled, "delete button should be disabled initially") + + // Check the checkbox — button should still be disabled (need text too). + page.MustElement("#delete-confirm-check").MustClick() + disabled = page.MustEval(`() => document.getElementById('delete-account-btn').disabled`).Bool() + assert.True(t, disabled, "delete button should be disabled with only checkbox") + + // Type "DELETE" — button should become enabled. + page.MustElement("#delete-confirm-text").MustInput("DELETE") + time.Sleep(100 * time.Millisecond) + disabled = page.MustEval(`() => document.getElementById('delete-account-btn').disabled`).Bool() + assert.False(t, disabled, "delete button should be enabled after checkbox + DELETE") + + // Click delete. + page.MustElement("#delete-account-btn").MustClick() + + // HTMX processes HX-Redirect header and navigates to /login. + waitURLChange(t, page, "/rooms/", 10*time.Second) + + // User should be gone from Redis. + u, err := ts.Redis.GetUser(context.Background(), victim.ID) + require.NoError(t, err) + assert.Nil(t, u, "user should be deleted from Redis") +} + +// TestProfile_DeleteGuardPartialInput verifies the delete button stays disabled +// when only one guard condition is met. +func TestProfile_DeleteGuardPartialInput(t *testing.T) { + t.Parallel() + + room := profileRoomBase + "-delguard" + ts := testutil.NewTestServer(t) + ts.SeedRoom(t, model.Room{ID: room, Name: "DelGuard"}) + require.NoError(t, ts.Redis.CreateUser(context.Background(), alice)) + + b := newBrowser(t) + page := authPage(t, b, ts, alice, room) + + page.MustElement("#profile-btn").MustClick() + page.MustElement("#open-profile-btn").MustClick() + page.Timeout(5 * time.Second).MustElement("#display-name") + + // Type "DELETE" without checking the box. + page.MustElement("#delete-confirm-text").MustInput("DELETE") + time.Sleep(100 * time.Millisecond) + disabled := page.MustEval(`() => document.getElementById('delete-account-btn').disabled`).Bool() + assert.True(t, disabled, "delete button should be disabled without checkbox") + + // Type wrong text with checkbox checked. + page.MustElement("#delete-confirm-text").MustSelectAllText().MustInput("delete") // lowercase + page.MustElement("#delete-confirm-check").MustClick() + time.Sleep(100 * time.Millisecond) + disabled = page.MustEval(`() => document.getElementById('delete-account-btn').disabled`).Bool() + assert.True(t, disabled, "delete button should be disabled with wrong case") +} + +// TestProfile_DisconnectProvider verifies that a connected provider can be +// disconnected when the user has multiple auth methods. +func TestProfile_DisconnectProvider(t *testing.T) { + t.Parallel() + + room := profileRoomBase + "-disconnect" + ts := testutil.NewTestServer(t) + ts.SeedRoom(t, model.Room{ID: room, Name: "Disconnect"}) + + user := model.User{ID: "user-multi", Name: "Multi", Email: "multi@example.com"} + require.NoError(t, ts.Redis.CreateUser(context.Background(), user)) + // Link two identities so disconnect is allowed. + require.NoError(t, ts.Redis.LinkIdentity(context.Background(), user.ID, "github", "gh-123")) + require.NoError(t, ts.Redis.LinkIdentity(context.Background(), user.ID, "google", "g-456")) + + b := newBrowser(t) + page := authPage(t, b, ts, user, room) + + page.MustElement("#profile-btn").MustClick() + page.MustElement("#open-profile-btn").MustClick() + page.Timeout(5 * time.Second).MustElement("#display-name") + + // Both providers should be shown as connected with Disconnect buttons. + buttons := page.MustElements(`#profile-section form[hx-post*="/disconnect"] button`) + assert.Equal(t, 2, len(buttons), "should show 2 disconnect buttons") + + // Disconnect GitHub. + page.MustElement(`form[hx-post*="/github/disconnect"] button`).MustClick() + time.Sleep(500 * time.Millisecond) + + // After re-render, GitHub should no longer have a disconnect button (it shows Connect). + identities, err := ts.Redis.GetUserIdentities(context.Background(), user.ID) + require.NoError(t, err) + assert.Equal(t, 1, len(identities), "should have 1 identity left") + assert.Equal(t, "google:g-456", identities[0]) +} + +// TestProfile_DisconnectLastProviderBlocked verifies that the user cannot +// disconnect their only auth method. +func TestProfile_DisconnectLastProviderBlocked(t *testing.T) { + t.Parallel() + + room := profileRoomBase + "-lastprov" + ts := testutil.NewTestServer(t) + ts.SeedRoom(t, model.Room{ID: room, Name: "LastProv"}) + + user := model.User{ID: "user-single", Name: "Single", Email: "single@example.com"} + require.NoError(t, ts.Redis.CreateUser(context.Background(), user)) + require.NoError(t, ts.Redis.LinkIdentity(context.Background(), user.ID, "github", "gh-only")) + + b := newBrowser(t) + page := authPage(t, b, ts, user, room) + + page.MustElement("#profile-btn").MustClick() + page.MustElement("#open-profile-btn").MustClick() + page.Timeout(5 * time.Second).MustElement("#display-name") + + // With only one identity, the disconnect button should not be present; + // instead "Only sign-in method" text should appear. + hasDisconnect := page.MustEval(`() => !!document.querySelector('form[hx-post*="/disconnect"]')`).Bool() + assert.False(t, hasDisconnect, "should not show disconnect button for last provider") + + onlyText := page.MustElement(".settings-identity__only").MustText() + assert.Contains(t, onlyText, "Only sign-in method") + + // Identity should still be linked. + identities, err := ts.Redis.GetUserIdentities(context.Background(), user.ID) + require.NoError(t, err) + assert.Equal(t, 1, len(identities)) +} + +// --------------------------------------------------------------------------- +// Authorization: cannot modify another user's profile +// --------------------------------------------------------------------------- + +// TestProfile_CannotDeleteOtherUser verifies that a DELETE /user/profile +// request always deletes the authenticated user, not an arbitrary user. +// (The endpoint has no user-ID parameter — it operates on the session user.) +func TestProfile_CannotDeleteOtherUser(t *testing.T) { + t.Parallel() + + ts := testutil.NewTestServer(t) + room := profileRoomBase + "-authz-del" + ts.SeedRoom(t, model.Room{ID: room, Name: "Authz Del"}) + + require.NoError(t, ts.Redis.CreateUser(context.Background(), alice)) + require.NoError(t, ts.Redis.CreateUser(context.Background(), bob)) + ts.GrantAccess(t, room, alice.ID) + ts.GrantAccess(t, room, bob.ID) + + // Alice tries to delete — it should delete HER account, not Bob's. + cookie := ts.AuthCookie(t, alice) + form := url.Values{"confirmation": {"DELETE"}} + req, err := http.NewRequest("POST", + ts.Server.URL+"/user/profile/delete", + strings.NewReader(form.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Alice should be gone. + u, err := ts.Redis.GetUser(context.Background(), alice.ID) + require.NoError(t, err) + assert.Nil(t, u, "alice should be deleted") + + // Bob should still exist. + u, err = ts.Redis.GetUser(context.Background(), bob.ID) + require.NoError(t, err) + assert.NotNil(t, u, "bob should still exist") + assert.Equal(t, "Bob", u.Name) +} + +// TestProfile_CannotRenameOtherUser verifies that a PATCH /user/profile +// request only renames the authenticated user's profile. +func TestProfile_CannotRenameOtherUser(t *testing.T) { + t.Parallel() + + ts := testutil.NewTestServer(t) + room := profileRoomBase + "-authz-rename" + ts.SeedRoom(t, model.Room{ID: room, Name: "Authz Rename"}) + + require.NoError(t, ts.Redis.CreateUser(context.Background(), alice)) + require.NoError(t, ts.Redis.CreateUser(context.Background(), bob)) + ts.GrantAccess(t, room, alice.ID) + + // Alice sends PATCH — it can only change her own name. + cookie := ts.AuthCookie(t, alice) + form := url.Values{"name": {"Hacked"}} + req, err := http.NewRequest("PATCH", + ts.Server.URL+"/user/profile", + strings.NewReader(form.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Alice's name should be changed. + u, err := ts.Redis.GetUser(context.Background(), alice.ID) + require.NoError(t, err) + assert.Equal(t, "Hacked", u.Name) + + // Bob's name must not be affected. + u, err = ts.Redis.GetUser(context.Background(), bob.ID) + require.NoError(t, err) + assert.Equal(t, "Bob", u.Name) +} + +// TestProfile_CannotDisconnectOtherUserProvider verifies that a disconnect +// request only affects the authenticated user's identities. +func TestProfile_CannotDisconnectOtherUserProvider(t *testing.T) { + t.Parallel() + + ts := testutil.NewTestServer(t) + room := profileRoomBase + "-authz-disc" + ts.SeedRoom(t, model.Room{ID: room, Name: "Authz Disc"}) + + require.NoError(t, ts.Redis.CreateUser(context.Background(), alice)) + require.NoError(t, ts.Redis.CreateUser(context.Background(), bob)) + // Alice has github + google. + require.NoError(t, ts.Redis.LinkIdentity(context.Background(), alice.ID, "github", "a-gh")) + require.NoError(t, ts.Redis.LinkIdentity(context.Background(), alice.ID, "google", "a-g")) + // Bob has github. + require.NoError(t, ts.Redis.LinkIdentity(context.Background(), bob.ID, "github", "b-gh")) + + ts.GrantAccess(t, room, alice.ID) + + // Alice disconnects her github — this should NOT affect Bob's github. + cookie := ts.AuthCookie(t, alice) + req, err := http.NewRequest("POST", + ts.Server.URL+"/user/identities/github/disconnect", nil) + require.NoError(t, err) + req.AddCookie(cookie) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + + // Alice should have only google left. + aliceIdents, err := ts.Redis.GetUserIdentities(context.Background(), alice.ID) + require.NoError(t, err) + assert.Equal(t, []string{"google:a-g"}, aliceIdents) + + // Bob's github should be untouched. + bobIdents, err := ts.Redis.GetUserIdentities(context.Background(), bob.ID) + require.NoError(t, err) + assert.Equal(t, []string{"github:b-gh"}, bobIdents) +} + +// TestProfile_UnauthenticatedReturns302 verifies that profile endpoints +// redirect to /login when there is no session. +func TestProfile_UnauthenticatedReturns302(t *testing.T) { + t.Parallel() + + ts := testutil.NewTestServer(t) + + client := testutil.NoRedirectClient() + for _, path := range []string{"/user/profile"} { + req, err := http.NewRequest("GET", ts.Server.URL+path, nil) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusFound, resp.StatusCode, "GET %s without auth", path) + assert.Contains(t, resp.Header.Get("Location"), "/login") + } + + // PATCH without auth. + form := url.Values{"name": {"Hacker"}} + req, err := http.NewRequest("PATCH", + ts.Server.URL+"/user/profile", + strings.NewReader(form.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := client.Do(req) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusFound, resp.StatusCode, "PATCH without auth") + + // POST delete without auth. + form = url.Values{"confirmation": {"DELETE"}} + req, err = http.NewRequest("POST", + ts.Server.URL+"/user/profile/delete", + strings.NewReader(form.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err = client.Do(req) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusFound, resp.StatusCode, "DELETE without auth") +} diff --git a/internal/handler/profile.go b/internal/handler/profile.go new file mode 100644 index 0000000..5336d4a --- /dev/null +++ b/internal/handler/profile.go @@ -0,0 +1,206 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/emilhauk/msg/internal/auth" + "github.com/emilhauk/msg/internal/middleware" + redisclient "github.com/emilhauk/msg/internal/redis" + "github.com/emilhauk/msg/internal/tmpl" +) + +// ProfileHandler handles user profile management endpoints. +type ProfileHandler struct { + Redis *redisclient.Client + Renderer *tmpl.Renderer + SessionSecret []byte + Secure bool + GitHubEnabled bool + GoogleEnabled bool +} + +type identityInfo struct { + Provider string + ProviderLabel string + Connected bool +} + +type profileData struct { + Name string + Identities []identityInfo + CanDisconnect bool // true if user has >1 auth method + NameError string + NameSuccess bool +} + +// HandleProfile renders the profile section partial (HTMX). +// GET /user/profile +func (h *ProfileHandler) HandleProfile(w http.ResponseWriter, r *http.Request) { + user := middleware.UserFromContext(r.Context()) + data := h.buildProfileData(r, user.ID, user.Name, "", false) + h.Renderer.RenderPartial(w, http.StatusOK, "profile.html", data) +} + +// HandleUpdateName updates the user's display name. +// PATCH /user/profile +func (h *ProfileHandler) HandleUpdateName(w http.ResponseWriter, r *http.Request) { + user := middleware.UserFromContext(r.Context()) + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + newName := strings.TrimSpace(r.FormValue("name")) + if newName == "" || len(newName) > 50 { + data := h.buildProfileData(r, user.ID, user.Name, "Name must be 1–50 characters", false) + h.Renderer.RenderPartial(w, http.StatusOK, "profile.html", data) + return + } + + // Atomically claim the new name index. + oldName := user.Name + if !strings.EqualFold(oldName, newName) { + claimed, err := h.Redis.ClaimNameIndex(r.Context(), newName, user.ID) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !claimed { + data := h.buildProfileData(r, user.ID, user.Name, "That name is already taken", false) + h.Renderer.RenderPartial(w, http.StatusOK, "profile.html", data) + return + } + _ = h.Redis.DeleteNameIndex(r.Context(), oldName) + } + + if err := h.Redis.UpdateUserName(r.Context(), user.ID, newName); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + data := h.buildProfileData(r, user.ID, newName, "", true) + h.Renderer.RenderPartial(w, http.StatusOK, "profile.html", data) +} + +// HandleDisconnect unlinks an OAuth provider from the user's account. +// POST /user/identities/{provider}/disconnect +func (h *ProfileHandler) HandleDisconnect(w http.ResponseWriter, r *http.Request) { + user := middleware.UserFromContext(r.Context()) + provider := r.PathValue("provider") + if provider != "github" && provider != "google" { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + identities, err := h.Redis.GetUserIdentities(r.Context(), user.ID) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + hasPassword, _ := h.Redis.GetUserPassword(r.Context(), user.ID) + authMethodCount := len(identities) + if hasPassword != "" { + authMethodCount++ + } + + if authMethodCount <= 1 { + data := h.buildProfileData(r, user.ID, user.Name, "", false) + h.Renderer.RenderPartial(w, http.StatusOK, "profile.html", data) + return + } + + // Find the matching identity to unlink. + found := false + for _, ident := range identities { + parts := strings.SplitN(ident, ":", 2) + if len(parts) == 2 && parts[0] == provider { + if err := h.Redis.UnlinkIdentity(r.Context(), user.ID, parts[0], parts[1]); err != nil { + http.Error(w, "failed to disconnect provider", http.StatusInternalServerError) + return + } + found = true + break + } + } + if !found { + http.Error(w, "provider not connected", http.StatusBadRequest) + return + } + + data := h.buildProfileData(r, user.ID, user.Name, "", false) + h.Renderer.RenderPartial(w, http.StatusOK, "profile.html", data) +} + +// HandleDelete permanently deletes the user's account. +// POST /user/profile/delete +func (h *ProfileHandler) HandleDelete(w http.ResponseWriter, r *http.Request) { + user := middleware.UserFromContext(r.Context()) + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + if r.FormValue("confirmation") != "DELETE" { + http.Error(w, "invalid confirmation", http.StatusBadRequest) + return + } + + if err := h.Redis.DeleteUser(r.Context(), user.ID); err != nil { + http.Error(w, "failed to delete account", http.StatusInternalServerError) + return + } + + auth.ClearCookie(w, h.Secure) + w.Header().Set("HX-Redirect", "/login") + w.WriteHeader(http.StatusOK) +} + +func (h *ProfileHandler) buildProfileData(r *http.Request, userID, name, nameError string, nameSuccess bool) profileData { + identities, _ := h.Redis.GetUserIdentities(r.Context(), userID) + hasPassword, _ := h.Redis.GetUserPassword(r.Context(), userID) + authMethodCount := len(identities) + if hasPassword != "" { + authMethodCount++ + } + + // Build identity list with all configured providers. + type providerDef struct { + key string + label string + enabled bool + } + providers := []providerDef{ + {"github", "GitHub", h.GitHubEnabled}, + {"google", "Google", h.GoogleEnabled}, + } + + connectedSet := make(map[string]bool) + for _, ident := range identities { + parts := strings.SplitN(ident, ":", 2) + if len(parts) == 2 { + connectedSet[parts[0]] = true + } + } + + var infoList []identityInfo + for _, p := range providers { + if !p.enabled && !connectedSet[p.key] { + continue + } + infoList = append(infoList, identityInfo{ + Provider: p.key, + ProviderLabel: p.label, + Connected: connectedSet[p.key], + }) + } + + return profileData{ + Name: name, + Identities: infoList, + CanDisconnect: authMethodCount > 1, + NameError: nameError, + NameSuccess: nameSuccess, + } +} diff --git a/internal/handler/rooms.go b/internal/handler/rooms.go index 4855c55..bee43e0 100644 --- a/internal/handler/rooms.go +++ b/internal/handler/rooms.go @@ -38,7 +38,11 @@ func (h *RoomsHandler) HandleRoot(w http.ResponseWriter, r *http.Request) { user := middleware.UserFromContext(r.Context()) rooms, err := h.Redis.GetAccessibleRooms(r.Context(), user.ID) if err == nil && len(rooms) > 0 { - http.Redirect(w, r, "/rooms/"+rooms[0].ID, http.StatusFound) + dest := "/rooms/" + rooms[0].ID + if q := r.URL.RawQuery; q != "" { + dest += "?" + q + } + http.Redirect(w, r, dest, http.StatusFound) return } h.Renderer.Render(w, http.StatusOK, "no-rooms.html", map[string]any{ diff --git a/internal/redis/client.go b/internal/redis/client.go index e9685ec..a1dd6ab 100644 --- a/internal/redis/client.go +++ b/internal/redis/client.go @@ -10,6 +10,7 @@ import ( "fmt" "sort" "strconv" + "strings" "time" "github.com/rs/zerolog/log" @@ -77,6 +78,8 @@ func (c *Client) Close() error { return c.rdb.Close() } // --------------------------------------------------------------------------- // SetSession stores session data for a given token, refreshing TTL. +// It also records the token in the user's session set so that all sessions +// can be invalidated when the account is deleted. func (c *Client) SetSession(ctx context.Context, token string, user model.User) error { key := "sessions:" + token pipe := c.rdb.Pipeline() @@ -86,6 +89,7 @@ func (c *Client) SetSession(ctx context.Context, token string, user model.User) "avatar_url", user.AvatarURL, ) pipe.Expire(ctx, key, sessionTTL) + pipe.SAdd(ctx, "users:"+user.ID+":sessions", token) _, err := pipe.Exec(ctx) return err } @@ -114,23 +118,28 @@ func (c *Client) DeleteSession(ctx context.Context, token string) error { // Users // --------------------------------------------------------------------------- -// CreateUser writes a new canonical user to Redis. Only called the first time -// an identity is seen. For subsequent logins, use UpsertUser to refresh -// display name and avatar. +// CreateUser writes a new canonical user to Redis and claims the display name +// in the name index. Only called the first time an identity is seen. func (c *Client) CreateUser(ctx context.Context, user model.User) error { - return c.rdb.HSet(ctx, "users:"+user.ID, + if err := c.rdb.HSet(ctx, "users:"+user.ID, "id", user.ID, "name", user.Name, "avatar_url", user.AvatarURL, "email", user.Email, "created_at", user.CreatedAt, - ).Err() + ).Err(); err != nil { + return err + } + if user.Name != "" { + c.rdb.SetNX(ctx, "name_index:"+strings.ToLower(user.Name), user.ID, 0) //nolint:errcheck + } + return nil } -// UpsertUser refreshes the display name and avatar for an existing canonical user. +// UpsertUser refreshes the avatar for an existing canonical user. The display +// name is managed separately via the profile settings and is not overwritten. func (c *Client) UpsertUser(ctx context.Context, user model.User) error { return c.rdb.HSet(ctx, "users:"+user.ID, - "name", user.Name, "avatar_url", user.AvatarURL, ).Err() } @@ -244,6 +253,184 @@ func (c *Client) LinkIdentity(ctx context.Context, userID, provider, providerUse return err } +// GetUserIdentities returns all identity strings for a user (e.g. "github:123"). +func (c *Client) GetUserIdentities(ctx context.Context, userID string) ([]string, error) { + return c.rdb.SMembers(ctx, "users:"+userID+":identities").Result() +} + +// UnlinkIdentity removes a provider identity from a user. +func (c *Client) UnlinkIdentity(ctx context.Context, userID, provider, providerUserID string) error { + pipe := c.rdb.Pipeline() + pipe.Del(ctx, "identities:"+provider+":"+providerUserID) + pipe.SRem(ctx, "users:"+userID+":identities", provider+":"+providerUserID) + _, err := pipe.Exec(ctx) + return err +} + +// ClaimNameIndex atomically claims a display name for a user. Returns true if +// the name was successfully claimed (or was already owned by the same user). +func (c *Client) ClaimNameIndex(ctx context.Context, name, userID string) (bool, error) { + key := "name_index:" + strings.ToLower(name) + ok, err := c.rdb.SetNX(ctx, key, userID, 0).Result() + if err != nil { + return false, err + } + if ok { + return true, nil + } + // Key exists — check if it's already ours (e.g. case change). + owner, err := c.rdb.Get(ctx, key).Result() + if err != nil { + return false, err + } + if owner == userID { + return true, nil + } + return false, nil +} + +// DeleteNameIndex releases a display name. +func (c *Client) DeleteNameIndex(ctx context.Context, name string) error { + return c.rdb.Del(ctx, "name_index:"+strings.ToLower(name)).Err() +} + +// SeedNameIndexes scans all user hashes and creates missing name_index entries. +// This is an idempotent migration that runs at startup so that the uniqueness +// constraint works for users created before the name index was introduced. +func (c *Client) SeedNameIndexes(ctx context.Context) error { + var cursor uint64 + for { + keys, next, err := c.rdb.Scan(ctx, cursor, "users:*", 100).Result() + if err != nil { + return fmt.Errorf("scan users: %w", err) + } + for _, key := range keys { + // Skip sub-keys like users:{id}:identities, users:{id}:sessions, etc. + if strings.Count(key, ":") != 1 { + continue + } + name, err := c.rdb.HGet(ctx, key, "name").Result() + if err != nil || name == "" { + continue + } + id, err := c.rdb.HGet(ctx, key, "id").Result() + if err != nil || id == "" { + continue + } + // SetNX — only creates if not already claimed. + c.rdb.SetNX(ctx, "name_index:"+strings.ToLower(name), id, 0) //nolint:errcheck + } + cursor = next + if cursor == 0 { + break + } + } + return nil +} + +// UpdateUserName updates the display name on the user hash and all active sessions. +// Expired session tokens are pruned from the tracking set. +func (c *Client) UpdateUserName(ctx context.Context, userID, newName string) error { + if err := c.rdb.HSet(ctx, "users:"+userID, "name", newName).Err(); err != nil { + return err + } + + setKey := "users:" + userID + ":sessions" + tokens, _ := c.rdb.SMembers(ctx, setKey).Result() + if len(tokens) == 0 { + return nil + } + + pipe := c.rdb.Pipeline() + var stale []string + for _, token := range tokens { + exists, _ := c.rdb.Exists(ctx, "sessions:"+token).Result() + if exists == 0 { + stale = append(stale, token) + continue + } + pipe.HSet(ctx, "sessions:"+token, "name", newName) + } + // Prune expired session tokens from the tracking set. + for _, token := range stale { + pipe.SRem(ctx, setKey, token) + } + _, err := pipe.Exec(ctx) + return err +} + +// DeleteUserSessions removes all tracked sessions for a user. +func (c *Client) DeleteUserSessions(ctx context.Context, userID string) error { + setKey := "users:" + userID + ":sessions" + tokens, err := c.rdb.SMembers(ctx, setKey).Result() + if err != nil { + return err + } + if len(tokens) == 0 { + return nil + } + pipe := c.rdb.Pipeline() + for _, token := range tokens { + pipe.Del(ctx, "sessions:"+token) + } + pipe.Del(ctx, setKey) + _, err = pipe.Exec(ctx) + return err +} + +// DeleteUser cascade-removes all data associated with a user account. +func (c *Client) DeleteUser(ctx context.Context, userID string) error { + // Collect user data needed for cleanup. + userKey := "users:" + userID + vals, err := c.rdb.HGetAll(ctx, userKey).Result() + if err != nil { + return fmt.Errorf("get user: %w", err) + } + + identities, _ := c.rdb.SMembers(ctx, "users:"+userID+":identities").Result() + + // Remove all sessions first. + _ = c.DeleteUserSessions(ctx, userID) + + pipe := c.rdb.Pipeline() + + // Identity mappings. + for _, ident := range identities { + pipe.Del(ctx, "identities:"+ident) + } + pipe.Del(ctx, "users:"+userID+":identities") + + // Email index. + if email := vals["email"]; email != "" { + pipe.Del(ctx, "email_index:"+email) + } + + // Name index. + if name := vals["name"]; name != "" { + pipe.Del(ctx, "name_index:"+strings.ToLower(name)) + } + + // Password, push subscriptions, mute. + pipe.Del(ctx, "users:"+userID+":password") + pipe.Del(ctx, "users:"+userID+":push_subscriptions") + pipe.Del(ctx, "users:"+userID+":mute_until") + + // Remove from all rooms. + roomIDs, _ := c.rdb.ZRange(ctx, "rooms", 0, -1).Result() + for _, roomID := range roomIDs { + pipe.SRem(ctx, "rooms:"+roomID+":access", userID) + pipe.ZRem(ctx, "rooms:"+roomID+":members", userID) + pipe.Del(ctx, "users:"+userID+":rooms:"+roomID+":last_active") + pipe.Del(ctx, "users:"+userID+":rooms:"+roomID+":viewing") + } + + // User hash itself. + pipe.Del(ctx, userKey) + + _, err = pipe.Exec(ctx) + return err +} + // --------------------------------------------------------------------------- // Rooms // --------------------------------------------------------------------------- diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index dba3450..42d9a6a 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -183,6 +183,20 @@ func buildMux(rc *redisclient.Client, renderer *tmpl.Renderer, webFS fs.FS, secr mux.Handle("POST /rooms/{id}/inactive", authMW(http.HandlerFunc(notificationsHandler.HandleRoomInactive))) mux.Handle("GET /user/events", authMW(http.HandlerFunc(sseHandler.HandleUserSSE))) + // User profile routes. + profileHandler := &handler.ProfileHandler{ + Redis: rc, + Renderer: renderer, + SessionSecret: secret, + Secure: false, + GitHubEnabled: true, + GoogleEnabled: true, + } + mux.Handle("GET /user/profile", authMW(http.HandlerFunc(profileHandler.HandleProfile))) + mux.Handle("PATCH /user/profile", authMW(http.HandlerFunc(profileHandler.HandleUpdateName))) + mux.Handle("POST /user/profile/delete", authMW(http.HandlerFunc(profileHandler.HandleDelete))) + mux.Handle("POST /user/identities/{provider}/disconnect", authMW(http.HandlerFunc(profileHandler.HandleDisconnect))) + // Push notification routes. mux.HandleFunc("GET /push/vapid-public-key", notificationsHandler.HandleVAPIDPublicKey) mux.Handle("POST /push/subscribe", authMW(http.HandlerFunc(notificationsHandler.HandleSubscribe))) diff --git a/internal/tmpl/render.go b/internal/tmpl/render.go index 0299e30..a195a1d 100644 --- a/internal/tmpl/render.go +++ b/internal/tmpl/render.go @@ -405,6 +405,16 @@ func New(fsys fs.FS) (*Renderer, error) { r.templates[name] = t } + // profile.html — user profile section, loaded lazily in settings dialog. + { + name := "profile.html" + t, err := template.New(name).Funcs(funcMap).ParseFS(fsys, "templates/profile.html") + if err != nil { + return nil, fmt.Errorf("tmpl: parse partial profile.html: %w", err) + } + r.templates[name] = t + } + return r, nil } diff --git a/main.go b/main.go index 9c90f3b..35af8d2 100644 --- a/main.go +++ b/main.go @@ -68,6 +68,11 @@ func main() { log.Fatal().Err(err).Msg("seed room") } + // Ensure all existing users have a name_index entry for uniqueness checks. + if err := redis.SeedNameIndexes(context.Background()); err != nil { + log.Warn().Err(err).Msg("seed name indexes") + } + // Templates. webSubFS, err := fs.Sub(webFS, "web") if err != nil { @@ -153,6 +158,15 @@ func main() { VAPIDPublicKey: vapidCfg.VAPIDPublicKey, } + profileHandler := &handler.ProfileHandler{ + Redis: redis, + Renderer: renderer, + SessionSecret: sessionSecret, + Secure: strings.HasPrefix(baseURL, "https://"), + GitHubEnabled: os.Getenv("GITHUB_CLIENT_ID") != "", + GoogleEnabled: os.Getenv("GOOGLE_CLIENT_ID") != "", + } + secure := strings.HasPrefix(baseURL, "https://") authMW := middleware.RequireAuth(redis, sessionSecret, secure) @@ -250,6 +264,12 @@ func main() { mux.Handle("POST /rooms/{id}/inactive", authMW(http.HandlerFunc(notificationsHandler.HandleRoomInactive))) mux.Handle("GET /user/events", authMW(http.HandlerFunc(sseHandler.HandleUserSSE))) + // User profile routes. + mux.Handle("GET /user/profile", authMW(http.HandlerFunc(profileHandler.HandleProfile))) + mux.Handle("PATCH /user/profile", authMW(http.HandlerFunc(profileHandler.HandleUpdateName))) + mux.Handle("POST /user/profile/delete", authMW(http.HandlerFunc(profileHandler.HandleDelete))) + mux.Handle("POST /user/identities/{provider}/disconnect", authMW(http.HandlerFunc(profileHandler.HandleDisconnect))) + // Push notification routes. mux.HandleFunc("GET /push/vapid-public-key", notificationsHandler.HandleVAPIDPublicKey) mux.Handle("POST /push/subscribe", authMW(http.HandlerFunc(notificationsHandler.HandleSubscribe))) diff --git a/web/static/app/theme.js b/web/static/app/theme.js index 26b4029..bd31241 100644 --- a/web/static/app/theme.js +++ b/web/static/app/theme.js @@ -1,17 +1,23 @@ -// Theme toggle: persist choice in localStorage, fall back to OS preference. +// Theme: persist choice in localStorage, fall back to OS preference. const root = document.documentElement; const stored = localStorage.getItem('theme'); if (stored) root.setAttribute('data-theme', stored); +function syncThemeSwitcher() { + const current = root.getAttribute('data-theme') || 'auto'; + document.querySelectorAll('[data-theme-value]').forEach((btn) => { + btn.setAttribute('aria-checked', btn.dataset.themeValue === current ? 'true' : 'false'); + }); +} + document.addEventListener('click', (e) => { - const btn = e.target.closest('[data-theme-toggle]'); + const btn = e.target.closest('[data-theme-value]'); if (!btn) return; - const current = root.getAttribute('data-theme'); - const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - const next = current === 'dark' ? 'light' - : current === 'light' ? 'auto' - : isDark ? 'light' : 'dark'; - root.setAttribute('data-theme', next); - localStorage.setItem('theme', next); - btn.setAttribute('aria-label', `Theme: ${next}`); + const value = btn.dataset.themeValue; + root.setAttribute('data-theme', value); + localStorage.setItem('theme', value); + syncThemeSwitcher(); }); + +syncThemeSwitcher(); +window.__syncThemeSwitcher = syncThemeSwitcher; diff --git a/web/static/style.css b/web/static/style.css index 536a80b..aaf0600 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -642,6 +642,194 @@ img { display: block; max-width: 100%; } :root[data-theme="dark"] .leave-room-btn { background: color-mix(in srgb, var(--color-danger) 10%, transparent); border-color: var(--color-danger); } :root[data-theme="dark"] .leave-room-btn:hover { background: transparent; border-color: color-mix(in srgb, var(--color-danger) 30%, transparent); } +/* ---- Settings dialog ---- */ +.settings-dialog { + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 0; + width: min(400px, calc(100vw - 2rem)); + box-shadow: 0 8px 32px rgba(0,0,0,.18); + background: var(--color-bg); + color: var(--color-text); + position: fixed; + top: 15vh; + left: 50%; + transform: translateX(-50%); + margin: 0; + animation: new-room-in 180ms ease both; +} +.settings-dialog.is-closing { animation: new-room-out 140ms ease both; } +.settings-dialog::backdrop { background: rgba(0,0,0,.45); } + +.settings-dialog__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--color-border); +} +.settings-dialog__title { font-size: 1rem; font-weight: 600; margin: 0; } + +.settings-dialog__close { + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border: none; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + border-radius: var(--radius-sm); +} +.settings-dialog__close:hover { background: var(--color-bg-subtle); color: var(--color-text); } +.settings-dialog__close svg { width: 1rem; height: 1rem; } + +.settings-dialog__body { padding: 1.25rem; } + +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} +.settings-row__label { + display: flex; + align-items: center; + gap: .5rem; + font-size: .9375rem; + font-weight: 500; +} +.settings-row__icon { width: 1.25rem; height: 1.25rem; flex-shrink: 0; } + +/* Theme segmented control */ +.theme-switcher { + display: flex; + background: var(--color-bg-subtle); + border-radius: var(--radius-pill); + padding: 3px; + gap: 2px; +} +.theme-switcher__option { + font-family: inherit; + font-size: .8125rem; + font-weight: 500; + padding: .3rem .75rem; + border: none; + border-radius: var(--radius-pill); + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: background .15s, color .15s, box-shadow .15s; + white-space: nowrap; +} +.theme-switcher__option:hover { color: var(--color-text); } +.theme-switcher__option[aria-checked="true"] { + background: var(--color-bg); + color: var(--color-text); + box-shadow: var(--shadow-sm); +} + +.settings-dialog__body { max-height: calc(80vh - 3.5rem); overflow-y: auto; } +.settings-divider { border: none; border-top: 1px solid var(--color-border); margin: 1rem 0; } +.settings-loading { font-size: .875rem; color: var(--color-text-muted); padding: .5rem 0; } + +/* Settings sections */ +.settings-section { margin-bottom: 1.25rem; } +.settings-section__header { + display: flex; + align-items: center; + gap: .5rem; + margin-bottom: .75rem; +} +.settings-section__icon { width: 1.25rem; height: 1.25rem; flex-shrink: 0; } +.settings-section__title { font-size: .9375rem; font-weight: 600; margin: 0; } + +/* Settings form fields */ +.settings-field { margin-bottom: .75rem; } +.settings-field__label { + display: block; + font-size: .8125rem; + font-weight: 500; + color: var(--color-text-muted); + margin-bottom: .375rem; +} +.settings-field__row { display: flex; gap: .5rem; align-items: center; } +.settings-field__input { + flex: 1; + padding: .4rem .625rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg); + color: var(--color-text); + font-family: inherit; + font-size: .875rem; + box-sizing: border-box; +} +.settings-field__input:focus { outline: 2px solid var(--color-primary); outline-offset: -1px; } +.settings-field__error { margin: .375rem 0 0; font-size: .8125rem; color: var(--color-danger); } +.settings-field__success { margin: .375rem 0 0; font-size: .8125rem; color: #16a34a; } + +/* Identity list */ +.settings-identity-list { display: flex; flex-direction: column; gap: .5rem; } +.settings-identity { + display: flex; + align-items: center; + justify-content: space-between; + padding: .375rem .625rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} +.settings-identity__provider { + display: flex; + align-items: center; + gap: .5rem; + font-size: .875rem; + font-weight: 500; +} +.settings-identity__icon { width: 1rem; height: 1rem; flex-shrink: 0; } +.settings-identity__only { font-size: .75rem; color: var(--color-text-muted); font-style: italic; } + +/* Ghost button for disconnect */ +.btn--ghost { + background: transparent; + border-color: transparent; + color: var(--color-text-muted); +} +.btn--ghost:hover { background: var(--color-bg-subtle); color: var(--color-text); } + +/* Danger button */ +.btn--danger { + background: var(--color-danger); + border-color: var(--color-danger); + color: #fff; +} +.btn--danger:hover { background: #dc2626; border-color: #dc2626; } +.btn--danger:disabled { + opacity: .45; + cursor: not-allowed; +} + +/* Danger zone */ +.settings-section--danger { + border: 1px solid color-mix(in srgb, var(--color-danger) 30%, transparent); + border-radius: var(--radius-md); + padding: 1rem; + margin-top: 1rem; +} +.settings-section--danger .settings-section__title { color: var(--color-danger); } +.settings-danger__desc { font-size: .8125rem; color: var(--color-text-muted); margin: 0 0 .75rem; } +.settings-danger__guard { display: flex; flex-direction: column; gap: .625rem; margin-bottom: .75rem; } +.settings-danger__check { + display: flex; + align-items: center; + gap: .5rem; + font-size: .8125rem; + cursor: pointer; +} +.settings-danger__check input[type="checkbox"] { accent-color: var(--color-danger); } +.settings-danger__type { display: flex; flex-direction: column; gap: .25rem; font-size: .8125rem; } + /* Command autocomplete description text */ .command-autocomplete__desc { margin-left: auto; color: var(--color-text-muted); font-size: .8125rem; } diff --git a/web/templates/profile.html b/web/templates/profile.html new file mode 100644 index 0000000..29974f9 --- /dev/null +++ b/web/templates/profile.html @@ -0,0 +1,88 @@ +{{define "profile.html"}} +{{if .NameSuccess}}

{{.Name}}

{{end}} +
+
+
+ +

Profile

+
+ +
+ +
+ + +
+ {{if .NameError}}

{{.NameError}}

{{end}} + {{if .NameSuccess}}

Name updated

{{end}} +
+ +
+ Connected accounts +
+ {{range .Identities}} +
+ + {{if eq .Provider "github"}} + + {{else if eq .Provider "google"}} + + {{end}} + {{.ProviderLabel}} + + {{if .Connected}} + {{if $.CanDisconnect}} +
+ +
+ {{else}} + Only sign-in method + {{end}} + {{else}} + Connect + {{end}} +
+ {{end}} +
+
+
+ +
+

Danger zone

+

Permanently delete your account and all associated data. This cannot be undone.

+
+ + +
+
+ + +
+
+
+{{end}} diff --git a/web/templates/room.html b/web/templates/room.html index 13ea277..256e64b 100644 --- a/web/templates/room.html +++ b/web/templates/room.html @@ -56,14 +56,21 @@ + +
+

Settings

+ +
+
+
+
+ + Theme +
+
+ + + +
+
+
+
+ + +
+

Profile

+ +
+
+
+
Loading...
+
+
+
+

New room

@@ -380,6 +433,100 @@

Enable notifications

} }()); + // ---- Settings dialog ---- + (function () { + var dialog = document.getElementById('settings-dialog'); + var openBtn = document.getElementById('open-settings-btn'); + var closeBtn = document.getElementById('settings-close'); + if (!dialog || !openBtn) return; + + function closeSettingsDialog() { + dialog.classList.add('is-closing'); + dialog.addEventListener('animationend', function handler() { + dialog.removeEventListener('animationend', handler); + dialog.classList.remove('is-closing'); + dialog.close(); + }); + } + + openBtn.addEventListener('click', function () { + var pop = document.getElementById('profile-popover'); + if (pop) pop.hidden = true; + if (window.__syncThemeSwitcher) window.__syncThemeSwitcher(); + dialog.showModal(); + }); + + closeBtn.addEventListener('click', closeSettingsDialog); + dialog.addEventListener('click', function (e) { + if (e.target === dialog) closeSettingsDialog(); + }); + dialog.addEventListener('cancel', function (e) { + e.preventDefault(); + closeSettingsDialog(); + }); + }()); + + // ---- Profile dialog ---- + (function () { + var dialog = document.getElementById('profile-dialog'); + var openBtn = document.getElementById('open-profile-btn'); + var closeBtn = document.getElementById('profile-close'); + if (!dialog || !openBtn) return; + + function closeProfileDialog() { + dialog.classList.add('is-closing'); + dialog.addEventListener('animationend', function handler() { + dialog.removeEventListener('animationend', handler); + dialog.classList.remove('is-closing'); + dialog.close(); + }); + } + + function openProfileDialog() { + var pop = document.getElementById('profile-popover'); + if (pop) pop.hidden = true; + dialog.showModal(); + var ps = document.getElementById('profile-section'); + if (ps && typeof htmx !== 'undefined') htmx.trigger(ps, 'load-profile'); + } + + openBtn.addEventListener('click', openProfileDialog); + closeBtn.addEventListener('click', closeProfileDialog); + dialog.addEventListener('click', function (e) { + if (e.target === dialog) closeProfileDialog(); + }); + dialog.addEventListener('cancel', function (e) { + e.preventDefault(); + closeProfileDialog(); + }); + + // Auto-open profile when redirected back from OAuth connect. + if (new URLSearchParams(window.location.search).get('settings') === 'profile') { + history.replaceState(null, '', window.location.pathname); + openProfileDialog(); + } + }()); + + // ---- Delete account safety guard ---- + (function () { + function syncDeleteGuard() { + var check = document.getElementById('delete-confirm-check'); + var text = document.getElementById('delete-confirm-text'); + var btn = document.getElementById('delete-account-btn'); + var hidden = document.getElementById('delete-confirm-hidden'); + if (!check || !text || !btn) return; + var ok = check.checked && text.value === 'DELETE'; + btn.disabled = !ok; + if (hidden) hidden.value = text.value; + } + document.addEventListener('input', function (e) { + if (e.target.id === 'delete-confirm-text') syncDeleteGuard(); + }); + document.addEventListener('change', function (e) { + if (e.target.id === 'delete-confirm-check') syncDeleteGuard(); + }); + }()); + // ---- Leave room ---- (function () { var dialog = document.getElementById('leave-room-dialog');