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:
@@ -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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user