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:
2026-06-03 16:55:02 +02:00
co-authored by Claude Opus 4.8
parent 2fe4833434
commit 2fb4230ab6
7 changed files with 715 additions and 83 deletions
+5 -2
View File
@@ -186,7 +186,10 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
log.Warn("web auth: STUB allow-all (no TAPIR_OIDC_ISSUER) — local dev only, do not expose") log.Warn("web auth: STUB allow-all (no TAPIR_OIDC_ISSUER) — local dev only, do not expose")
} }
app := &web.App{Store: st, Identity: st, Auth: authn, Log: log} // The file-backed SecretStore is shared by the connect flow (writes tokens)
// and account management (deletes them on disconnect / delete-account).
secretStore := secrets.NewFileStore(cfg.SecretsFile)
app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log}
// Web-initiated YouTube connect (ADR-006). Mounted only when the OAuth client // Web-initiated YouTube connect (ADR-006). Mounted only when the OAuth client
// credentials are present; the refresh token persists through the SecretStore // credentials are present; the refresh token persists through the SecretStore
@@ -197,7 +200,7 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
ClientID: cfg.YTClientID, ClientID: cfg.YTClientID,
ClientSecret: cfg.YTClientSecret, ClientSecret: cfg.YTClientSecret,
RedirectURL: cfg.YTConnectRedirectURL, RedirectURL: cfg.YTConnectRedirectURL,
}, secrets.NewFileStore(cfg.SecretsFile), st, log) }, secretStore, st, log)
log.Info("web youtube connect enabled", "redirect", cfg.YTConnectRedirectURL) log.Info("web youtube connect enabled", "redirect", cfg.YTConnectRedirectURL)
} }
+121
View File
@@ -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 ""
}
+153
View File
@@ -0,0 +1,153 @@
package web_test
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/web"
)
// fakeSecrets is a SecretRemover that records the refs it was asked to delete, so
// account tests can assert the OAuth token cleanup without a real secret file.
type fakeSecrets struct {
mu sync.Mutex
deleted []string
}
func (f *fakeSecrets) Delete(ref string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.deleted = append(f.deleted, ref)
return nil
}
func (f *fakeSecrets) deletedRefs() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.deleted...)
}
// newAccountApp builds the App as the registered stub user with a recording fake
// SecretStore, so disconnect/delete can be exercised end-to-end.
func newAccountApp(t *testing.T) (*web.App, *fakeSecrets) {
t.Helper()
s := newStore(t)
fs := &fakeSecrets{}
return &web.App{
Store: s,
Identity: s,
Auth: web.StubAuth{U: web.User{Subject: stubSubject, Email: "ada@example.com"}},
Secrets: fs,
}, fs
}
func seedConnection(t *testing.T, app *web.App, provider, account, ref string) {
t.Helper()
require.NoError(t, app.Store.(*store.Store).UpsertConnection(context.Background(), userID, store.Connection{
Provider: provider,
ProviderAccount: account,
TokenRef: ref,
Status: "active",
}))
}
func TestAccountPageShowsConnectionAndName(t *testing.T) {
ctx := context.Background()
app, _ := newAccountApp(t)
p := rawPool(t)
resetDB(t, p)
_, err := p.Exec(ctx, `UPDATE users SET display_name = $1 WHERE id = $2`, "Ada", userID)
require.NoError(t, err)
seedConnection(t, app, "youtube", "ada@channel", web.YouTubeTokenRef(userID))
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/account", nil))
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "Ada", "display name shown")
require.Contains(t, html, "ada@example.com", "signed-in email shown")
require.Contains(t, html, "ada@channel", "connected account shown")
require.Contains(t, html, "YouTube")
require.Contains(t, html, "/account/disconnect/youtube", "a Disconnect control is present")
require.NotContains(t, html, "/oauth/youtube/connect", "no Connect link while already connected")
// Confirm-before-destroy: the delete is behind a disclosure, not a bare button.
require.Contains(t, html, "/account/delete")
require.Contains(t, html, "<details", "delete is gated behind a confirm step")
require.Contains(t, html, "cannot be undone")
}
func TestAccountPageShowsConnectLinkWhenNotConnected(t *testing.T) {
app, _ := newAccountApp(t)
resetDB(t, rawPool(t))
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/account", nil))
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "/oauth/youtube/connect", "Connect link offered when not connected")
require.Contains(t, html, "Connect YouTube")
}
func TestDisconnectRemovesTokenAndConnectionKeepsAccount(t *testing.T) {
ctx := context.Background()
app, fs := newAccountApp(t)
resetDB(t, rawPool(t))
ref := web.YouTubeTokenRef(userID)
seedConnection(t, app, "youtube", "ada@channel", ref)
req := httptest.NewRequest(http.MethodPost, "/account/disconnect/youtube", nil)
rec := do(t, app, req)
require.Equal(t, http.StatusSeeOther, rec.Code)
require.Equal(t, "/account", rec.Header().Get("Location"))
// Token purged from the SecretStore.
require.Contains(t, fs.deletedRefs(), ref, "the per-user YouTube token must be deleted")
// Connection row gone...
conns, err := app.Store.(*store.Store).ConnectionsForUser(ctx, userID)
require.NoError(t, err)
require.Empty(t, conns, "the connection row must be removed")
// ...but the account itself survives (disconnect is not delete).
name, err := app.Store.(*store.Store).DisplayName(ctx, userID)
require.NoError(t, err)
_ = name
var users int
require.NoError(t, rawPool(t).QueryRow(ctx, `SELECT count(*) FROM users WHERE id = $1`, userID).Scan(&users))
require.Equal(t, 1, users, "disconnect must not delete the account")
}
func TestDeleteAccountWipesDataAndSecretsAndLogsOut(t *testing.T) {
ctx := context.Background()
app, fs := newAccountApp(t)
p := rawPool(t)
resetDB(t, p)
ref := web.YouTubeTokenRef(userID)
seedConnection(t, app, "youtube", "ada@channel", ref)
require.NoError(t, deliver(ctx, app, videoX, "a summary")) // some user data to wipe
rec := do(t, app, httptest.NewRequest(http.MethodPost, "/account/delete", nil))
require.Equal(t, http.StatusSeeOther, rec.Code)
require.Equal(t, "/auth/logout", rec.Header().Get("Location"), "delete logs the user out")
// The user's secrets were removed (the per-user YouTube token).
require.Contains(t, fs.deletedRefs(), ref, "delete must purge the user's OAuth tokens")
// The account and its data are gone.
for _, q := range []string{
`SELECT count(*) FROM users WHERE id = $1`,
`SELECT count(*) FROM summaries WHERE user_id = $1`,
`SELECT count(*) FROM video_connections WHERE user_id = $1`,
`SELECT count(*) FROM user_identities WHERE user_id = $1`,
} {
var n int
require.NoError(t, p.QueryRow(ctx, q, userID).Scan(&n))
require.Equal(t, 0, n, "delete must remove all rows: %s", q)
}
}
+23
View File
@@ -21,6 +21,20 @@ type Store interface {
ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error) ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error)
SetAction(ctx context.Context, userID, videoID, action string) error SetAction(ctx context.Context, userID, videoID, action string) error
ClearAction(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 // 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/* // nil (e.g. dev without YouTube client credentials), the /oauth/youtube/*
// routes are not mounted. // routes are not mounted.
Connect *ConnectHandler 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 { func (a *App) logger() *slog.Logger {
@@ -62,6 +79,12 @@ func (a *App) Router() http.Handler {
app.HandleFunc("GET /register", a.handleRegisterForm) app.HandleFunc("GET /register", a.handleRegisterForm)
app.HandleFunc("POST /register", a.handleRegister) 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 // Web-initiated YouTube connect (ADR-006). Gated like every app route, so
// CurrentUserID is set and the connection binds to the authenticated user. // CurrentUserID is set and the connection binds to the authenticated user.
if a.Connect != nil { if a.Connect != nil {
+71
View File
@@ -199,6 +199,43 @@ func flashFor(code string) (flashView, bool) {
return f, ok 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". // 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 // Dates are kept as the raw YYYY-MM-DD strings so the form re-renders the user's
// input verbatim; parsing happens in matchFilter. // 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 { transform: translateY(1px); }
.actions .action.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); } .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) { @media (max-width: 640px) {
main { padding: var(--s3) var(--s2); } main { padding: var(--s3) var(--s2); }
.filters { gap: var(--s2); } .filters { gap: var(--s2); }
+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. // ActionButtons is the toggle group fragment returned by POST /v/{id}/action.
// Each button submits its verb; HTMX swaps this element in place (outerHTML), // 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 // and without JS the form POSTs and the handler redirects back to the detail
+243 -46
View File
@@ -767,11 +767,11 @@ func RegisterPage(email, errMsg string) templ.Component {
}) })
} }
// ActionButtons is the toggle group fragment returned by POST /v/{id}/action. // AccountPage is the account-management view: the registered display name and
// Each button submits its verb; HTMX swaps this element in place (outerHTML), // signed-in email, the user's connected video accounts (each with a Disconnect
// and without JS the form POSTs and the handler redirects back to the detail // control), a Connect-YouTube link when none is connected, and the delete-account
// page. active marks the verbs currently set for (user, video). // danger zone. flash surfaces a one-shot notification (disconnect/connect).
func ActionButtons(videoID string, active map[string]bool) templ.Component { func AccountPage(displayName, email string, conns []store.Connection, flash string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -792,112 +792,309 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component {
templ_7745c5c3_Var34 = templ.NopComponent templ_7745c5c3_Var34 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<form id=\"action-buttons\" class=\"actions\" method=\"post\" action=\"") templ_7745c5c3_Var35 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = flashBanner(flash).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var35 templ.SafeURL templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " <article class=\"account\"><h1>Account</h1><dl class=\"account-meta\"><dt>Display name</dt><dd>")
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(actionURL(videoID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 210, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" hx-post=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var36 string var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(actionURL(videoID))) templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(displayNameOr(displayName))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 211, Col: 38} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 212, Col: 36}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</dd>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, v := range actionVerbs { if email != "" {
var templ_7745c5c3_Var37 = []any{"action", templ.KV("active", active[v])} templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<dt>Signed in as</dt><dd>")
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var37...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<button type=\"submit\" name=\"action\" value=\"") var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 215, Col: 16}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</dd>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</dl><section><h2>Connected accounts</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(conns) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<p class=\"muted\">No connected video accounts yet.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<ul class=\"conn-list\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, c := range conns {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<li class=\"conn\"><div class=\"conn-main\"><span class=\"conn-provider\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var38 string var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(v) templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(providerLabel(c.Provider))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 219, Col: 13} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 227, Col: 64}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if c.ProviderAccount != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<span class=\"muted\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var39 string var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var37).String()) templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(c.ProviderAccount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 229, Col: 49}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" aria-pressed=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<span class=\"chip\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var40 string var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(ariaPressed(active[v])) templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(c.Status)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 221, Col: 41} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 231, Col: 38}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span></div><div class=\"conn-meta muted\">connected ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if active[v] {
var templ_7745c5c3_Var41 string var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs("✓ " + actionLabel(v)) templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(c.ConnectedAt.Format("2006-01-02"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 224, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 233, Col: 83}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</div><form method=\"post\" action=\"")
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(actionLabel(v))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 226, Col: 21} return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 templ.SafeURL
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(disconnectURL(c.Provider))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 234, Col: 62}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\"><button type=\"submit\" class=\"btn-secondary\">Disconnect</button></form></li>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</form>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if !hasYouTube(conns) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<p><a class=\"btn\" href=\"/oauth/youtube/connect\">Connect YouTube</a></p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = Layout("Tapir — Account").Render(templ.WithChildren(ctx, templ_7745c5c3_Var35), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// 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
// page. active marks the verbs currently set for (user, video).
func ActionButtons(videoID string, active map[string]bool) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var43 := templ.GetChildren(ctx)
if templ_7745c5c3_Var43 == nil {
templ_7745c5c3_Var43 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<form id=\"action-buttons\" class=\"actions\" method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 templ.SafeURL
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinURLErrs(actionURL(videoID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 274, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(actionURL(videoID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 275, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, v := range actionVerbs {
var templ_7745c5c3_Var46 = []any{"action", templ.KV("active", active[v])}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var46...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<button type=\"submit\" name=\"action\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var47 string
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(v)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 283, Col: 13}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var48 string
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var46).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" aria-pressed=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var49 string
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(ariaPressed(active[v]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 285, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if active[v] {
var templ_7745c5c3_Var50 string
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs("✓ " + actionLabel(v))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 288, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var51 string
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(actionLabel(v))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 290, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }