feat(web): account page with disconnect + delete-account
CI / Lint / Test / Vet (push) Successful in 17s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Has been skipped

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:
2026-06-03 16:55:02 +02:00
co-authored by Claude Opus 4.8
parent 2fe4833434
commit 22eafcf43f
7 changed files with 715 additions and 83 deletions
+64
View File
@@ -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)
<article class="account">
<h1>Account</h1>
<dl class="account-meta">
<dt>Display name</dt>
<dd>{ displayNameOr(displayName) }</dd>
if email != "" {
<dt>Signed in as</dt>
<dd>{ email }</dd>
}
</dl>
<section>
<h2>Connected accounts</h2>
if len(conns) == 0 {
<p class="muted">No connected video accounts yet.</p>
} else {
<ul class="conn-list">
for _, c := range conns {
<li class="conn">
<div class="conn-main">
<span class="conn-provider">{ providerLabel(c.Provider) }</span>
if c.ProviderAccount != "" {
<span class="muted">{ c.ProviderAccount }</span>
}
<span class="chip">{ c.Status }</span>
</div>
<div class="conn-meta muted">connected { c.ConnectedAt.Format("2006-01-02") }</div>
<form method="post" action={ disconnectURL(c.Provider) }>
<button type="submit" class="btn-secondary">Disconnect</button>
</form>
</li>
}
</ul>
}
if !hasYouTube(conns) {
<p><a class="btn" href="/oauth/youtube/connect">Connect YouTube</a></p>
}
</section>
<section class="danger-zone">
<h2>Delete account</h2>
<p class="muted">
Permanently remove your Tapir account and all of its data summaries,
watch/skip/save actions, and connected accounts. This cannot be undone.
</p>
<details class="confirm-delete">
<summary class="btn-danger">Delete account…</summary>
<div class="confirm-body">
<p>This permanently deletes your account and all data. Are you sure?</p>
<form method="post" action="/account/delete">
<button type="submit" class="btn-danger">Yes, permanently delete my account</button>
</form>
</div>
</details>
</section>
</article>
}
}
// 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