feat(web): account page with disconnect + delete-account
GET /account shows the registered display name, the signed-in email, the
user's connected video accounts (status + when), a Connect-YouTube link
when none is connected, and the disconnect / delete controls. Linked from
the header nav.
POST /account/disconnect/{provider}: deletes the OAuth token from the
SecretStore (resolved from the connection's own token_ref, provider-
agnostic) and the connection row. Does NOT delete the account.
POST /account/delete: confirm-before-destroy (a <details> disclosure gates
the destructive submit — works without JS). Captures token refs, calls
store.DeleteUser (cascades all rows), purges every secret, then routes to
/auth/logout to clear the session. Tapir-side only — Dex is left untouched
(decision 2026-06-03).
Account handlers depend on a narrow SecretRemover (Delete) and the extended
Store port; cmd/tapir serve shares one file-backed SecretStore between the
connect flow and account management.
Tests: account page renders connections + name + Connect link; disconnect
removes token (fake records Delete) + row and keeps the account; delete
wipes users/summaries/connections/identities and purges the token, then
redirects to logout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,20 @@ type Store interface {
|
||||
ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error)
|
||||
SetAction(ctx context.Context, userID, videoID, action string) error
|
||||
ClearAction(ctx context.Context, userID, videoID, action string) error
|
||||
|
||||
// Account management (the /account page, disconnect, delete-account).
|
||||
ConnectionsForUser(ctx context.Context, userID string) ([]store.Connection, error)
|
||||
DeleteConnection(ctx context.Context, userID, provider string) error
|
||||
DeleteUser(ctx context.Context, userID string) error
|
||||
DisplayName(ctx context.Context, userID string) (string, error)
|
||||
}
|
||||
|
||||
// SecretRemover deletes secret material by its opaque ref. *secrets.FileStore
|
||||
// satisfies it; account tests use a fake. The account handlers depend only on
|
||||
// this narrow capability (not the read-side ports.SecretStore), mirroring how the
|
||||
// connect flow depends on auth.TokenWriter for the write side.
|
||||
type SecretRemover interface {
|
||||
Delete(ref string) error
|
||||
}
|
||||
|
||||
// App is the Stage-1 web surface: handlers over the store, gated by an Auth
|
||||
@@ -37,6 +51,9 @@ type App struct {
|
||||
// nil (e.g. dev without YouTube client credentials), the /oauth/youtube/*
|
||||
// routes are not mounted.
|
||||
Connect *ConnectHandler
|
||||
// Secrets removes a user's OAuth tokens on disconnect / delete-account. The
|
||||
// account routes require it; cmd/tapir wires the file-backed store.
|
||||
Secrets SecretRemover
|
||||
}
|
||||
|
||||
func (a *App) logger() *slog.Logger {
|
||||
@@ -62,6 +79,12 @@ func (a *App) Router() http.Handler {
|
||||
app.HandleFunc("GET /register", a.handleRegisterForm)
|
||||
app.HandleFunc("POST /register", a.handleRegister)
|
||||
|
||||
// Account management: view connections, disconnect a provider, delete the
|
||||
// account. Gated like every app route, so CurrentUserID is set.
|
||||
app.HandleFunc("GET /account", a.handleAccount)
|
||||
app.HandleFunc("POST /account/disconnect/{provider}", a.handleDisconnect)
|
||||
app.HandleFunc("POST /account/delete", a.handleDeleteAccount)
|
||||
|
||||
// Web-initiated YouTube connect (ADR-006). Gated like every app route, so
|
||||
// CurrentUserID is set and the connection binds to the authenticated user.
|
||||
if a.Connect != nil {
|
||||
|
||||
Reference in New Issue
Block a user