From e7c2e575d3dec011739d4f0e744671eab540da3a Mon Sep 17 00:00:00 2001 From: Mathias Date: Sun, 7 Jun 2026 23:01:14 +0200 Subject: [PATCH] refactor: remove Dex local-password invite provisioning (ADR-019) Authentik owns invites now (infra ADR-0001). Delete adapters/dex, the /invite set-password UI, the tapir invite CLI, the InvitationStore/ DexPasswordCreator ports + App wiring, the invite Templ pages, and the invite Taskfile target. New users are invited via Authentik, log in via OIDC, and hit the existing /register gate. invitations table (mig 009) left in place (append-only; harmless). task check green. Co-Authored-By: Claude Opus 4.8 (1M context) --- Taskfile.yml | 8 - cmd/tapir/invite.go | 61 --- cmd/tapir/invite_test.go | 59 --- cmd/tapir/main.go | 20 +- internal/adapters/dex/dex.go | 181 ------- internal/adapters/dex/dex_test.go | 105 ---- internal/adapters/store/invitations.go | 86 ---- internal/adapters/store/invitations_test.go | 110 ----- internal/web/export_test.go | 14 - internal/web/flash.go | 11 +- internal/web/handlers.go | 13 - internal/web/invite.go | 164 ------- internal/web/invite_test.go | 185 ------- internal/web/view.go | 16 +- internal/web/views.templ | 62 --- internal/web/views_templ.go | 504 ++++++-------------- 16 files changed, 146 insertions(+), 1453 deletions(-) delete mode 100644 cmd/tapir/invite.go delete mode 100644 cmd/tapir/invite_test.go delete mode 100644 internal/adapters/dex/dex.go delete mode 100644 internal/adapters/dex/dex_test.go delete mode 100644 internal/adapters/store/invitations.go delete mode 100644 internal/adapters/store/invitations_test.go delete mode 100644 internal/web/export_test.go delete mode 100644 internal/web/invite.go delete mode 100644 internal/web/invite_test.go diff --git a/Taskfile.yml b/Taskfile.yml index 63cdc67..6997897 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -52,14 +52,6 @@ tasks: cmds: - go build -o bin/tapir ./cmd/tapir - invite: - desc: "Invite a user by email. Usage: task invite EMAIL=user@example.com" - requires: - vars: [EMAIL] - cmds: - - task: build - - op run --env-file ~/.config/tapir/tapir.env -- ./bin/tapir invite "{{.EMAIL}}" - skills: desc: Wire the engineering skills library into this repo (symlinks, gitignored). cmds: diff --git a/cmd/tapir/invite.go b/cmd/tapir/invite.go deleted file mode 100644 index 004540a..0000000 --- a/cmd/tapir/invite.go +++ /dev/null @@ -1,61 +0,0 @@ -package main - -import ( - "context" - "fmt" - "io" - "os" - "strings" - "time" - - "gitea.d-ma.be/mathias/tapir/internal/adapters/store" - "gitea.d-ma.be/mathias/tapir/internal/config" -) - -// inviteTTL is how long a minted invite stays claimable. A week is generous for a -// human to act on an emailed link without leaving a stale capability around. -const inviteTTL = 7 * 24 * time.Hour - -// inviter is the narrow store capability cmdInvite needs — minting an invitation. -// Defined here (not store) so runInvite is testable with a fake, no Postgres. -type inviter interface { - CreateInvitation(ctx context.Context, email string, ttl time.Duration) (string, error) -} - -// cmdInvite mints an invitation for an email and prints the claim URL. Host-side -// only (no Dex session): the operator runs it, copies the link, and sends it. -// Usage: tapir invite . -func cmdInvite(ctx context.Context, args []string) error { - if len(args) < 1 || strings.TrimSpace(args[0]) == "" { - return fmt.Errorf("usage: tapir invite ") - } - email := strings.TrimSpace(args[0]) - - cfg, err := config.Load() - if err != nil { - return err - } - if strings.TrimSpace(cfg.DBDSN) == "" { - return fmt.Errorf("missing required config: TAPIR_DB_DSN") - } - - st, err := store.New(ctx, cfg.DBDSN) - if err != nil { - return err - } - defer st.Close() - - return runInvite(ctx, st, os.Stdout, cfg.PublicURL, email) -} - -// runInvite is the testable core: mint the token and print the absolute claim URL -// to w. Pure of config/store construction so a fake inviter exercises it. -func runInvite(ctx context.Context, inv inviter, w io.Writer, publicURL, email string) error { - token, err := inv.CreateInvitation(ctx, email, inviteTTL) - if err != nil { - return fmt.Errorf("create invitation: %w", err) - } - base := strings.TrimRight(strings.TrimSpace(publicURL), "/") - _, err = fmt.Fprintf(w, "Invite URL (valid 7 days):\n%s/invite/%s\n", base, token) - return err -} diff --git a/cmd/tapir/invite_test.go b/cmd/tapir/invite_test.go deleted file mode 100644 index 57a2f6c..0000000 --- a/cmd/tapir/invite_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package main - -import ( - "context" - "errors" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -// fakeInviter records the mint call and returns a canned token. -type fakeInviter struct { - token string - err error - gotEmail string - gotTTL time.Duration - callCount int -} - -func (f *fakeInviter) CreateInvitation(_ context.Context, email string, ttl time.Duration) (string, error) { - f.callCount++ - f.gotEmail, f.gotTTL = email, ttl - return f.token, f.err -} - -func TestRunInvitePrintsURL(t *testing.T) { - inv := &fakeInviter{token: "deadbeefcafe"} - var out strings.Builder - - err := runInvite(context.Background(), inv, &out, "https://tapir.d-ma.be", "new@example.com") - require.NoError(t, err) - - require.Equal(t, "new@example.com", inv.gotEmail) - require.Equal(t, inviteTTL, inv.gotTTL) - got := out.String() - require.Contains(t, got, "https://tapir.d-ma.be/invite/deadbeefcafe") - require.Contains(t, got, "valid 7 days") -} - -func TestRunInviteTrimsTrailingSlash(t *testing.T) { - inv := &fakeInviter{token: "tok"} - var out strings.Builder - - err := runInvite(context.Background(), inv, &out, "https://tapir.d-ma.be/", "x@example.com") - require.NoError(t, err) - require.Contains(t, out.String(), "https://tapir.d-ma.be/invite/tok") - require.NotContains(t, out.String(), "//invite") -} - -func TestRunInvitePropagatesError(t *testing.T) { - inv := &fakeInviter{err: errors.New("db down")} - var out strings.Builder - - err := runInvite(context.Background(), inv, &out, "https://tapir.d-ma.be", "x@example.com") - require.Error(t, err) - require.Empty(t, out.String()) -} diff --git a/cmd/tapir/main.go b/cmd/tapir/main.go index fc95766..247daab 100644 --- a/cmd/tapir/main.go +++ b/cmd/tapir/main.go @@ -22,7 +22,6 @@ import ( "os/signal" "time" - "gitea.d-ma.be/mathias/tapir/internal/adapters/dex" "gitea.d-ma.be/mathias/tapir/internal/adapters/secrets" "gitea.d-ma.be/mathias/tapir/internal/adapters/store" "gitea.d-ma.be/mathias/tapir/internal/adapters/youtube" @@ -55,8 +54,6 @@ func main() { err = cmdRun(ctx, log) case "serve": err = cmdServe(ctx, log) - case "invite": - err = cmdInvite(ctx, os.Args[2:]) case "report": err = runReport(ctx, os.Args[2:]) default: @@ -77,7 +74,6 @@ usage: tapir auth one-time: authorize YouTube and store a refresh token tapir run detect new videos, summarize, deliver to your store tapir serve run the web UI (read summaries, record watch/skip/save) - tapir invite mint an invitation link for a new user (host-side) tapir list [-limit N] list stored summaries, recent first tapir show show one summary in full tapir report Stage-0 usage gate: per-user distinct active weeks @@ -197,19 +193,9 @@ func cmdServe(ctx context.Context, log *slog.Logger) error { secretStore := secrets.NewFileStore(cfg.SecretsFile) app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log} - // Email-invite onboarding (public /invite/{token}). The store validates and - // consumes tokens; the Dex client creates the local-password account. In-cluster - // the SA token mount is present and account creation works; off-cluster (dev) it - // is nil and the submit handler degrades to a clear "deployed-only" message. - app.Invitations = st - if dexClient, err := dex.NewPasswordClient(); err == nil { - app.Dex = dexClient - log.Info("invite account creation enabled (in-cluster dex password client)") - } else if errors.Is(err, dex.ErrNotInCluster) { - log.Warn("invite account creation disabled: not in-cluster — /invite is deployed-only") - } else { - return fmt.Errorf("dex password client: %w", err) - } + // User onboarding is handled by the IdP (Authentik invite flow), not Tapir — + // the Dex local-password provisioning path was removed (ADR-019). An + // authenticated subject with no Tapir user is routed to /register. // Web-initiated YouTube connect (ADR-006). Mounted only when the OAuth client // credentials are present; the refresh token persists through the SecretStore diff --git a/internal/adapters/dex/dex.go b/internal/adapters/dex/dex.go deleted file mode 100644 index ac35f33..0000000 --- a/internal/adapters/dex/dex.go +++ /dev/null @@ -1,181 +0,0 @@ -// Package dex creates Dex local-password accounts by writing -// passwords.dex.coreos.com custom resources directly against the in-cluster -// Kubernetes API. This is the write side of the invite flow: a recipient sets a -// password on /invite/{token}, Tapir bcrypt-hashes it and POSTs a Password CR into -// the auth namespace, and Dex (configured with kubernetes storage) then serves -// local-password login for that email. -// -// Why the raw API and not kubectl/client-go: the deployed pod already carries a -// service-account token and the cluster CA at the well-known mount paths, so a -// single net/http POST needs no extra dependency and no shelling out. Standalone / -// dev has no such mount — NewPasswordClient returns ErrNotInCluster and the web -// handler degrades gracefully (account creation only works in the deployed env). -package dex - -import ( - "bytes" - "context" - "crypto/tls" - "crypto/x509" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "strings" - "time" -) - -// Sentinel errors let the web handler turn API outcomes into clear user messages. -var ( - // ErrNotInCluster means the service-account token mount is absent, so there is - // no in-cluster API to talk to (local dev / tests). Construction-time only. - ErrNotInCluster = errors.New("dex: not running in-cluster (no service-account token)") - // ErrPasswordExists maps the API's 409 Conflict — a Password CR for this email - // already exists. The handler treats it as a benign "log in instead". - ErrPasswordExists = errors.New("dex: password already exists") - // ErrForbidden maps 401/403 — the tapir ServiceAccount lacks create/get on - // passwords.dex.coreos.com in the auth namespace (RBAC not applied). - ErrForbidden = errors.New("dex: forbidden — missing RBAC for passwords.dex.coreos.com") -) - -// Well-known in-cluster service-account mount paths (projected by kubelet). -const ( - saTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec // path, not a secret - saCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" - // apiServer is the in-cluster API endpoint; its TLS is validated against the - // mounted cluster CA. - apiServer = "https://kubernetes.default.svc" - // passwordsPath is the Dex Password collection in the auth namespace. - passwordsPath = "/apis/dex.coreos.com/v1/namespaces/auth/passwords" -) - -// PasswordClient writes Dex Password CRs against the in-cluster API. Construct it -// with NewPasswordClient; the zero value is not usable. -type PasswordClient struct { - server string - token string - http *http.Client -} - -// NewPasswordClient reads the service-account token and cluster CA from the -// well-known mount paths and returns a client that authenticates as the pod's -// ServiceAccount. It returns ErrNotInCluster when the token mount is absent (dev / -// tests / standalone), so callers can detect "no Dex available" and degrade. -func NewPasswordClient() (*PasswordClient, error) { - token, err := os.ReadFile(saTokenPath) - if errors.Is(err, os.ErrNotExist) { - return nil, ErrNotInCluster - } - if err != nil { - return nil, fmt.Errorf("dex: read service-account token: %w", err) - } - - caPEM, err := os.ReadFile(saCAPath) - if err != nil { - return nil, fmt.Errorf("dex: read cluster CA: %w", err) - } - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(caPEM) { - return nil, errors.New("dex: cluster CA is not valid PEM") - } - - hc := &http.Client{ - Timeout: 10 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, - }, - } - return newClient(apiServer, strings.TrimSpace(string(token)), hc), nil -} - -// newClient is the injectable constructor shared by NewPasswordClient and tests -// (which point server at an httptest.Server and pass its TLS client). -func newClient(server, token string, hc *http.Client) *PasswordClient { - return &PasswordClient{server: server, token: token, http: hc} -} - -// password is the wire form of a Dex Password CR. The hash field is a plain -// bcrypt string (e.g. "$2a$12$..."). Dex v2.41+ stores and compares it as-is — -// it does NOT base64-decode the field. Earlier code base64-encoded the hash -// based on a misread of Dex's internal []byte type; that caused every dynamic -// invite login to fail with "Invalid credentials" while static passwords (set as -// plain strings in the configmap) worked fine. -type password struct { - APIVersion string `json:"apiVersion"` - Kind string `json:"kind"` - Metadata map[string]string `json:"metadata"` - Email string `json:"email"` - Hash string `json:"hash"` - Username string `json:"username"` - UserID string `json:"userID"` -} - -// CreatePassword creates a Dex local-password account for email with the given -// bcrypt hash and Dex user id. The CR name is derived from the email so it is a -// valid, stable, idempotent Kubernetes object name. Returns ErrPasswordExists on -// 409 (the account already exists) and ErrForbidden on 401/403 (RBAC missing). -func (c *PasswordClient) CreatePassword(ctx context.Context, email, bcryptHash, userID string) error { - body, err := json.Marshal(password{ - APIVersion: "dex.coreos.com/v1", - Kind: "Password", - Metadata: map[string]string{"name": passwordName(email), "namespace": "auth"}, - Email: email, - Hash: bcryptHash, // raw bcrypt string — Dex compares it directly - Username: email, - UserID: userID, - }) - if err != nil { - return fmt.Errorf("dex: marshal password: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.server+passwordsPath, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("dex: build request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - resp, err := c.http.Do(req) - if err != nil { - return fmt.Errorf("dex: create password: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - switch resp.StatusCode { - case http.StatusCreated, http.StatusOK: - return nil - case http.StatusConflict: - return ErrPasswordExists - case http.StatusUnauthorized, http.StatusForbidden: - return ErrForbidden - default: - snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return fmt.Errorf("dex: create password: unexpected status %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet))) - } -} - -// passwordName maps an email to the Kubernetes object name Dex uses internally -// when looking up a Password CR by email (Dex storage/kubernetes passwordID()). -// Dex maps every character that is not [a-z0-9-] to '-' — it does NOT use -// human-readable substitutions like '-at-' or '-dot-'. Using a different scheme -// creates a name mismatch: Tapir writes the CR under one name, Dex looks it up -// under another, and every login returns "Invalid credentials". -func passwordName(email string) string { - n := strings.ToLower(strings.TrimSpace(email)) - var b strings.Builder - for _, r := range n { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { - b.WriteRune(r) - } else { - b.WriteRune('-') - } - } - result := strings.Trim(b.String(), "-") - if result == "" { - return "user" - } - return result -} diff --git a/internal/adapters/dex/dex_test.go b/internal/adapters/dex/dex_test.go deleted file mode 100644 index aa6501b..0000000 --- a/internal/adapters/dex/dex_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package dex - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/require" -) - -// newTestClient points a PasswordClient at an httptest server, using that -// server's TLS client so the in-cluster TLS path is exercised without a real CA. -func newTestClient(srv *httptest.Server) *PasswordClient { - return newClient(srv.URL, "test-token", srv.Client()) -} - -func TestCreatePasswordSuccess(t *testing.T) { - var gotAuth, gotPath, gotMethod string - var gotBody password - - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotAuth, gotPath, gotMethod = r.Header.Get("Authorization"), r.URL.Path, r.Method - b, _ := io.ReadAll(r.Body) - _ = json.Unmarshal(b, &gotBody) - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"kind":"Password"}`)) - })) - defer srv.Close() - - err := newTestClient(srv).CreatePassword(context.Background(), - "New.User@Example.com", "$2a$12$abcdefghijklmnopqrstuv", "user-uuid-1") - require.NoError(t, err) - - require.Equal(t, http.MethodPost, gotMethod) - require.Equal(t, passwordsPath, gotPath) - require.Equal(t, "Bearer test-token", gotAuth) - - // Email/username carry the raw address; the CR name is sanitised + lowercased. - require.Equal(t, "New.User@Example.com", gotBody.Email) - require.Equal(t, "New.User@Example.com", gotBody.Username) - require.Equal(t, "user-uuid-1", gotBody.UserID) - require.Equal(t, "new-user-example-com", gotBody.Metadata["name"]) - require.Equal(t, "auth", gotBody.Metadata["namespace"]) - - // Hash is stored as the raw bcrypt string — Dex compares it directly. - require.Equal(t, "$2a$12$abcdefghijklmnopqrstuv", gotBody.Hash) -} - -func TestCreatePasswordConflict(t *testing.T) { - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusConflict) - })) - defer srv.Close() - - err := newTestClient(srv).CreatePassword(context.Background(), "dup@example.com", "$2a$12$x", "u") - require.ErrorIs(t, err, ErrPasswordExists) -} - -func TestCreatePasswordForbidden(t *testing.T) { - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusForbidden) - })) - defer srv.Close() - - err := newTestClient(srv).CreatePassword(context.Background(), "x@example.com", "$2a$12$x", "u") - require.ErrorIs(t, err, ErrForbidden) -} - -func TestCreatePasswordUnexpectedStatus(t *testing.T) { - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte("boom")) - })) - defer srv.Close() - - err := newTestClient(srv).CreatePassword(context.Background(), "x@example.com", "$2a$12$x", "u") - require.Error(t, err) - require.NotErrorIs(t, err, ErrPasswordExists) - require.NotErrorIs(t, err, ErrForbidden) - require.Contains(t, err.Error(), "500") -} - -func TestNewPasswordClientNotInCluster(t *testing.T) { - // In the test environment the SA token mount does not exist. - _, err := NewPasswordClient() - require.ErrorIs(t, err, ErrNotInCluster) -} - -func TestPasswordName(t *testing.T) { - // Must match Dex's internal passwordID() — maps every non-[a-z0-9-] to '-'. - // Using a different scheme (e.g. '-at-', '-dot-') causes a name mismatch: - // Tapir writes the CR under one name, Dex looks it up under another. - cases := map[string]string{ - "Alice@Example.com": "alice-example-com", - "a.b+c@gmail.com": "a-b-c-gmail-com", - "UPPER@DOMAIN.IO": "upper-domain-io", - "mathias@d-ma.be": "mathias-d-ma-be", - } - for in, want := range cases { - require.Equal(t, want, passwordName(in), in) - } -} diff --git a/internal/adapters/store/invitations.go b/internal/adapters/store/invitations.go deleted file mode 100644 index 1229665..0000000 --- a/internal/adapters/store/invitations.go +++ /dev/null @@ -1,86 +0,0 @@ -package store - -import ( - "context" - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "time" - - "github.com/jackc/pgx/v5" -) - -// Invitations are NOT routed through withUser: an invitation exists before its -// user does, so there is no user_id to scope by and no authenticated context when -// one is minted (host CLI) or claimed (the public /invite handler). The token is -// the capability — single-use, time-boxed, crypto-random. The invitations table -// is deliberately outside RLS for the same reason (see migration 009). - -// CreateInvitation mints a single-use invite for email, valid for ttl, and -// returns its token. The token is 32 bytes of crypto-random entropy, hex-encoded; -// it is the only secret a recipient needs to claim the invite. -func (s *Store) CreateInvitation(ctx context.Context, email string, ttl time.Duration) (string, error) { - token, err := newInviteToken() - if err != nil { - return "", err - } - if _, err := s.pool.Exec(ctx, - `INSERT INTO invitations (email, token, expires_at) - VALUES ($1, $2, NOW() + $3::interval)`, - email, token, ttl.String()); err != nil { - return "", fmt.Errorf("store: create invitation: %w", err) - } - return token, nil -} - -// PeekInvitation returns the invited email for a token that is real, unexpired, -// and unused WITHOUT consuming it — the read the /invite form does to validate the -// link before showing the password fields. Returns ErrNotFound when the token is -// missing, expired, or already used. Use ClaimInvitation to consume. -func (s *Store) PeekInvitation(ctx context.Context, token string) (string, error) { - var email string - err := s.pool.QueryRow(ctx, - `SELECT email FROM invitations - WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`, - token).Scan(&email) - if errors.Is(err, pgx.ErrNoRows) { - return "", ErrNotFound - } - if err != nil { - return "", fmt.Errorf("store: peek invitation: %w", err) - } - return email, nil -} - -// ClaimInvitation atomically consumes a valid invite and returns its email. The -// UPDATE ... WHERE used_at IS NULL AND expires_at > NOW() guarded by RETURNING -// makes the claim a single round-trip race-free check-and-set: two concurrent -// claims of the same token, only one updates a row, the other gets no rows and so -// ErrNotFound. Same ErrNotFound for missing/expired/already-used tokens. -func (s *Store) ClaimInvitation(ctx context.Context, token string) (string, error) { - var email string - err := s.pool.QueryRow(ctx, - `UPDATE invitations - SET used_at = NOW() - WHERE token = $1 AND used_at IS NULL AND expires_at > NOW() - RETURNING email`, - token).Scan(&email) - if errors.Is(err, pgx.ErrNoRows) { - return "", ErrNotFound - } - if err != nil { - return "", fmt.Errorf("store: claim invitation: %w", err) - } - return email, nil -} - -// newInviteToken returns 32 bytes of crypto-random entropy, hex-encoded (64 -// chars). Hex keeps the token URL-safe with no escaping in /invite/{token}. -func newInviteToken() (string, error) { - var b [32]byte - if _, err := rand.Read(b[:]); err != nil { - return "", fmt.Errorf("store: invite token: %w", err) - } - return hex.EncodeToString(b[:]), nil -} diff --git a/internal/adapters/store/invitations_test.go b/internal/adapters/store/invitations_test.go deleted file mode 100644 index e9ef35b..0000000 --- a/internal/adapters/store/invitations_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package store_test - -import ( - "context" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgxpool" - "github.com/stretchr/testify/require" - - "gitea.d-ma.be/mathias/tapir/internal/adapters/store" -) - -// resetInvitations clears the invitations table between cases. It is not in the -// shared resetDB TRUNCATE list (invitations is not user-owned and has no FK to -// users), so the invite tests wipe it themselves. -func resetInvitations(t *testing.T, p *pgxpool.Pool) { - t.Helper() - _, err := p.Exec(context.Background(), `TRUNCATE invitations`) - require.NoError(t, err) -} - -func TestCreateInvitationReturnsUsableToken(t *testing.T) { - s, p := newStore(t), rawPool(t) - resetInvitations(t, p) - ctx := context.Background() - - token, err := s.CreateInvitation(ctx, "new@example.com", time.Hour) - require.NoError(t, err) - require.Len(t, token, 64, "32 random bytes hex-encoded") - - // Peek does not consume: the same token previews twice. - email, err := s.PeekInvitation(ctx, token) - require.NoError(t, err) - require.Equal(t, "new@example.com", email) - email, err = s.PeekInvitation(ctx, token) - require.NoError(t, err) - require.Equal(t, "new@example.com", email) -} - -func TestCreateInvitationTokensAreUnique(t *testing.T) { - s, p := newStore(t), rawPool(t) - resetInvitations(t, p) - ctx := context.Background() - - t1, err := s.CreateInvitation(ctx, "a@example.com", time.Hour) - require.NoError(t, err) - t2, err := s.CreateInvitation(ctx, "b@example.com", time.Hour) - require.NoError(t, err) - require.NotEqual(t, t1, t2) -} - -func TestClaimInvitationHappyPath(t *testing.T) { - s, p := newStore(t), rawPool(t) - resetInvitations(t, p) - ctx := context.Background() - - token, err := s.CreateInvitation(ctx, "claim@example.com", time.Hour) - require.NoError(t, err) - - email, err := s.ClaimInvitation(ctx, token) - require.NoError(t, err) - require.Equal(t, "claim@example.com", email) -} - -func TestClaimInvitationIsSingleUse(t *testing.T) { - s, p := newStore(t), rawPool(t) - resetInvitations(t, p) - ctx := context.Background() - - token, err := s.CreateInvitation(ctx, "once@example.com", time.Hour) - require.NoError(t, err) - - _, err = s.ClaimInvitation(ctx, token) - require.NoError(t, err) - - // Second claim fails — already used. - _, err = s.ClaimInvitation(ctx, token) - require.ErrorIs(t, err, store.ErrNotFound) - - // And a used token no longer previews. - _, err = s.PeekInvitation(ctx, token) - require.ErrorIs(t, err, store.ErrNotFound) -} - -func TestClaimInvitationExpired(t *testing.T) { - s, p := newStore(t), rawPool(t) - resetInvitations(t, p) - ctx := context.Background() - - // Negative ttl => already expired. - token, err := s.CreateInvitation(ctx, "old@example.com", -time.Minute) - require.NoError(t, err) - - _, err = s.PeekInvitation(ctx, token) - require.ErrorIs(t, err, store.ErrNotFound) - _, err = s.ClaimInvitation(ctx, token) - require.ErrorIs(t, err, store.ErrNotFound) -} - -func TestClaimInvitationNotFound(t *testing.T) { - s, p := newStore(t), rawPool(t) - resetInvitations(t, p) - ctx := context.Background() - - _, err := s.ClaimInvitation(ctx, "does-not-exist") - require.ErrorIs(t, err, store.ErrNotFound) - _, err = s.PeekInvitation(ctx, "does-not-exist") - require.ErrorIs(t, err, store.ErrNotFound) -} diff --git a/internal/web/export_test.go b/internal/web/export_test.go deleted file mode 100644 index c072529..0000000 --- a/internal/web/export_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package web - -import "net/http" - -// Test-only handles to the unexported invite handlers so the external web_test -// package can mount them on an httptest mux (and get PathValue routing) without -// standing up the full Router + auth stack. export_test.go compiles only under -// `go test`, so these never widen the package's real API. -func (a *App) HandleInviteFormForTest(w http.ResponseWriter, r *http.Request) { - a.handleInviteForm(w, r) -} -func (a *App) HandleInviteSubmitForTest(w http.ResponseWriter, r *http.Request) { - a.handleInviteSubmit(w, r) -} diff --git a/internal/web/flash.go b/internal/web/flash.go index cbd2e60..26c3e82 100644 --- a/internal/web/flash.go +++ b/internal/web/flash.go @@ -11,12 +11,11 @@ const flashCookie = "tapir_flash" // Flash codes. Kept small and stable — the message + severity live in // flashMessages (view.go), not here, so the cookie never carries free text. const ( - flashConnected = "connected" - flashConnectFailed = "connect_failed" - flashDisconnected = "disconnected" - flashDeleted = "deleted" - flashRegistered = "registered" - flashAccountCreated = "account_created" + flashConnected = "connected" + flashConnectFailed = "connect_failed" + flashDisconnected = "disconnected" + flashDeleted = "deleted" + flashRegistered = "registered" ) // flashMaxAge bounds how long an unread flash lingers (seconds). Long enough to diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 0e652d4..234cc20 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -80,14 +80,6 @@ type App struct { // Processing tracks in-flight immediate summarizations so the status endpoint // shows the animation until the summary lands. The zero value is ready to use. Processing ProcessingSet - // Invitations validates and consumes email-invite tokens for the public - // /invite/{token} flow. Nil = the invite routes report "invalid" (the flow is - // effectively off). *store.Store satisfies it. - Invitations InvitationStore - // Dex creates the Dex local-password account when an invite is claimed. Nil = - // not in-cluster (dev): the submit handler degrades to a clear "deployed-only" - // message instead of creating an account. *dex.PasswordClient satisfies it. - Dex DexPasswordCreator } func (a *App) logger() *slog.Logger { @@ -107,11 +99,6 @@ func (a *App) Router() http.Handler { root.Handle("GET /static/", staticHandler()) root.Handle("/auth/", a.Auth.Routes()) - // Email invitation claim (public — the visitor has no Dex session yet, so this - // sits OUTSIDE Auth.Middleware). The token in the path is the capability. - root.HandleFunc("GET /invite/{token}", a.handleInviteForm) - root.HandleFunc("POST /invite/{token}", a.handleInviteSubmit) - app := http.NewServeMux() app.HandleFunc("GET /{$}", a.handleList) app.HandleFunc("GET /v/{videoId}", a.handleDetail) diff --git a/internal/web/invite.go b/internal/web/invite.go deleted file mode 100644 index 2f9641a..0000000 --- a/internal/web/invite.go +++ /dev/null @@ -1,164 +0,0 @@ -package web - -import ( - "context" - "crypto/rand" - "errors" - "fmt" - "net/http" - - "golang.org/x/crypto/bcrypt" - - "gitea.d-ma.be/mathias/tapir/internal/adapters/dex" - "gitea.d-ma.be/mathias/tapir/internal/adapters/store" -) - -// InvitationStore is the narrow store surface the public invite flow needs: -// PeekInvitation validates a token without consuming it (the GET form preview); -// ClaimInvitation consumes it atomically (the POST). *store.Store satisfies it. -// Deliberately separate from Store (the user-scoped surface) — invites run with no -// authenticated user (the user does not exist yet). -type InvitationStore interface { - PeekInvitation(ctx context.Context, token string) (email string, err error) - ClaimInvitation(ctx context.Context, token string) (email string, err error) -} - -// DexPasswordCreator creates a Dex local-password account from a bcrypt hash. -// *dex.PasswordClient satisfies it; tests substitute a fake. A nil App.Dex means -// the process is not in-cluster (dev) and account creation is unavailable. -type DexPasswordCreator interface { - CreatePassword(ctx context.Context, email, bcryptHash, userID string) error -} - -// bcryptCost is the work factor for hashing invite passwords. 12 is a sensible -// 2020s default — noticeably slow to brute-force, fast enough for a single login. -const bcryptCost = 12 - -// minPasswordLen is the floor for an invite password. Length beats composition -// rules; 8 is the practical minimum we accept. -const minPasswordLen = 8 - -// handleInviteForm renders the set-password form for a valid invite token, or a -// clear "expired / already used" page otherwise. It only previews the token -// (PeekInvitation) — the token is consumed on submit, not on view, so a refresh -// or a link-preview fetch never burns the invite. -func (a *App) handleInviteForm(w http.ResponseWriter, r *http.Request) { - token := r.PathValue("token") - if a.Invitations == nil { - a.renderStatus(w, r, http.StatusOK, InviteInvalidPage()) - return - } - email, err := a.Invitations.PeekInvitation(r.Context(), token) - if errors.Is(err, store.ErrNotFound) { - a.renderStatus(w, r, http.StatusOK, InviteInvalidPage()) - return - } - if err != nil { - a.serverError(w, r, "peek invitation", err) - return - } - a.render(w, r, InvitePage(email, token, "")) -} - -// handleInviteSubmit validates the chosen password, consumes the invite, and -// creates the Dex local-password account. Order matters (see inline): password is -// validated first (no token burned on a typo), then the invite is claimed exactly -// once, then the Dex account is created. On success the visitor is sent to the Dex -// login to sign in with the email + new password. -func (a *App) handleInviteSubmit(w http.ResponseWriter, r *http.Request) { - token := r.PathValue("token") - if a.Invitations == nil { - a.renderStatus(w, r, http.StatusOK, InviteInvalidPage()) - return - } - if err := r.ParseForm(); err != nil { - http.Error(w, "bad form", http.StatusBadRequest) - return - } - password := r.FormValue("password") - confirm := r.FormValue("password_confirm") - - // 1. Validate before consuming the token, so a mismatch/typo is retryable. - if len(password) < minPasswordLen { - a.reshowInvite(w, r, token, "Password must be at least 8 characters.") - return - } - if password != confirm { - a.reshowInvite(w, r, token, "Passwords do not match.") - return - } - - // Off-cluster (dev): we cannot create a Dex account. Degrade clearly WITHOUT - // consuming the invite, so it still works once deployed. - if a.Dex == nil { - a.render(w, r, InviteNoticePage("Account creation only works in the deployed environment.", false)) - return - } - - // 2. Consume the invite exactly once. If the token vanished between GET and - // POST (expired, replay, concurrent claim) this is where it surfaces. - email, err := a.Invitations.ClaimInvitation(r.Context(), token) - if errors.Is(err, store.ErrNotFound) { - a.renderStatus(w, r, http.StatusOK, InviteInvalidPage()) - return - } - if err != nil { - a.serverError(w, r, "claim invitation", err) - return - } - - // 3. Hash the password (cost 12). The Dex client base64-encodes it for the CR. - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) - if err != nil { - a.serverError(w, r, "hash password", err) - return - } - - // 4. Create the Dex local-password account. - userID, err := newID() - if err != nil { - a.serverError(w, r, "new user id", err) - return - } - switch err := a.Dex.CreatePassword(r.Context(), email, string(hash), userID); { - case err == nil: - // 5. Off to the Dex login — a flash surfaces on the first page after login. - setFlash(w, flashAccountCreated) - http.Redirect(w, r, loginPath, http.StatusSeeOther) - case errors.Is(err, dex.ErrPasswordExists): - a.render(w, r, InviteNoticePage("An account with this email already exists. Try logging in.", true)) - case errors.Is(err, dex.ErrForbidden): - a.render(w, r, InviteNoticePage("Unable to create your Dex account — please contact the administrator.", false)) - default: - a.serverError(w, r, "create dex password", err) - } -} - -// reshowInvite re-renders the password form with a validation message, re-fetching -// the email from the (still-unconsumed) token. A token that became invalid in the -// meantime falls back to the expired/used page. -func (a *App) reshowInvite(w http.ResponseWriter, r *http.Request, token, errMsg string) { - email, err := a.Invitations.PeekInvitation(r.Context(), token) - if errors.Is(err, store.ErrNotFound) { - a.renderStatus(w, r, http.StatusOK, InviteInvalidPage()) - return - } - if err != nil { - a.serverError(w, r, "peek invitation", err) - return - } - a.renderStatus(w, r, http.StatusBadRequest, InvitePage(email, token, errMsg)) -} - -// newID returns a fresh random RFC-4122 v4 UUID for the Dex userID field -// (crypto/rand, no new dependency). Kept local rather than coupling web to the -// store package's unexported generator. -func newID() (string, error) { - var b [16]byte - if _, err := rand.Read(b[:]); err != nil { - return "", fmt.Errorf("web: new id: %w", err) - } - b[6] = (b[6] & 0x0f) | 0x40 // version 4 - b[8] = (b[8] & 0x3f) | 0x80 // variant 10 - return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil -} diff --git a/internal/web/invite_test.go b/internal/web/invite_test.go deleted file mode 100644 index 82b41df..0000000 --- a/internal/web/invite_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package web_test - -import ( - "context" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgxpool" - "github.com/stretchr/testify/require" - "golang.org/x/crypto/bcrypt" - - "gitea.d-ma.be/mathias/tapir/internal/adapters/dex" - "gitea.d-ma.be/mathias/tapir/internal/adapters/store" - "gitea.d-ma.be/mathias/tapir/internal/web" -) - -// fakeDex captures the CreatePassword call and returns a canned error. -type fakeDex struct { - called bool - email, hash, userID string - err error -} - -func (f *fakeDex) CreatePassword(_ context.Context, email, hash, userID string) error { - f.called = true - f.email, f.hash, f.userID = email, hash, userID - return f.err -} - -func resetInvites(t *testing.T, p *pgxpool.Pool) { - t.Helper() - _, err := p.Exec(context.Background(), `TRUNCATE invitations`) - require.NoError(t, err) -} - -// inviteMux mounts only the two public invite routes against app, so PathValue -// ("token") is populated exactly as in production without the full Router/auth. -func inviteMux(app *web.App) http.Handler { - mux := http.NewServeMux() - mux.HandleFunc("GET /invite/{token}", app.HandleInviteFormForTest) - mux.HandleFunc("POST /invite/{token}", app.HandleInviteSubmitForTest) - return mux -} - -func newInvite(t *testing.T, st *store.Store, email string, ttl time.Duration) string { - t.Helper() - token, err := st.CreateInvitation(context.Background(), email, ttl) - require.NoError(t, err) - return token -} - -func TestInviteFormValidToken(t *testing.T) { - st, p := newStore(t), rawPool(t) - resetInvites(t, p) - token := newInvite(t, st, "invitee@example.com", time.Hour) - - app := &web.App{Invitations: st, Dex: &fakeDex{}} - rr := httptest.NewRecorder() - inviteMux(app).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/invite/"+token, nil)) - - require.Equal(t, http.StatusOK, rr.Code) - body := rr.Body.String() - require.Contains(t, body, "invitee@example.com") - require.Contains(t, body, "Create my account") -} - -func TestInviteFormInvalidToken(t *testing.T) { - st, p := newStore(t), rawPool(t) - resetInvites(t, p) - - app := &web.App{Invitations: st, Dex: &fakeDex{}} - rr := httptest.NewRecorder() - inviteMux(app).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/invite/nope", nil)) - - require.Equal(t, http.StatusOK, rr.Code) - require.Contains(t, rr.Body.String(), "no longer valid") -} - -func postInvite(app *web.App, token string, form url.Values) *httptest.ResponseRecorder { - rr := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/invite/"+token, strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - inviteMux(app).ServeHTTP(rr, req) - return rr -} - -func TestInviteSubmitPasswordMismatch(t *testing.T) { - st, p := newStore(t), rawPool(t) - resetInvites(t, p) - token := newInvite(t, st, "a@example.com", time.Hour) - fd := &fakeDex{} - app := &web.App{Invitations: st, Dex: fd} - - rr := postInvite(app, token, url.Values{"password": {"longenough1"}, "password_confirm": {"different1"}}) - - require.Equal(t, http.StatusBadRequest, rr.Code) - require.Contains(t, rr.Body.String(), "do not match") - require.False(t, fd.called) - // Token not consumed — still claimable. - _, err := st.PeekInvitation(context.Background(), token) - require.NoError(t, err) -} - -func TestInviteSubmitShortPassword(t *testing.T) { - st, p := newStore(t), rawPool(t) - resetInvites(t, p) - token := newInvite(t, st, "a@example.com", time.Hour) - fd := &fakeDex{} - app := &web.App{Invitations: st, Dex: fd} - - rr := postInvite(app, token, url.Values{"password": {"short"}, "password_confirm": {"short"}}) - - require.Equal(t, http.StatusBadRequest, rr.Code) - require.Contains(t, rr.Body.String(), "at least 8") - require.False(t, fd.called) -} - -func TestInviteSubmitValidCreatesAccount(t *testing.T) { - st, p := newStore(t), rawPool(t) - resetInvites(t, p) - token := newInvite(t, st, "new@example.com", time.Hour) - fd := &fakeDex{} - app := &web.App{Invitations: st, Dex: fd} - - rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}}) - - require.Equal(t, http.StatusSeeOther, rr.Code) - require.Equal(t, "/auth/login", rr.Header().Get("Location")) - - require.True(t, fd.called) - require.Equal(t, "new@example.com", fd.email) - require.NotEmpty(t, fd.userID) - // The handler hands Dex a real bcrypt hash of the chosen password. - require.NoError(t, bcrypt.CompareHashAndPassword([]byte(fd.hash), []byte("correcthorse"))) - - // Flash queued for the post-login page. - require.Contains(t, rr.Header().Get("Set-Cookie"), "tapir_flash=account_created") - - // Token consumed — a second claim fails. - _, err := st.ClaimInvitation(context.Background(), token) - require.ErrorIs(t, err, store.ErrNotFound) -} - -func TestInviteSubmitDevModeNoDex(t *testing.T) { - st, p := newStore(t), rawPool(t) - resetInvites(t, p) - token := newInvite(t, st, "dev@example.com", time.Hour) - app := &web.App{Invitations: st, Dex: nil} // not in-cluster - - rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}}) - - require.Equal(t, http.StatusOK, rr.Code) - require.Contains(t, rr.Body.String(), "deployed environment") - // Token preserved so it still works once deployed. - _, err := st.PeekInvitation(context.Background(), token) - require.NoError(t, err) -} - -func TestInviteSubmitPasswordExists(t *testing.T) { - st, p := newStore(t), rawPool(t) - resetInvites(t, p) - token := newInvite(t, st, "dup@example.com", time.Hour) - app := &web.App{Invitations: st, Dex: &fakeDex{err: dex.ErrPasswordExists}} - - rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}}) - - require.Equal(t, http.StatusOK, rr.Code) - require.Contains(t, rr.Body.String(), "already exists") -} - -func TestInviteSubmitForbidden(t *testing.T) { - st, p := newStore(t), rawPool(t) - resetInvites(t, p) - token := newInvite(t, st, "x@example.com", time.Hour) - app := &web.App{Invitations: st, Dex: &fakeDex{err: dex.ErrForbidden}} - - rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}}) - - require.Equal(t, http.StatusOK, rr.Code) - require.Contains(t, rr.Body.String(), "administrator") -} diff --git a/internal/web/view.go b/internal/web/view.go index 10823df..46b4bd9 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -183,11 +183,6 @@ func statusURL(videoID string) templ.SafeURL { return templ.SafeURL("/v/" + videoID + "/status") } -// inviteURL builds the claim path (POST) for an invite token. -func inviteURL(token string) templ.SafeURL { - return templ.SafeURL("/invite/" + token) -} - // Charmbracelet-inspired palette for the summarizing animation (TapirSpinner) — // a charm purple box, pink tapir, mint snout/eyes/progress. Kept as named consts // so the inline span colours and the CSS track/fill share one source of truth. @@ -338,12 +333,11 @@ type flashView struct { // flashMessages maps each flash code to its banner. An unknown code renders no // banner (flashFor returns ok=false), so a forged cookie value is inert. var flashMessages = map[string]flashView{ - flashConnected: {"success", "YouTube account connected."}, - flashConnectFailed: {"error", "Could not connect your YouTube account. Please try again."}, - flashDisconnected: {"success", "Account disconnected."}, - flashDeleted: {"success", "Your account and all its data were deleted."}, - flashRegistered: {"success", "Welcome to Tapir — your account is ready."}, - flashAccountCreated: {"success", "Account created — log in with your email and password."}, + flashConnected: {"success", "YouTube account connected."}, + flashConnectFailed: {"error", "Could not connect your YouTube account. Please try again."}, + flashDisconnected: {"success", "Account disconnected."}, + flashDeleted: {"success", "Your account and all its data were deleted."}, + flashRegistered: {"success", "Welcome to Tapir — your account is ready."}, } func flashFor(code string) (flashView, bool) { diff --git a/internal/web/views.templ b/internal/web/views.templ index 957c69b..c3b3ec9 100644 --- a/internal/web/views.templ +++ b/internal/web/views.templ @@ -379,68 +379,6 @@ templ RegisterPage(email, errMsg string) { } } -// InvitePage is the public set-password form an invited user reaches via their -// emailed /invite/{token} link. The email is shown read-only (it is fixed by the -// invite, not chosen here); the visitor sets a password to create their account. -// errMsg, when set, reports a validation problem on the prior submit. No auth -// chrome (header nav) is appropriate — the visitor has no session yet — but the -// shared Layout keeps the look consistent. -templ InvitePage(email, token, errMsg string) { - @PublicLayout("Tapir — Set your password") { -
-

Set up your Tapir account

-

Invitation for { email }.

-

Choose a password to finish creating your account. You'll then log in with this email and password.

- if errMsg != "" { - - } -
- - - - -
-
- } -} - -// InviteInvalidPage is shown when an invite token is missing, expired, or already -// used — a dead-end with no form, so a stale or replayed link reads clearly. -templ InviteInvalidPage() { - @PublicLayout("Tapir — Invitation") { -
-

This invite link is no longer valid

-

This invitation has expired or has already been used. Ask for a fresh invite link, or log in if you already have an account.

-

Log in

-
- } -} - -// InviteNoticePage is a terminal message after a submit that neither succeeded nor -// is a retryable validation error (account already exists, RBAC missing, or the -// dev "deployed-only" degrade). showLogin adds a log-in CTA where that is the -// natural next step. -templ InviteNoticePage(message string, showLogin bool) { - @PublicLayout("Tapir — Invitation") { -
-

Invitation

-

{ message }

- if showLogin { -

Log in

- } -
- } -} - // 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 diff --git a/internal/web/views_templ.go b/internal/web/views_templ.go index 46286fb..d27e9ba 100644 --- a/internal/web/views_templ.go +++ b/internal/web/views_templ.go @@ -1453,13 +1453,11 @@ func RegisterPage(email, errMsg string) templ.Component { }) } -// InvitePage is the public set-password form an invited user reaches via their -// emailed /invite/{token} link. The email is shown read-only (it is fixed by the -// invite, not chosen here); the visitor sets a password to create their account. -// errMsg, when set, reports a validation problem on the prior submit. No auth -// chrome (header nav) is appropriate — the visitor has no session yet — but the -// shared Layout keeps the look consistent. -func InvitePage(email, token, errMsg string) templ.Component { +// 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). +func AccountPage(displayName, email string, conns []store.Connection, autoSummarize bool, channelErrors []store.ChannelError, flash string) 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 { @@ -1492,283 +1490,47 @@ func InvitePage(email, token, errMsg string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "

Set up your Tapir account

Invitation for ") + templ_7745c5c3_Err = flashBanner(flash).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "

Account

Display name
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var65 string - templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(email) + templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(displayNameOr(displayName)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 392, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 393, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, ".

Choose a password to finish creating your account. You'll then log in with this email and password.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if errMsg != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "

") + if email != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "

Signed in as
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var66 string - templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg) + templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(email) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 395, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 396, Col: 16} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) - templ_7745c5c3_Err = PublicLayout("Tapir — Set your password").Render(templ.WithChildren(ctx, templ_7745c5c3_Var64), templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -// InviteInvalidPage is shown when an invite token is missing, expired, or already -// used — a dead-end with no form, so a stale or replayed link reads clearly. -func InviteInvalidPage() 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_Var69 := templ.GetChildren(ctx) - if templ_7745c5c3_Var69 == nil { - templ_7745c5c3_Var69 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var70 := 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 = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "

This invite link is no longer valid

This invitation has expired or has already been used. Ask for a fresh invite link, or log in if you already have an account.

Log in

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) - templ_7745c5c3_Err = PublicLayout("Tapir — Invitation").Render(templ.WithChildren(ctx, templ_7745c5c3_Var70), templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -// InviteNoticePage is a terminal message after a submit that neither succeeded nor -// is a retryable validation error (account already exists, RBAC missing, or the -// dev "deployed-only" degrade). showLogin adds a log-in CTA where that is the -// natural next step. -func InviteNoticePage(message string, showLogin 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_Var71 := templ.GetChildren(ctx) - if templ_7745c5c3_Var71 == nil { - templ_7745c5c3_Var71 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var72 := 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 = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "

Invitation

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var73 string - templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(message) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 436, Col: 15} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if showLogin { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "

Log in

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) - templ_7745c5c3_Err = PublicLayout("Tapir — Invitation").Render(templ.WithChildren(ctx, templ_7745c5c3_Var72), templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -// 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). -func AccountPage(displayName, email string, conns []store.Connection, autoSummarize bool, channelErrors []store.ChannelError, flash string) 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_Var74 := templ.GetChildren(ctx) - if templ_7745c5c3_Var74 == nil { - templ_7745c5c3_Var74 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var75 := 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 { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "

Account

Display name
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var76 string - templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(displayNameOr(displayName)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 455, Col: 36} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if email != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "
Signed in as
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var77 string - templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(email) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 458, Col: 16} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "

Summarization

Automatic summarizes every new video as it is discovered. Manual lets you pick which videos to summarize — new videos appear in your list with a Summarize button.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "

Summarization

Automatic summarizes every new video as it is discovered. Manual lets you pick which videos to summarize — new videos appear in your list with a Summarize button.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1776,178 +1538,178 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(channelErrors) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "

Unavailable channels

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "

Unavailable channels

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var78 string - templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d channel(s) returned errors on the last discovery pass.", len(channelErrors))) + var templ_7745c5c3_Var67 string + templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d channel(s) returned errors on the last discovery pass.", len(channelErrors))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 474, Col: 100} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 412, Col: 100} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, " These may have been deleted or made private on YouTube.

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, " These may have been deleted or made private on YouTube.

      ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, ce := range channelErrors { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "
    • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var79 string - templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(ce.ChannelName) + var templ_7745c5c3_Var68 string + templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(ce.ChannelName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 480, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 418, Col: 57} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, " unavailable since ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, " unavailable since ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var80 string - templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.JoinStringErrs(ce.FirstSeen.Format("2006-01-02")) + var templ_7745c5c3_Var69 string + templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(ce.FirstSeen.Format("2006-01-02")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 482, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 420, Col: 89} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var80)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "

Connected accounts

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "

Connected accounts

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(conns) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "

No connected video accounts yet.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "

No connected video accounts yet.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "
      ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, c := range conns { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "
    • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var81 string - templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(providerLabel(c.Provider)) + var templ_7745c5c3_Var70 string + templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(providerLabel(c.Provider)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 497, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 435, Col: 64} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if c.ProviderAccount != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var82 string - templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(c.ProviderAccount) + var templ_7745c5c3_Var71 string + templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(c.ProviderAccount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 499, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 437, Col: 49} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var83 string - templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(c.Status) + var templ_7745c5c3_Var72 string + templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(c.Status) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 501, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 439, Col: 38} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "
      connected ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "
      connected ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var84 string - templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(c.ConnectedAt.Format("2006-01-02")) + var templ_7745c5c3_Var73 string + templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(c.ConnectedAt.Format("2006-01-02")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 503, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 441, Col: 83} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if !hasYouTube(conns) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "

Connect YouTube

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "

Connect YouTube

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "

Delete account

Permanently remove your Tapir account and all of its data — summaries, watch/skip/save actions, and connected accounts. This cannot be undone.

Delete account…

This permanently deletes your account and all data. Are you sure?

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "

Delete account

Permanently remove your Tapir account and all of its data — summaries, watch/skip/save actions, and connected accounts. This cannot be undone.

Delete account…

This permanently deletes your account and all data. Are you sure?

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = Layout("Tapir — Account").Render(templ.WithChildren(ctx, templ_7745c5c3_Var75), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = Layout("Tapir — Account").Render(templ.WithChildren(ctx, templ_7745c5c3_Var64), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1975,51 +1737,51 @@ func summarizeModeControl(auto bool) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var86 := templ.GetChildren(ctx) - if templ_7745c5c3_Var86 == nil { - templ_7745c5c3_Var86 = templ.NopComponent + templ_7745c5c3_Var75 := templ.GetChildren(ctx) + if templ_7745c5c3_Var75 == nil { + templ_7745c5c3_Var75 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "

Current mode: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "

Current mode: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var87 string - templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeLabel(auto)) + var templ_7745c5c3_Var76 string + templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeLabel(auto)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 541, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 479, Col: 53} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2047,117 +1809,117 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var90 := templ.GetChildren(ctx) - if templ_7745c5c3_Var90 == nil { - templ_7745c5c3_Var90 = templ.NopComponent + templ_7745c5c3_Var79 := templ.GetChildren(ctx) + if templ_7745c5c3_Var79 == nil { + templ_7745c5c3_Var79 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, v := range actionVerbs { - var templ_7745c5c3_Var93 = []any{"action", templ.KV("active", active[v])} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var93...) + var templ_7745c5c3_Var82 = []any{"action", templ.KV("active", active[v])} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var82...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }