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) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 23:01:14 +02:00
co-authored by Claude Opus 4.8
parent 9cd3f7e934
commit e7c2e575d3
16 changed files with 146 additions and 1453 deletions
-8
View File
@@ -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:
-61
View File
@@ -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 <email>.
func cmdInvite(ctx context.Context, args []string) error {
if len(args) < 1 || strings.TrimSpace(args[0]) == "" {
return fmt.Errorf("usage: tapir invite <email>")
}
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
}
-59
View File
@@ -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())
}
+3 -17
View File
@@ -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 <email> mint an invitation link for a new user (host-side)
tapir list [-limit N] list stored summaries, recent first
tapir show <video-id> 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
-181
View File
@@ -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
}
-105
View File
@@ -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)
}
}
-86
View File
@@ -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
}
-110
View File
@@ -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)
}
-14
View File
@@ -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)
}
+5 -6
View File
@@ -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
-13
View File
@@ -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)
-164
View File
@@ -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
}
-185
View File
@@ -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")
}
+5 -11
View File
@@ -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) {
-62
View File
@@ -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") {
<article class="register">
<h1>Set up your Tapir account</h1>
<p class="meta">Invitation for { email }.</p>
<p>Choose a password to finish creating your account. You'll then log in with this email and password.</p>
if errMsg != "" {
<p class="error" role="alert">{ errMsg }</p>
}
<form method="post" action={ inviteURL(token) } class="register-form">
<label>
Email
<input type="email" name="email" value={ email } readonly/>
</label>
<label>
Password
<input type="password" name="password" minlength="8" required autofocus autocomplete="new-password"/>
</label>
<label>
Confirm password
<input type="password" name="password_confirm" minlength="8" required autocomplete="new-password"/>
</label>
<button type="submit" class="btn">Create my account</button>
</form>
</article>
}
}
// 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") {
<article class="register">
<h1>This invite link is no longer valid</h1>
<p>This invitation has expired or has already been used. Ask for a fresh invite link, or log in if you already have an account.</p>
<p><a class="btn" href="/auth/login">Log in</a></p>
</article>
}
}
// 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") {
<article class="register">
<h1>Invitation</h1>
<p>{ message }</p>
if showLogin {
<p><a class="btn" href="/auth/login">Log in</a></p>
}
</article>
}
}
// 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
+133 -371
View File
@@ -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, "<article class=\"register\"><h1>Set up your Tapir account</h1><p class=\"meta\">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, " <article class=\"account\"><h1>Account</h1><dl class=\"account-meta\"><dt>Display name</dt><dd>")
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, ".</p><p>Choose a password to finish creating your account. You'll then log in with this email and password.</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "</dd>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if errMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "<p class=\"error\" role=\"alert\">")
if email != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "<dt>Signed in as</dt><dd>")
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, "</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</dd>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var67 templ.SafeURL
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinURLErrs(inviteURL(token))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 397, Col: 48}
}
_, 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, 123, "\" class=\"register-form\"><label>Email <input type=\"email\" name=\"email\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var68 string
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 400, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\" readonly></label> <label>Password <input type=\"password\" name=\"password\" minlength=\"8\" required autofocus autocomplete=\"new-password\"></label> <label>Confirm password <input type=\"password\" name=\"password_confirm\" minlength=\"8\" required autocomplete=\"new-password\"></label> <button type=\"submit\" class=\"btn\">Create my account</button></form></article>")
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, "<article class=\"register\"><h1>This invite link is no longer valid</h1><p>This invitation has expired or has already been used. Ask for a fresh invite link, or log in if you already have an account.</p><p><a class=\"btn\" href=\"/auth/login\">Log in</a></p></article>")
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, "<article class=\"register\"><h1>Invitation</h1><p>")
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, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if showLogin {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<p><a class=\"btn\" href=\"/auth/login\">Log in</a></p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</article>")
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, " <article class=\"account\"><h1>Account</h1><dl class=\"account-meta\"><dt>Display name</dt><dd>")
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, "</dd>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if email != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "<dt>Signed in as</dt><dd>")
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, "</dd>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</dl><section><h2>Summarization</h2><p class=\"muted\">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.</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</dl><section><h2>Summarization</h2><p class=\"muted\">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.</p>")
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, "</section>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "</section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(channelErrors) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "<section class=\"channel-errors\"><h2>Unavailable channels</h2><p class=\"muted\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<section class=\"channel-errors\"><h2>Unavailable channels</h2><p class=\"muted\">")
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.</p><ul class=\"channel-error-list\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, " These may have been deleted or made private on YouTube.</p><ul class=\"channel-error-list\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, ce := range channelErrors {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "<li><span class=\"channel-error-name\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<li><span class=\"channel-error-name\">")
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, "</span> <span class=\"chip chip-warn\">unavailable</span> <span class=\"muted channel-error-since\">since ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "</span> <span class=\"chip chip-warn\">unavailable</span> <span class=\"muted channel-error-since\">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, "</span></li>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "</span></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</ul></section>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</ul></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<section><h2>Connected accounts</h2>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<section><h2>Connected accounts</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(conns) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "<p class=\"muted\">No connected video accounts yet.</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<p class=\"muted\">No connected video accounts yet.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<ul class=\"conn-list\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "<ul class=\"conn-list\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, c := range conns {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<li class=\"conn\"><div class=\"conn-main\"><span class=\"conn-provider\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<li class=\"conn\"><div class=\"conn-main\"><span class=\"conn-provider\">")
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, "</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if c.ProviderAccount != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<span class=\"muted\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "<span class=\"muted\">")
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, "</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<span class=\"chip\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<span class=\"chip\">")
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, "</span></div><div class=\"conn-meta muted\">connected ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</span></div><div class=\"conn-meta muted\">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, "</div><form method=\"post\" action=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "</div><form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var85 templ.SafeURL
templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinURLErrs(disconnectURL(c.Provider))
var templ_7745c5c3_Var74 templ.SafeURL
templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinURLErrs(disconnectURL(c.Provider))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 504, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 442, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "\"><button type=\"submit\" class=\"btn-secondary\">Disconnect</button></form></li>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "\"><button type=\"submit\" class=\"btn-secondary\">Disconnect</button></form></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "</ul>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if !hasYouTube(conns) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "<p><a class=\"btn\" href=\"/oauth/youtube/connect\">Connect YouTube</a></p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<p><a class=\"btn\" href=\"/oauth/youtube/connect\">Connect YouTube</a></p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</section><section class=\"danger-zone\"><h2>Delete account</h2><p class=\"muted\">Permanently remove your Tapir account and all of its data — summaries, watch/skip/save actions, and connected accounts. This cannot be undone.</p><details class=\"confirm-delete\"><summary class=\"btn-danger\">Delete account…</summary><div class=\"confirm-body\"><p>This permanently deletes your account and all data. Are you sure?</p><form method=\"post\" action=\"/account/delete\"><button type=\"submit\" class=\"btn-danger\">Yes, permanently delete my account</button></form></div></details></section></article>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "</section><section class=\"danger-zone\"><h2>Delete account</h2><p class=\"muted\">Permanently remove your Tapir account and all of its data — summaries, watch/skip/save actions, and connected accounts. This cannot be undone.</p><details class=\"confirm-delete\"><summary class=\"btn-danger\">Delete account…</summary><div class=\"confirm-body\"><p>This permanently deletes your account and all data. Are you sure?</p><form method=\"post\" action=\"/account/delete\"><button type=\"submit\" class=\"btn-danger\">Yes, permanently delete my account</button></form></div></details></section></article>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = Layout("Tapir — Account").Render(templ.WithChildren(ctx, templ_7745c5c3_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, "<div id=\"summarize-mode\" class=\"summarize-mode\"><p>Current mode: <strong>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<div id=\"summarize-mode\" class=\"summarize-mode\"><p>Current mode: <strong>")
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, "</strong></p><form method=\"post\" action=\"/account/summarize-mode\" hx-post=\"/account/summarize-mode\" hx-target=\"#summarize-mode\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"enabled\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "</strong></p><form method=\"post\" action=\"/account/summarize-mode\" hx-post=\"/account/summarize-mode\" hx-target=\"#summarize-mode\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"enabled\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var88 string
templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolStr(!auto))
var templ_7745c5c3_Var77 string
templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolStr(!auto))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 549, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 487, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "\"> <button type=\"submit\" class=\"btn-secondary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\"> <button type=\"submit\" class=\"btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var89 string
templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeToggleLabel(auto))
var templ_7745c5c3_Var78 string
templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeToggleLabel(auto))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 550, Col: 79}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 488, Col: 79}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "</button></form></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "</button></form></div>")
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, "<form id=\"action-buttons\" class=\"actions\" method=\"post\" action=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<form id=\"action-buttons\" class=\"actions\" method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var91 templ.SafeURL
templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinURLErrs(actionURL(videoID))
var templ_7745c5c3_Var80 templ.SafeURL
templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.JoinURLErrs(actionURL(videoID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 564, Col: 29}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 502, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var80))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "\" hx-post=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var92 string
templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(actionURL(videoID)))
var templ_7745c5c3_Var81 string
templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(actionURL(videoID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 565, Col: 38}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 503, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var81)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\">")
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, "<button type=\"submit\" name=\"action\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<button type=\"submit\" name=\"action\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var94 string
templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.ResolveAttributeValue(v)
var templ_7745c5c3_Var83 string
templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.ResolveAttributeValue(v)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 573, Col: 13}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 511, Col: 13}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var94)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var83)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "\" class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var95 string
templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var93).String())
var templ_7745c5c3_Var84 string
templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var82).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var95)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var84)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\" aria-pressed=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "\" aria-pressed=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var96 string
templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.ResolveAttributeValue(ariaPressed(active[v]))
var templ_7745c5c3_Var85 string
templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.ResolveAttributeValue(ariaPressed(active[v]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 575, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 513, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var96)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var85)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if active[v] {
var templ_7745c5c3_Var97 string
templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs("✓ " + actionLabel(v))
var templ_7745c5c3_Var86 string
templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs("✓ " + actionLabel(v))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 578, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 516, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var98 string
templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(actionLabel(v))
var templ_7745c5c3_Var87 string
templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(actionLabel(v))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 580, Col: 21}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 518, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "</form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}