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 != "" {
-
{ 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.
-
- }
-}
-
-// 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") {
-
-
- }
-
- }
-}
-
// 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, "
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.
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.