diff --git a/cmd/tapir/main.go b/cmd/tapir/main.go index 89ba294..f1db179 100644 --- a/cmd/tapir/main.go +++ b/cmd/tapir/main.go @@ -186,7 +186,10 @@ func cmdServe(ctx context.Context, log *slog.Logger) error { log.Warn("web auth: STUB allow-all (no TAPIR_OIDC_ISSUER) — local dev only, do not expose") } - app := &web.App{Store: st, Identity: st, Auth: authn, Log: log} + // The file-backed SecretStore is shared by the connect flow (writes tokens) + // and account management (deletes them on disconnect / delete-account). + secretStore := secrets.NewFileStore(cfg.SecretsFile) + app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log} // Web-initiated YouTube connect (ADR-006). Mounted only when the OAuth client // credentials are present; the refresh token persists through the SecretStore @@ -197,7 +200,7 @@ func cmdServe(ctx context.Context, log *slog.Logger) error { ClientID: cfg.YTClientID, ClientSecret: cfg.YTClientSecret, RedirectURL: cfg.YTConnectRedirectURL, - }, secrets.NewFileStore(cfg.SecretsFile), st, log) + }, secretStore, st, log) log.Info("web youtube connect enabled", "redirect", cfg.YTConnectRedirectURL) } diff --git a/internal/web/account.go b/internal/web/account.go new file mode 100644 index 0000000..1a6c792 --- /dev/null +++ b/internal/web/account.go @@ -0,0 +1,121 @@ +package web + +import ( + "net/http" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" +) + +// handleAccount renders the account page: the user's display name, the +// authenticated email, their connected video accounts (with a Connect link when +// YouTube is not connected), and the disconnect / delete-account controls. +func (a *App) handleAccount(w http.ResponseWriter, r *http.Request) { + userID, ok := a.currentUserID(w, r) + if !ok { + return + } + conns, err := a.Store.ConnectionsForUser(r.Context(), userID) + if err != nil { + a.serverError(w, r, "list connections", err) + return + } + name, err := a.Store.DisplayName(r.Context(), userID) + if err != nil { + a.serverError(w, r, "display name", err) + return + } + var email string + if u, ok := a.Auth.CurrentUser(r); ok { + email = u.Email + } + a.render(w, r, AccountPage(name, email, conns, takeFlash(w, r))) +} + +// handleDisconnect removes a provider connection: it deletes the OAuth token from +// the SecretStore (resolved from the connection's own token_ref) and the +// connection row. It does NOT delete the account. Redirects back to /account with +// a flash. Disconnecting an absent provider is a no-op (idempotent). +func (a *App) handleDisconnect(w http.ResponseWriter, r *http.Request) { + userID, ok := a.currentUserID(w, r) + if !ok { + return + } + provider := r.PathValue("provider") + if provider == "" { + http.Error(w, "missing provider", http.StatusBadRequest) + return + } + + conns, err := a.Store.ConnectionsForUser(r.Context(), userID) + if err != nil { + a.serverError(w, r, "list connections", err) + return + } + // Remove the token before the row, using the connection's own ref so this is + // provider-agnostic. A SecretStore failure is logged, not fatal — the row + // removal below still revokes access from Tapir's side. + if ref := tokenRefFor(conns, provider); ref != "" && a.Secrets != nil { + if err := a.Secrets.Delete(ref); err != nil { + a.logger().Error("disconnect: delete token", "provider", provider, "err", err) + } + } + if err := a.Store.DeleteConnection(r.Context(), userID, provider); err != nil { + a.serverError(w, r, "delete connection", err) + return + } + + setFlash(w, flashDisconnected) + http.Redirect(w, r, "/account", http.StatusSeeOther) +} + +// handleDeleteAccount permanently deletes the user: it removes all tapir data +// (DeleteUser cascades the rows) and every one of the user's secrets, then logs +// the user out. Tapir-side only (decision 2026-06-03) — the Dex identity is left +// untouched, so a later login simply re-enters registration. +func (a *App) handleDeleteAccount(w http.ResponseWriter, r *http.Request) { + userID, ok := a.currentUserID(w, r) + if !ok { + return + } + + // Capture the secret refs BEFORE the rows are deleted — DeleteUser cascades + // the video_connections away. + conns, err := a.Store.ConnectionsForUser(r.Context(), userID) + if err != nil { + a.serverError(w, r, "list connections", err) + return + } + if err := a.Store.DeleteUser(r.Context(), userID); err != nil { + a.serverError(w, r, "delete user", err) + return + } + // Best-effort secret cleanup: the account is already gone, so a SecretStore + // failure is logged, never resurrects the account. + if a.Secrets != nil { + for _, c := range conns { + if c.TokenRef == "" { + continue + } + if err := a.Secrets.Delete(c.TokenRef); err != nil { + a.logger().Error("delete account: delete token", "ref", c.TokenRef, "err", err) + } + } + } + + // Clear the session by routing through the auth logout endpoint, then land on + // the list page with a flash (StubAuth logout is a no-op; Dex clears the + // session cookie and redirects to login). + setFlash(w, flashDeleted) + http.Redirect(w, r, "/auth/logout", http.StatusSeeOther) +} + +// tokenRefFor returns the SecretStore ref for the user's connection to provider, +// or "" if there is none. +func tokenRefFor(conns []store.Connection, provider string) string { + for _, c := range conns { + if c.Provider == provider { + return c.TokenRef + } + } + return "" +} diff --git a/internal/web/account_test.go b/internal/web/account_test.go new file mode 100644 index 0000000..79e8cde --- /dev/null +++ b/internal/web/account_test.go @@ -0,0 +1,153 @@ +package web_test + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" + "gitea.d-ma.be/mathias/tapir/internal/web" +) + +// fakeSecrets is a SecretRemover that records the refs it was asked to delete, so +// account tests can assert the OAuth token cleanup without a real secret file. +type fakeSecrets struct { + mu sync.Mutex + deleted []string +} + +func (f *fakeSecrets) Delete(ref string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.deleted = append(f.deleted, ref) + return nil +} + +func (f *fakeSecrets) deletedRefs() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.deleted...) +} + +// newAccountApp builds the App as the registered stub user with a recording fake +// SecretStore, so disconnect/delete can be exercised end-to-end. +func newAccountApp(t *testing.T) (*web.App, *fakeSecrets) { + t.Helper() + s := newStore(t) + fs := &fakeSecrets{} + return &web.App{ + Store: s, + Identity: s, + Auth: web.StubAuth{U: web.User{Subject: stubSubject, Email: "ada@example.com"}}, + Secrets: fs, + }, fs +} + +func seedConnection(t *testing.T, app *web.App, provider, account, ref string) { + t.Helper() + require.NoError(t, app.Store.(*store.Store).UpsertConnection(context.Background(), userID, store.Connection{ + Provider: provider, + ProviderAccount: account, + TokenRef: ref, + Status: "active", + })) +} + +func TestAccountPageShowsConnectionAndName(t *testing.T) { + ctx := context.Background() + app, _ := newAccountApp(t) + p := rawPool(t) + resetDB(t, p) + _, err := p.Exec(ctx, `UPDATE users SET display_name = $1 WHERE id = $2`, "Ada", userID) + require.NoError(t, err) + seedConnection(t, app, "youtube", "ada@channel", web.YouTubeTokenRef(userID)) + + rec := do(t, app, httptest.NewRequest(http.MethodGet, "/account", nil)) + require.Equal(t, http.StatusOK, rec.Code) + html := body(t, rec) + + require.Contains(t, html, "Ada", "display name shown") + require.Contains(t, html, "ada@example.com", "signed-in email shown") + require.Contains(t, html, "ada@channel", "connected account shown") + require.Contains(t, html, "YouTube") + require.Contains(t, html, "/account/disconnect/youtube", "a Disconnect control is present") + require.NotContains(t, html, "/oauth/youtube/connect", "no Connect link while already connected") + // Confirm-before-destroy: the delete is behind a disclosure, not a bare button. + require.Contains(t, html, "/account/delete") + require.Contains(t, html, " summary { display: inline-block; list-style: none; cursor: pointer; font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid #d9534f; border-radius: var(--radius); background: transparent; color: #c0392b; } +.confirm-delete > summary::-webkit-details-marker { display: none; } +.confirm-delete > summary:hover { background: #fce8e6; } +.confirm-delete[open] > summary { margin-bottom: var(--s3); } +.confirm-body { border: 1px solid #d9534f; border-radius: var(--radius); padding: var(--s3); background: #fce8e6; color: #8a1c10; } +.btn-danger { font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid #d9534f; border-radius: var(--radius); background: #d9534f; color: #fff; cursor: pointer; } +.btn-danger:hover { filter: brightness(1.05); } +.btn-danger:focus-visible { outline: 2px solid #d9534f; outline-offset: 1px; } +@media (prefers-color-scheme: dark) { + .confirm-body { background: #3a1714; color: #f3b5ae; } + .confirm-delete > summary { color: #f3b5ae; } + .confirm-delete > summary:hover { background: #3a1714; } +} + @media (max-width: 640px) { main { padding: var(--s3) var(--s2); } .filters { gap: var(--s2); } diff --git a/internal/web/views.templ b/internal/web/views.templ index 0bb3949..5198730 100644 --- a/internal/web/views.templ +++ b/internal/web/views.templ @@ -198,6 +198,70 @@ templ RegisterPage(email, errMsg string) { } } +// AccountPage is the account-management view: the registered display name and +// signed-in email, the user's connected video accounts (each with a Disconnect +// control), a Connect-YouTube link when none is connected, and the delete-account +// danger zone. flash surfaces a one-shot notification (disconnect/connect). +templ AccountPage(displayName, email string, conns []store.Connection, flash string) { + @Layout("Tapir — Account") { + @flashBanner(flash) +
+

Account

+ +
+

Connected accounts

+ if len(conns) == 0 { +

No connected video accounts yet.

+ } else { +
    + for _, c := range conns { +
  • +
    + { providerLabel(c.Provider) } + if c.ProviderAccount != "" { + { c.ProviderAccount } + } + { c.Status } +
    +
    connected { c.ConnectedAt.Format("2006-01-02") }
    +
    + +
    +
  • + } +
+ } + if !hasYouTube(conns) { +

Connect YouTube

+ } +
+
+

Delete account

+

+ Permanently remove your Tapir account and all of its data — summaries, + watch/skip/save actions, and connected accounts. This cannot be undone. +

+
+ Delete account… +
+

This permanently deletes your account and all data. Are you sure?

+
+ +
+
+
+
+
+ } +} + // ActionButtons is the toggle group fragment returned by POST /v/{id}/action. // Each button submits its verb; HTMX swaps this element in place (outerHTML), // and without JS the form POSTs and the handler redirects back to the detail diff --git a/internal/web/views_templ.go b/internal/web/views_templ.go index 2a5a353..8ea23b9 100644 --- a/internal/web/views_templ.go +++ b/internal/web/views_templ.go @@ -767,11 +767,11 @@ func RegisterPage(email, errMsg string) templ.Component { }) } -// ActionButtons is the toggle group fragment returned by POST /v/{id}/action. -// Each button submits its verb; HTMX swaps this element in place (outerHTML), -// and without JS the form POSTs and the handler redirects back to the detail -// page. active marks the verbs currently set for (user, video). -func ActionButtons(videoID string, active map[string]bool) templ.Component { +// AccountPage is the account-management view: the registered display name and +// signed-in email, the user's connected video accounts (each with a Disconnect +// control), a Connect-YouTube link when none is connected, and the delete-account +// danger zone. flash surfaces a one-shot notification (disconnect/connect). +func AccountPage(displayName, email string, conns []store.Connection, flash string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -792,112 +792,309 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component { templ_7745c5c3_Var34 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - for _, v := range actionVerbs { - var templ_7745c5c3_Var37 = []any{"action", templ.KV("active", active[v])} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var37...) + templ_7745c5c3_Var35 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = flashBanner(flash).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "") + if !hasYouTube(conns) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "

Connect YouTube

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "

Delete account

Permanently remove your Tapir account and all of its data — summaries, watch/skip/save actions, and connected accounts. This cannot be undone.

Delete account…

This permanently deletes your account and all data. Are you sure?

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout("Tapir — Account").Render(templ.WithChildren(ctx, templ_7745c5c3_Var35), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// ActionButtons is the toggle group fragment returned by POST /v/{id}/action. +// Each button submits its verb; HTMX swaps this element in place (outerHTML), +// and without JS the form POSTs and the handler redirects back to the detail +// page. active marks the verbs currently set for (user, video). +func ActionButtons(videoID string, active map[string]bool) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var43 := templ.GetChildren(ctx) + if templ_7745c5c3_Var43 == nil { + templ_7745c5c3_Var43 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, v := range actionVerbs { + var templ_7745c5c3_Var46 = []any{"action", templ.KV("active", active[v])} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var46...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }