Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72bf8a5553 | ||
|
|
dece5dec44 | ||
|
|
893886a60a | ||
|
|
e44485df16 | ||
|
|
8b7ef07ba3 | ||
|
|
1cf58768ed | ||
|
|
f45ba35e25 |
@@ -51,3 +51,8 @@ TAPIR_POLL_INTERVAL=
|
|||||||
# caption endpoint; after it expires the video is retried. 0 = always retry.
|
# caption endpoint; after it expires the video is retried. 0 = always retry.
|
||||||
# Go duration; default 1h.
|
# Go duration; default 1h.
|
||||||
TAPIR_FETCH_BACKOFF=
|
TAPIR_FETCH_BACKOFF=
|
||||||
|
|
||||||
|
# --- invitations (tapir invite) -------------------------------------------
|
||||||
|
# Public base URL used to build the invite link `tapir invite <email>` prints.
|
||||||
|
# Default https://tapir.d-ma.be; no trailing slash needed.
|
||||||
|
TAPIR_PUBLIC_URL=
|
||||||
|
|||||||
@@ -59,8 +59,11 @@ to the next stage's ambition until the current stage's test passes.
|
|||||||
### Stage 0 — Useful to me or a friend (the gate)
|
### Stage 0 — Useful to me or a friend (the gate)
|
||||||
|
|
||||||
> **Headline test:** Over a 3–4 week window, *either* the maintainer *or* at least one
|
> **Headline test:** Over a 3–4 week window, *either* the maintainer *or* at least one
|
||||||
> onboarded friend returns to Tapir **unprompted** and reads/acts on summaries in **≥2
|
> onboarded friend returns to Tapir and reads/acts on summaries in **≥2 separate weeks**.
|
||||||
> separate weeks**. The test is *return usage* (behavioural), not stated approval.
|
> The test is *return usage* (behavioural), not stated approval. The ideal signal is an
|
||||||
|
> **unprompted** return (organic, not because the maintainer nudged them) — but see the
|
||||||
|
> measurement note below: we currently cannot distinguish prompted from organic returns, so
|
||||||
|
> in practice we count all returns and read the result with that caveat.
|
||||||
|
|
||||||
- Captions-first summarization works end-to-end for real subscriptions (the maintainer's
|
- Captions-first summarization works end-to-end for real subscriptions (the maintainer's
|
||||||
and onboarded friends').
|
and onboarded friends').
|
||||||
@@ -69,12 +72,24 @@ to the next stage's ambition until the current stage's test passes.
|
|||||||
most of the time.
|
most of the time.
|
||||||
- **Why behavioural, not feedback.** Friend *feedback* is gathered and genuinely valuable —
|
- **Why behavioural, not feedback.** Friend *feedback* is gathered and genuinely valuable —
|
||||||
but it is **not** the gate. Asked-for feedback from friendly users is the least reliable
|
but it is **not** the gate. Asked-for feedback from friendly users is the least reliable
|
||||||
signal in product development (politeness bias); whether they *come back on their own* is
|
signal in product development (politeness bias); whether they *come back* is the thing we
|
||||||
the thing we actually care about. So the gate measures returns, not nice words.
|
actually care about. So the gate measures returns, not nice words.
|
||||||
|
- **Measurement note — "unprompted" is an ideal we can't yet measure.** Whether a return was
|
||||||
|
organic or prompted by a nudge is not captured by any data Tapir holds (it's context only
|
||||||
|
the maintainer has). Rather than waive the standard, we name the gap: *unprompted* return
|
||||||
|
is the signal we genuinely want; *returns* (prompted or not) is what the data can show. A
|
||||||
|
return that needed a nudge is a weaker signal than one that didn't, and the result is read
|
||||||
|
with that in mind. If distinguishing them ever matters enough, the maintainer tracks nudges
|
||||||
|
manually or a future build records prompt events — neither is in scope now.
|
||||||
- **Why "me OR a friend".** This replaces the original "useful to *me*, specifically" gate
|
- **Why "me OR a friend".** This replaces the original "useful to *me*, specifically" gate
|
||||||
(2026-06-03 decision, recorded in DECISIONS.md ADR-016). Getting signal from friendly
|
(2026-06-03 decision, recorded in DECISIONS.md ADR-016). Getting signal from friendly
|
||||||
users is valuable enough to count — but the bar stays behavioural so it can't be cleared
|
users is valuable enough to count — but the bar stays behavioural so it can't be cleared
|
||||||
by a polite reaction. (Ties to the 2026-07-01 check-in.)
|
by a polite reaction. (Ties to the 2026-07-01 check-in.)
|
||||||
|
- **How it's measured.** Return usage is read from two sources: `summary_actions` (timestamped
|
||||||
|
watch/skip/save per user) answers "acted in ≥2 distinct weeks"; an append-only login-events
|
||||||
|
table (see infra/Tapir build) answers "returned/read in ≥2 distinct weeks" even without an
|
||||||
|
action click — the honest signal for a *reading* product. Login events accrue only from their
|
||||||
|
deploy date onward, so the gate window's data begins then.
|
||||||
- **This is the gate.** Hardening (Stage 1) and any SaaS ambition stay deferred until this
|
- **This is the gate.** Hardening (Stage 1) and any SaaS ambition stay deferred until this
|
||||||
behavioural signal exists. Note: multi-user machinery was deliberately built *ahead* of
|
behavioural signal exists. Note: multi-user machinery was deliberately built *ahead* of
|
||||||
this gate (ADR-012) with isolation enforced — that was an explicit, recorded call, not a
|
this gate (ADR-012) with isolation enforced — that was an explicit, recorded call, not a
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"time"
|
"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/secrets"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
||||||
@@ -53,6 +54,8 @@ func main() {
|
|||||||
err = cmdRun(ctx, log)
|
err = cmdRun(ctx, log)
|
||||||
case "serve":
|
case "serve":
|
||||||
err = cmdServe(ctx, log)
|
err = cmdServe(ctx, log)
|
||||||
|
case "invite":
|
||||||
|
err = cmdInvite(ctx, os.Args[2:])
|
||||||
default:
|
default:
|
||||||
usage()
|
usage()
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
@@ -71,6 +74,7 @@ usage:
|
|||||||
tapir auth one-time: authorize YouTube and store a refresh token
|
tapir auth one-time: authorize YouTube and store a refresh token
|
||||||
tapir run detect new videos, summarize, deliver to your store
|
tapir run detect new videos, summarize, deliver to your store
|
||||||
tapir serve run the web UI (read summaries, record watch/skip/save)
|
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 list [-limit N] list stored summaries, recent first
|
||||||
tapir show <video-id> show one summary in full
|
tapir show <video-id> show one summary in full
|
||||||
|
|
||||||
@@ -179,6 +183,20 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
|
|||||||
secretStore := secrets.NewFileStore(cfg.SecretsFile)
|
secretStore := secrets.NewFileStore(cfg.SecretsFile)
|
||||||
app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log}
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// Web-initiated YouTube connect (ADR-006). Mounted only when the OAuth client
|
// Web-initiated YouTube connect (ADR-006). Mounted only when the OAuth client
|
||||||
// credentials are present; the refresh token persists through the SecretStore
|
// credentials are present; the refresh token persists through the SecretStore
|
||||||
// under a per-user ref (web.YouTubeTokenRef). Live connect also needs the
|
// under a per-user ref (web.YouTubeTokenRef). Live connect also needs the
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Spec — Stage 0 usage measurement (login events)
|
||||||
|
|
||||||
|
**Date:** 2026-06-03
|
||||||
|
**Status:** Ready to build · **Repo:** tapir · **Size:** small (one migration + middleware + query)
|
||||||
|
**Why:** The Stage 0 gate (VISION, ADR-016) is *return usage in ≥2 separate weeks*. `summary_actions`
|
||||||
|
captures *acts* (watch/skip/save) but not *reads* — a friend who logs in weekly and reads summaries
|
||||||
|
without clicking anything is invisible. For a **reading** product that is the most important signal.
|
||||||
|
This adds the missing data so the gate is measurable as written. Solo session, not a swarm.
|
||||||
|
|
||||||
|
Read `CLAUDE.md` + ADR-016 first. TBD, conventional commits, `task check` green before each commit.
|
||||||
|
|
||||||
|
## Scope (resist sprawl — this is NOT analytics)
|
||||||
|
|
||||||
|
A lightweight, append-only record of *when each user was active*, enough to answer
|
||||||
|
"returned/read in ≥N distinct weeks". Not page-level events, not click tracking, not a funnel.
|
||||||
|
|
||||||
|
### 1. Migration — `login_events` (append-only)
|
||||||
|
```
|
||||||
|
login_events (
|
||||||
|
id UUID PK default gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL, -- per-user; RLS like every user-owned table
|
||||||
|
seen_at TIMESTAMPTZ NOT NULL default NOW()
|
||||||
|
)
|
||||||
|
INDEX (user_id, seen_at)
|
||||||
|
```
|
||||||
|
- **RLS:** `FORCE ROW LEVEL SECURITY`, same policy/pattern as the other user-owned tables (the
|
||||||
|
`tapir.current_user_id` GUC via the `withUser` seam — match migration 003). A reporting query that
|
||||||
|
needs cross-user counts runs as the owner/maintainer outside the per-user scope, or via a dedicated
|
||||||
|
read — decide consistently with how existing admin-ish reads are done.
|
||||||
|
- Append-only: no updates, no deletes except the user-delete cascade. **Add to the delete-account
|
||||||
|
cascade** (ADR-013) — `login_events` has no FK (mirrors `summary_actions`), so `DeleteUser` needs an
|
||||||
|
explicit delete for it, and the delete test must assert it's covered. *Do not forget this* — it's the
|
||||||
|
exact footgun the last delete work caught.
|
||||||
|
|
||||||
|
### 2. Middleware — throttled stamp
|
||||||
|
- In the authenticated request path (after `CurrentUserID` resolves, inside the registration-gated
|
||||||
|
app — NOT on `/welcome`/`/healthz`/`/auth`), record one `login_events` row **per user per day**
|
||||||
|
(throttle: skip if a row exists for this user with `seen_at` ≥ start-of-today). One insert per active
|
||||||
|
day, not per request — keeps the table small and the signal clean.
|
||||||
|
- Throttle check must itself be RLS-scoped (`withUser`). Keep it cheap (indexed lookup).
|
||||||
|
|
||||||
|
### 3. Query — the gate report
|
||||||
|
Provide a query (and optionally a tiny `tapir report` CLI subcommand or an admin page — your call,
|
||||||
|
CLI is fine) answering, per user:
|
||||||
|
```sql
|
||||||
|
-- distinct active weeks from reads (login_events) AND acts (summary_actions), unioned
|
||||||
|
WITH weeks AS (
|
||||||
|
SELECT user_id, date_trunc('week', seen_at) AS wk FROM login_events
|
||||||
|
UNION
|
||||||
|
SELECT user_id, date_trunc('week', acted_at) FROM summary_actions
|
||||||
|
)
|
||||||
|
SELECT user_id, COUNT(DISTINCT wk) AS active_weeks
|
||||||
|
FROM weeks GROUP BY user_id
|
||||||
|
ORDER BY active_weeks DESC;
|
||||||
|
```
|
||||||
|
Gate passes when any user_id (maintainer or friend) reaches `active_weeks >= 2` within the window.
|
||||||
|
|
||||||
|
## Honesty caveats to carry (from VISION/ADR-016)
|
||||||
|
- **"Unprompted" is not measurable here.** login_events records *that* a user returned, not *why*. A
|
||||||
|
nudged return looks identical to an organic one. This build does not close that gap and must not
|
||||||
|
claim to — the VISION measurement note stands: count returns, read a nudged return as weaker signal.
|
||||||
|
(If prompt-tracking is ever wanted, that's a separate decision, not this build.)
|
||||||
|
- **Data accrues from deploy onward.** The gate window's read-data starts when this ships — so ship
|
||||||
|
soon (maintainer's call) rather than batching with the infra tooling session.
|
||||||
|
- **`date_trunc('week')` is ISO/timezone-sensitive** and noisy at low volume (N=3). Two visits days
|
||||||
|
apart can fall in the same or different weeks. Acceptable, but don't over-read a single-week-margin
|
||||||
|
pass/fail.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
Page/event analytics; prompt-vs-organic tracking; dashboards beyond the one gate query; anything
|
||||||
|
touching the engine or sinks (this is web/store only — ADR-003 holds).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- Migration up/down; RLS on `login_events` (extend the two-user isolation test to cover it).
|
||||||
|
- Throttle: N requests same day → 1 row; next day → 2nd row.
|
||||||
|
- `DeleteUser` removes the user's `login_events` and leaves others' intact (extend the delete test).
|
||||||
|
- The gate query returns correct distinct-week counts across a seeded reads+acts fixture.
|
||||||
@@ -10,6 +10,7 @@ require (
|
|||||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
|
golang.org/x/crypto v0.45.0
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
|
|||||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||||
|
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// 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/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"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. NOTE: Dex's kubernetes storage
|
||||||
|
// types the hash as []byte, which Kubernetes JSON-marshals as base64. So the
|
||||||
|
// `hash` field must carry the base64 encoding of the bcrypt string, NOT the raw
|
||||||
|
// bcrypt string — store the raw string and Dex's base64-decode on login yields
|
||||||
|
// garbage and every login fails. CreatePassword does that encoding.
|
||||||
|
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,
|
||||||
|
// base64 of the bcrypt string — see the password type's NOTE.
|
||||||
|
Hash: base64.StdEncoding.EncodeToString([]byte(bcryptHash)),
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidNameChars matches anything not allowed in an RFC-1123 subdomain segment
|
||||||
|
// after the explicit @/. substitutions, so any stray character becomes '-'.
|
||||||
|
var invalidNameChars = regexp.MustCompile(`[^a-z0-9-]`)
|
||||||
|
|
||||||
|
// passwordName maps an email to a valid, deterministic Kubernetes object name:
|
||||||
|
// lowercase, '@' -> '-at-', '.' -> '-dot-', any remaining invalid char -> '-',
|
||||||
|
// with leading/trailing '-' trimmed. Deterministic so a re-invite targets the
|
||||||
|
// same CR (and so Dex's 409 is meaningful).
|
||||||
|
func passwordName(email string) string {
|
||||||
|
n := strings.ToLower(strings.TrimSpace(email))
|
||||||
|
n = strings.ReplaceAll(n, "@", "-at-")
|
||||||
|
n = strings.ReplaceAll(n, ".", "-dot-")
|
||||||
|
n = invalidNameChars.ReplaceAllString(n, "-")
|
||||||
|
n = strings.Trim(n, "-")
|
||||||
|
if n == "" {
|
||||||
|
n = "user"
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package dex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"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-dot-user-at-example-dot-com", gotBody.Metadata["name"])
|
||||||
|
require.Equal(t, "auth", gotBody.Metadata["namespace"])
|
||||||
|
|
||||||
|
// The hash is the BASE64 of the bcrypt string (Dex stores hash as []byte).
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(gotBody.Hash)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "$2a$12$abcdefghijklmnopqrstuv", string(decoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"Alice@Example.com": "alice-at-example-dot-com",
|
||||||
|
"a.b+c@gmail.com": "a-dot-b-c-at-gmail-dot-com",
|
||||||
|
"UPPER@DOMAIN.IO": "upper-at-domain-dot-io",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
require.Equal(t, want, passwordName(in), in)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS invitations;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Migration 009: invitations — an email-based invite to join Tapir (Stage-1
|
||||||
|
-- onboarding gate). Mathias mints one with `tapir invite <email>`; the recipient
|
||||||
|
-- visits /invite/{token}, sets a password, and Tapir creates their Dex account.
|
||||||
|
--
|
||||||
|
-- Deliberately NOT user-owned and NOT under RLS: an invitation exists BEFORE the
|
||||||
|
-- user does, so there is no user_id to scope by and no authenticated user context
|
||||||
|
-- when the invite is created (host CLI) or consumed (public /invite handler, no
|
||||||
|
-- Dex session). The token itself is the capability — a 32-byte crypto-random,
|
||||||
|
-- single-use, time-boxed secret. Hence no `user_id` FK and no ENABLE/FORCE ROW
|
||||||
|
-- LEVEL SECURITY here (unlike every user-owned table in migrations 003/005).
|
||||||
|
CREATE TABLE invitations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Lookups are by token (both the claim and the form preview); the UNIQUE
|
||||||
|
-- constraint already creates an index, this names one explicitly for clarity.
|
||||||
|
CREATE INDEX idx_invitations_token ON invitations(token);
|
||||||
@@ -68,6 +68,11 @@ type Config struct {
|
|||||||
// HTTPAddr is the listen address for `tapir serve` (the Stage-0 web UI).
|
// HTTPAddr is the listen address for `tapir serve` (the Stage-0 web UI).
|
||||||
HTTPAddr string
|
HTTPAddr string
|
||||||
|
|
||||||
|
// PublicURL is the externally-reachable base URL of the deployed service,
|
||||||
|
// e.g. "https://tapir.d-ma.be". Used to build absolute links handed to humans
|
||||||
|
// (the `tapir invite` URL). No trailing slash is assumed — callers trim it.
|
||||||
|
PublicURL string
|
||||||
|
|
||||||
// Dex OIDC (web login, ADR-011/012). When OIDCIssuer is empty, `serve` falls
|
// Dex OIDC (web login, ADR-011/012). When OIDCIssuer is empty, `serve` falls
|
||||||
// back to the allow-all StubAuth (local dev). When set, serve uses Dex: any
|
// back to the allow-all StubAuth (local dev). When set, serve uses Dex: any
|
||||||
// Dex-authenticated subject may sign in, then registers a tapir user (ADR-012).
|
// Dex-authenticated subject may sign in, then registers a tapir user (ADR-012).
|
||||||
@@ -92,6 +97,7 @@ const (
|
|||||||
defaultOAuthRedirectAddr = "localhost:8080"
|
defaultOAuthRedirectAddr = "localhost:8080"
|
||||||
defaultHTTPAddr = ":8080"
|
defaultHTTPAddr = ":8080"
|
||||||
defaultFetchBackoff = time.Hour
|
defaultFetchBackoff = time.Hour
|
||||||
|
defaultPublicURL = "https://tapir.d-ma.be"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Load reads the environment into a Config, applying defaults. It does not
|
// Load reads the environment into a Config, applying defaults. It does not
|
||||||
@@ -112,6 +118,7 @@ func Load() (Config, error) {
|
|||||||
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
|
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
|
||||||
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
|
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
|
||||||
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
|
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
|
||||||
|
PublicURL: envOr("TAPIR_PUBLIC_URL", defaultPublicURL),
|
||||||
OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"),
|
OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"),
|
||||||
DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"),
|
DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"),
|
||||||
DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"),
|
DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"),
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -11,11 +11,12 @@ const flashCookie = "tapir_flash"
|
|||||||
// Flash codes. Kept small and stable — the message + severity live in
|
// Flash codes. Kept small and stable — the message + severity live in
|
||||||
// flashMessages (view.go), not here, so the cookie never carries free text.
|
// flashMessages (view.go), not here, so the cookie never carries free text.
|
||||||
const (
|
const (
|
||||||
flashConnected = "connected"
|
flashConnected = "connected"
|
||||||
flashConnectFailed = "connect_failed"
|
flashConnectFailed = "connect_failed"
|
||||||
flashDisconnected = "disconnected"
|
flashDisconnected = "disconnected"
|
||||||
flashDeleted = "deleted"
|
flashDeleted = "deleted"
|
||||||
flashRegistered = "registered"
|
flashRegistered = "registered"
|
||||||
|
flashAccountCreated = "account_created"
|
||||||
)
|
)
|
||||||
|
|
||||||
// flashMaxAge bounds how long an unread flash lingers (seconds). Long enough to
|
// flashMaxAge bounds how long an unread flash lingers (seconds). Long enough to
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ type App struct {
|
|||||||
// Processing tracks in-flight immediate summarizations so the status endpoint
|
// Processing tracks in-flight immediate summarizations so the status endpoint
|
||||||
// shows the animation until the summary lands. The zero value is ready to use.
|
// shows the animation until the summary lands. The zero value is ready to use.
|
||||||
Processing ProcessingSet
|
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 {
|
func (a *App) logger() *slog.Logger {
|
||||||
@@ -87,6 +95,11 @@ func (a *App) Router() http.Handler {
|
|||||||
root.Handle("GET /static/", staticHandler())
|
root.Handle("GET /static/", staticHandler())
|
||||||
root.Handle("/auth/", a.Auth.Routes())
|
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 := http.NewServeMux()
|
||||||
app.HandleFunc("GET /{$}", a.handleList)
|
app.HandleFunc("GET /{$}", a.handleList)
|
||||||
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
+11
-5
@@ -183,6 +183,11 @@ func statusURL(videoID string) templ.SafeURL {
|
|||||||
return templ.SafeURL("/v/" + videoID + "/status")
|
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) —
|
// Charmbracelet-inspired palette for the summarizing animation (TapirSpinner) —
|
||||||
// a charm purple box, pink tapir, mint snout/eyes/progress. Kept as named consts
|
// 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.
|
// so the inline span colours and the CSS track/fill share one source of truth.
|
||||||
@@ -333,11 +338,12 @@ type flashView struct {
|
|||||||
// flashMessages maps each flash code to its banner. An unknown code renders no
|
// 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.
|
// banner (flashFor returns ok=false), so a forged cookie value is inert.
|
||||||
var flashMessages = map[string]flashView{
|
var flashMessages = map[string]flashView{
|
||||||
flashConnected: {"success", "YouTube account connected."},
|
flashConnected: {"success", "YouTube account connected."},
|
||||||
flashConnectFailed: {"error", "Could not connect your YouTube account. Please try again."},
|
flashConnectFailed: {"error", "Could not connect your YouTube account. Please try again."},
|
||||||
flashDisconnected: {"success", "Account disconnected."},
|
flashDisconnected: {"success", "Account disconnected."},
|
||||||
flashDeleted: {"success", "Your account and all its data were deleted."},
|
flashDeleted: {"success", "Your account and all its data were deleted."},
|
||||||
flashRegistered: {"success", "Welcome to Tapir — your account is ready."},
|
flashRegistered: {"success", "Welcome to Tapir — your account is ready."},
|
||||||
|
flashAccountCreated: {"success", "Account created — log in with your email and password."},
|
||||||
}
|
}
|
||||||
|
|
||||||
func flashFor(code string) (flashView, bool) {
|
func flashFor(code string) (flashView, bool) {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ templ WelcomePage(user User, loggedIn bool) {
|
|||||||
<div class="welcome-cta">
|
<div class="welcome-cta">
|
||||||
<a class="btn btn-lg" href="/auth/login">Get Started</a>
|
<a class="btn btn-lg" href="/auth/login">Get Started</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="welcome-sub">New to Tapir? Just sign in — you'll complete a quick setup right after. Already have an account? You'll go straight through.</p>
|
<p class="welcome-sub">Access is by invitation. If you have an invite link, it will set up your account automatically. Returning users with credentials can log in above.</p>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@@ -307,6 +307,68 @@ 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) {
|
||||||
|
@Layout("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() {
|
||||||
|
@Layout("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) {
|
||||||
|
@Layout("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
|
// AccountPage is the account-management view: the registered display name and
|
||||||
// signed-in email, the user's connected video accounts (each with a Disconnect
|
// 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
|
// control), a Connect-YouTube link when none is connected, and the delete-account
|
||||||
|
|||||||
+353
-115
@@ -153,7 +153,7 @@ func WelcomePage(user User, loggedIn bool) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<h1 class=\"welcome-title\">Watch less, know more</h1><p class=\"welcome-tagline\">Tapir summarizes the videos your subscriptions publish, so you can skim the gist and decide what is worth your time.</p><div class=\"welcome-cta\"><a class=\"btn btn-lg\" href=\"/auth/login\">Get Started</a></div><p class=\"welcome-sub\">New to Tapir? Just sign in — you'll complete a quick setup right after. Already have an account? You'll go straight through.</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<h1 class=\"welcome-title\">Watch less, know more</h1><p class=\"welcome-tagline\">Tapir summarizes the videos your subscriptions publish, so you can skim the gist and decide what is worth your time.</p><div class=\"welcome-cta\"><a class=\"btn btn-lg\" href=\"/auth/login\">Get Started</a></div><p class=\"welcome-sub\">Access is by invitation. If you have an invite link, it will set up your account automatically. Returning users with credentials can log in above.</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -1209,11 +1209,13 @@ func RegisterPage(email, errMsg string) templ.Component {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountPage is the account-management view: the registered display name and
|
// InvitePage is the public set-password form an invited user reaches via their
|
||||||
// signed-in email, the user's connected video accounts (each with a Disconnect
|
// emailed /invite/{token} link. The email is shown read-only (it is fixed by the
|
||||||
// control), a Connect-YouTube link when none is connected, and the delete-account
|
// invite, not chosen here); the visitor sets a password to create their account.
|
||||||
// danger zone. flash surfaces a one-shot notification (disconnect/connect).
|
// errMsg, when set, reports a validation problem on the prior submit. No auth
|
||||||
func AccountPage(displayName, email string, conns []store.Connection, autoSummarize bool, flash string) templ.Component {
|
// 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 {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
@@ -1246,47 +1248,283 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Err = flashBanner(flash).Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<article class=\"register\"><h1>Set up your Tapir account</h1><p class=\"meta\">Invitation for ")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, " <article class=\"account\"><h1>Account</h1><dl class=\"account-meta\"><dt>Display name</dt><dd>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var55 string
|
var templ_7745c5c3_Var55 string
|
||||||
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(displayNameOr(displayName))
|
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(email)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 321, Col: 36}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 320, Col: 41}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</dd>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, ".</p><p>Choose a password to finish creating your account. You'll then log in with this email and password.</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if email != "" {
|
if errMsg != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<dt>Signed in as</dt><dd>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<p class=\"error\" role=\"alert\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var56 string
|
var templ_7745c5c3_Var56 string
|
||||||
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(email)
|
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 324, Col: 16}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 323, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</dd>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</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, 100, "<form method=\"post\" action=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var57 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var57, 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: 325, Col: 48}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" class=\"register-form\"><label>Email <input type=\"email\" name=\"email\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var58 string
|
||||||
|
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue(email)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 328, Col: 51}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var58)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\" 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 = Layout("Tapir — Set your password").Render(templ.WithChildren(ctx, templ_7745c5c3_Var54), 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_Var59 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var59 == nil {
|
||||||
|
templ_7745c5c3_Var59 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Var60 := 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, 103, "<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 = Layout("Tapir — Invitation").Render(templ.WithChildren(ctx, templ_7745c5c3_Var60), 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_Var61 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var61 == nil {
|
||||||
|
templ_7745c5c3_Var61 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Var62 := 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, 104, "<article class=\"register\"><h1>Invitation</h1><p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var63 string
|
||||||
|
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(message)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 364, Col: 15}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if showLogin {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "<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, 107, "</article>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
templ_7745c5c3_Err = Layout("Tapir — Invitation").Render(templ.WithChildren(ctx, templ_7745c5c3_Var62), 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, 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_Var64 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var64 == nil {
|
||||||
|
templ_7745c5c3_Var64 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Var65 := 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, 108, " <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_Var66 string
|
||||||
|
templ_7745c5c3_Var66, 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: 383, Col: 36}
|
||||||
|
}
|
||||||
|
_, 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, 109, "</dd>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if email != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<dt>Signed in as</dt><dd>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var67 string
|
||||||
|
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(email)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 386, Col: 16}
|
||||||
|
}
|
||||||
|
_, 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, 111, "</dd>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -1294,119 +1532,119 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</section><section><h2>Connected accounts</h2>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "</section><section><h2>Connected accounts</h2>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(conns) == 0 {
|
if len(conns) == 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "<p class=\"muted\">No connected video accounts yet.</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "<p class=\"muted\">No connected video accounts yet.</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "<ul class=\"conn-list\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<ul class=\"conn-list\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, c := range conns {
|
for _, c := range conns {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<li class=\"conn\"><div class=\"conn-main\"><span class=\"conn-provider\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "<li class=\"conn\"><div class=\"conn-main\"><span class=\"conn-provider\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var57 string
|
var templ_7745c5c3_Var68 string
|
||||||
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(providerLabel(c.Provider))
|
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(providerLabel(c.Provider))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 345, Col: 64}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 407, Col: 64}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if c.ProviderAccount != "" {
|
if c.ProviderAccount != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "<span class=\"muted\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "<span class=\"muted\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var58 string
|
var templ_7745c5c3_Var69 string
|
||||||
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(c.ProviderAccount)
|
templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(c.ProviderAccount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 347, Col: 49}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 409, Col: 49}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<span class=\"chip\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "<span class=\"chip\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var59 string
|
var templ_7745c5c3_Var70 string
|
||||||
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(c.Status)
|
templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(c.Status)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 349, Col: 38}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 411, Col: 38}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</span></div><div class=\"conn-meta muted\">connected ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</span></div><div class=\"conn-meta muted\">connected ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var60 string
|
var templ_7745c5c3_Var71 string
|
||||||
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(c.ConnectedAt.Format("2006-01-02"))
|
templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(c.ConnectedAt.Format("2006-01-02"))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 351, Col: 83}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 413, Col: 83}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "</div><form method=\"post\" action=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</div><form method=\"post\" action=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var61 templ.SafeURL
|
var templ_7745c5c3_Var72 templ.SafeURL
|
||||||
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinURLErrs(disconnectURL(c.Provider))
|
templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinURLErrs(disconnectURL(c.Provider))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 352, Col: 62}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 414, Col: 62}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\"><button type=\"submit\" class=\"btn-secondary\">Disconnect</button></form></li>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\"><button type=\"submit\" class=\"btn-secondary\">Disconnect</button></form></li>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</ul>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "</ul>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !hasYouTube(conns) {
|
if !hasYouTube(conns) {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<p><a class=\"btn\" href=\"/oauth/youtube/connect\">Connect YouTube</a></p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<p><a class=\"btn\" href=\"/oauth/youtube/connect\">Connect YouTube</a></p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "</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, 126, "</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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
templ_7745c5c3_Err = Layout("Tapir — Account").Render(templ.WithChildren(ctx, templ_7745c5c3_Var54), templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Layout("Tapir — Account").Render(templ.WithChildren(ctx, templ_7745c5c3_Var65), templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -1434,51 +1672,51 @@ func summarizeModeControl(auto bool) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var62 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var73 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var62 == nil {
|
if templ_7745c5c3_Var73 == nil {
|
||||||
templ_7745c5c3_Var62 = templ.NopComponent
|
templ_7745c5c3_Var73 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<div id=\"summarize-mode\" class=\"summarize-mode\"><p>Current mode: <strong>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<div id=\"summarize-mode\" class=\"summarize-mode\"><p>Current mode: <strong>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var63 string
|
var templ_7745c5c3_Var74 string
|
||||||
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeLabel(auto))
|
templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeLabel(auto))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 389, Col: 53}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 451, Col: 53}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</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, 128, "</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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var64 string
|
var templ_7745c5c3_Var75 string
|
||||||
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolStr(!auto))
|
templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolStr(!auto))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 397, Col: 61}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 459, Col: 61}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "\"> <button type=\"submit\" class=\"btn-secondary\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "\"> <button type=\"submit\" class=\"btn-secondary\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var65 string
|
var templ_7745c5c3_Var76 string
|
||||||
templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeToggleLabel(auto))
|
templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeToggleLabel(auto))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 398, Col: 79}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 460, Col: 79}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</button></form></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</button></form></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -1506,117 +1744,117 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var66 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var77 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var66 == nil {
|
if templ_7745c5c3_Var77 == nil {
|
||||||
templ_7745c5c3_Var66 = templ.NopComponent
|
templ_7745c5c3_Var77 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<form id=\"action-buttons\" class=\"actions\" method=\"post\" action=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<form id=\"action-buttons\" class=\"actions\" method=\"post\" action=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var67 templ.SafeURL
|
var templ_7745c5c3_Var78 templ.SafeURL
|
||||||
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinURLErrs(actionURL(videoID))
|
templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinURLErrs(actionURL(videoID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 412, Col: 29}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 474, Col: 29}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "\" hx-post=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "\" hx-post=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var68 string
|
var templ_7745c5c3_Var79 string
|
||||||
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(actionURL(videoID)))
|
templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(actionURL(videoID)))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 413, Col: 38}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 475, Col: 38}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, v := range actionVerbs {
|
for _, v := range actionVerbs {
|
||||||
var templ_7745c5c3_Var69 = []any{"action", templ.KV("active", active[v])}
|
var templ_7745c5c3_Var80 = []any{"action", templ.KV("active", active[v])}
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var69...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var80...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "<button type=\"submit\" name=\"action\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "<button type=\"submit\" name=\"action\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var70 string
|
var templ_7745c5c3_Var81 string
|
||||||
templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(v)
|
templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.ResolveAttributeValue(v)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 421, Col: 13}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 483, Col: 13}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var81)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\" class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var71 string
|
var templ_7745c5c3_Var82 string
|
||||||
templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var69).String())
|
templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var80).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\" aria-pressed=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "\" aria-pressed=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var72 string
|
var templ_7745c5c3_Var83 string
|
||||||
templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(ariaPressed(active[v]))
|
templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.ResolveAttributeValue(ariaPressed(active[v]))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 423, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 485, Col: 41}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var83)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if active[v] {
|
if active[v] {
|
||||||
var templ_7745c5c3_Var73 string
|
var templ_7745c5c3_Var84 string
|
||||||
templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs("✓ " + actionLabel(v))
|
templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs("✓ " + actionLabel(v))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 426, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 488, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var templ_7745c5c3_Var74 string
|
var templ_7745c5c3_Var85 string
|
||||||
templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(actionLabel(v))
|
templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(actionLabel(v))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 428, Col: 21}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 490, Col: 21}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</button>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</button>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "</form>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "</form>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user