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 } auto, err := a.Store.GetAutoSummarize(r.Context(), userID) if err != nil { a.serverError(w, r, "summarize mode", err) return } a.render(w, r, AccountPage(name, email, conns, auto, 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 "" }