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:
@@ -199,6 +199,43 @@ func flashFor(code string) (flashView, bool) {
|
||||
return f, ok
|
||||
}
|
||||
|
||||
// providerLabels maps a provider key to its display name for the account page.
|
||||
var providerLabels = map[string]string{
|
||||
"youtube": "YouTube",
|
||||
"vimeo": "Vimeo",
|
||||
}
|
||||
|
||||
func providerLabel(p string) string {
|
||||
if l, ok := providerLabels[p]; ok {
|
||||
return l
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// displayNameOr falls back to a placeholder when the user has no display name set.
|
||||
func displayNameOr(name string) string {
|
||||
if name == "" {
|
||||
return "(not set)"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// hasYouTube reports whether the user already has a YouTube connection, so the
|
||||
// account page hides the Connect link when one exists.
|
||||
func hasYouTube(conns []store.Connection) bool {
|
||||
for _, c := range conns {
|
||||
if c.Provider == "youtube" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// disconnectURL builds the disconnect POST path for a provider.
|
||||
func disconnectURL(provider string) templ.SafeURL {
|
||||
return templ.SafeURL("/account/disconnect/" + provider)
|
||||
}
|
||||
|
||||
// Filter holds the list-view query parameters. Empty fields mean "no constraint".
|
||||
// Dates are kept as the raw YYYY-MM-DD strings so the form re-renders the user's
|
||||
// input verbatim; parsing happens in matchFilter.
|
||||
@@ -348,6 +385,40 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
||||
.actions .action:active { transform: translateY(1px); }
|
||||
.actions .action.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||
|
||||
/* account page */
|
||||
.account { max-width: 40rem; }
|
||||
.account h1 { font-size: 1.7rem; margin: 0 0 var(--s4); }
|
||||
.account section { margin-top: var(--s5); }
|
||||
.account section h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); border-top: 1px solid var(--line); padding-top: var(--s3); margin: 0 0 var(--s3); }
|
||||
.account-meta { display: grid; grid-template-columns: max-content 1fr; gap: var(--s1) var(--s3); margin: 0; }
|
||||
.account-meta dt { color: var(--muted); font-size: .85rem; }
|
||||
.account-meta dd { margin: 0; }
|
||||
.conn-list { list-style: none; margin: 0 0 var(--s3); padding: 0; display: grid; gap: var(--s2); }
|
||||
.conn { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: var(--s3); display: flex; flex-direction: column; gap: var(--s1); }
|
||||
.conn-main { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; }
|
||||
.conn-provider { font-weight: 600; }
|
||||
.conn-meta { font-size: .8rem; }
|
||||
.conn form { margin-top: var(--s1); }
|
||||
.btn-secondary { font: inherit; font-weight: 600; padding: .4rem .9rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); cursor: pointer; }
|
||||
.btn-secondary:hover { border-color: var(--accent); }
|
||||
.btn-secondary:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
/* delete danger zone — destructive action behind a confirm disclosure */
|
||||
.danger-zone h2 { border-top-color: #d9534f; }
|
||||
.confirm-delete > 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); }
|
||||
|
||||
Reference in New Issue
Block a user