diff --git a/cmd/tapir/main.go b/cmd/tapir/main.go index c5b78b0..2f3dc4b 100644 --- a/cmd/tapir/main.go +++ b/cmd/tapir/main.go @@ -22,6 +22,7 @@ import ( "os/signal" "time" + "gitea.d-ma.be/mathias/tapir/internal/adapters/dex" "gitea.d-ma.be/mathias/tapir/internal/adapters/secrets" "gitea.d-ma.be/mathias/tapir/internal/adapters/store" "gitea.d-ma.be/mathias/tapir/internal/auth" @@ -182,6 +183,20 @@ func cmdServe(ctx context.Context, log *slog.Logger) error { secretStore := secrets.NewFileStore(cfg.SecretsFile) app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log} + // Email-invite onboarding (public /invite/{token}). The store validates and + // consumes tokens; the Dex client creates the local-password account. In-cluster + // the SA token mount is present and account creation works; off-cluster (dev) it + // is nil and the submit handler degrades to a clear "deployed-only" message. + app.Invitations = st + if dexClient, err := dex.NewPasswordClient(); err == nil { + app.Dex = dexClient + log.Info("invite account creation enabled (in-cluster dex password client)") + } else if errors.Is(err, dex.ErrNotInCluster) { + log.Warn("invite account creation disabled: not in-cluster — /invite is deployed-only") + } else { + return fmt.Errorf("dex password client: %w", err) + } + // Web-initiated YouTube connect (ADR-006). Mounted only when the OAuth client // credentials are present; the refresh token persists through the SecretStore // under a per-user ref (web.YouTubeTokenRef). Live connect also needs the diff --git a/go.mod b/go.mod index 58215c0..e7065b5 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/golang-migrate/migrate/v4 v4.19.1 github.com/jackc/pgx/v5 v5.9.2 github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.45.0 golang.org/x/oauth2 v0.36.0 ) diff --git a/internal/web/export_test.go b/internal/web/export_test.go new file mode 100644 index 0000000..c072529 --- /dev/null +++ b/internal/web/export_test.go @@ -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) +} diff --git a/internal/web/flash.go b/internal/web/flash.go index 26c3e82..cbd2e60 100644 --- a/internal/web/flash.go +++ b/internal/web/flash.go @@ -11,11 +11,12 @@ const flashCookie = "tapir_flash" // Flash codes. Kept small and stable — the message + severity live in // flashMessages (view.go), not here, so the cookie never carries free text. const ( - flashConnected = "connected" - flashConnectFailed = "connect_failed" - flashDisconnected = "disconnected" - flashDeleted = "deleted" - flashRegistered = "registered" + flashConnected = "connected" + flashConnectFailed = "connect_failed" + flashDisconnected = "disconnected" + flashDeleted = "deleted" + flashRegistered = "registered" + flashAccountCreated = "account_created" ) // flashMaxAge bounds how long an unread flash lingers (seconds). Long enough to diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 9bb3fc4..624d515 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -68,6 +68,14 @@ type App struct { // Processing tracks in-flight immediate summarizations so the status endpoint // shows the animation until the summary lands. The zero value is ready to use. Processing ProcessingSet + // Invitations validates and consumes email-invite tokens for the public + // /invite/{token} flow. Nil = the invite routes report "invalid" (the flow is + // effectively off). *store.Store satisfies it. + Invitations InvitationStore + // Dex creates the Dex local-password account when an invite is claimed. Nil = + // not in-cluster (dev): the submit handler degrades to a clear "deployed-only" + // message instead of creating an account. *dex.PasswordClient satisfies it. + Dex DexPasswordCreator } func (a *App) logger() *slog.Logger { @@ -87,6 +95,11 @@ func (a *App) Router() http.Handler { root.Handle("GET /static/", staticHandler()) root.Handle("/auth/", a.Auth.Routes()) + // Email invitation claim (public — the visitor has no Dex session yet, so this + // sits OUTSIDE Auth.Middleware). The token in the path is the capability. + root.HandleFunc("GET /invite/{token}", a.handleInviteForm) + root.HandleFunc("POST /invite/{token}", a.handleInviteSubmit) + app := http.NewServeMux() app.HandleFunc("GET /{$}", a.handleList) app.HandleFunc("GET /v/{videoId}", a.handleDetail) diff --git a/internal/web/invite.go b/internal/web/invite.go new file mode 100644 index 0000000..2f9641a --- /dev/null +++ b/internal/web/invite.go @@ -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 +} diff --git a/internal/web/invite_test.go b/internal/web/invite_test.go new file mode 100644 index 0000000..82b41df --- /dev/null +++ b/internal/web/invite_test.go @@ -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") +} diff --git a/internal/web/view.go b/internal/web/view.go index 764ad34..038b8dd 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -183,6 +183,11 @@ func statusURL(videoID string) templ.SafeURL { return templ.SafeURL("/v/" + videoID + "/status") } +// inviteURL builds the claim path (POST) for an invite token. +func inviteURL(token string) templ.SafeURL { + return templ.SafeURL("/invite/" + token) +} + // Charmbracelet-inspired palette for the summarizing animation (TapirSpinner) — // a charm purple box, pink tapir, mint snout/eyes/progress. Kept as named consts // so the inline span colours and the CSS track/fill share one source of truth. @@ -333,11 +338,12 @@ type flashView struct { // flashMessages maps each flash code to its banner. An unknown code renders no // banner (flashFor returns ok=false), so a forged cookie value is inert. var flashMessages = map[string]flashView{ - flashConnected: {"success", "YouTube account connected."}, - flashConnectFailed: {"error", "Could not connect your YouTube account. Please try again."}, - flashDisconnected: {"success", "Account disconnected."}, - flashDeleted: {"success", "Your account and all its data were deleted."}, - flashRegistered: {"success", "Welcome to Tapir — your account is ready."}, + flashConnected: {"success", "YouTube account connected."}, + flashConnectFailed: {"error", "Could not connect your YouTube account. Please try again."}, + flashDisconnected: {"success", "Account disconnected."}, + flashDeleted: {"success", "Your account and all its data were deleted."}, + flashRegistered: {"success", "Welcome to Tapir — your account is ready."}, + flashAccountCreated: {"success", "Account created — log in with your email and password."}, } func flashFor(code string) (flashView, bool) { diff --git a/internal/web/views.templ b/internal/web/views.templ index 8e6160a..e42990d 100644 --- a/internal/web/views.templ +++ b/internal/web/views.templ @@ -59,7 +59,7 @@ templ WelcomePage(user User, loggedIn bool) {
Get Started
-

New to Tapir? Just sign in — you'll complete a quick setup right after. Already have an account? You'll go straight through.

+

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.

} } @@ -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") { +
+

Set up your Tapir account

+

Invitation for { email }.

+

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

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

This invite link is no longer valid

+

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

+

Log in

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

Invitation

+

{ message }

+ if showLogin { +

Log in

+ } +
+ } +} + // AccountPage is the account-management view: the registered display name and // signed-in email, the user's connected video accounts (each with a Disconnect // control), a Connect-YouTube link when none is connected, and the delete-account diff --git a/internal/web/views_templ.go b/internal/web/views_templ.go index 6f8412e..5af7768 100644 --- a/internal/web/views_templ.go +++ b/internal/web/views_templ.go @@ -153,7 +153,7 @@ func WelcomePage(user User, loggedIn bool) templ.Component { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

Watch less, know more

Tapir summarizes the videos your subscriptions publish, so you can skim the gist and decide what is worth your time.

Get Started

New to Tapir? Just sign in — you'll complete a quick setup right after. Already have an account? You'll go straight through.

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

Watch less, know more

Tapir summarizes the videos your subscriptions publish, so you can skim the gist and decide what is worth your time.

Get Started

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.

") if templ_7745c5c3_Err != nil { 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 -// 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 { +// InvitePage is the public set-password form an invited user reaches via their +// emailed /invite/{token} link. The email is shown read-only (it is fixed by the +// invite, not chosen here); the visitor sets a password to create their account. +// errMsg, when set, reports a validation problem on the prior submit. No auth +// chrome (header nav) is appropriate — the visitor has no session yet — but the +// shared Layout keeps the look consistent. +func InvitePage(email, token, errMsg string) templ.Component { 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 { @@ -1246,47 +1248,283 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar }() } 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, 96, "

Account

Display name
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "

Set up your Tapir account

Invitation for ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } 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 { - 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)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "

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

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if email != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "
Signed in as
") + if errMsg != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } 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 { - 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)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "

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

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

Summarization

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

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "
") + 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, "

This invite link is no longer valid

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

Log in

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = 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, "

Invitation

") + 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, "

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

Log in

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "
") + 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, "

Account

Display name
") + 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if email != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "
Signed in as
") + 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "

Summarization

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1294,119 +1532,119 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "

Connected accounts

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

Connected accounts

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

No connected video accounts yet.

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

No connected video accounts yet.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if !hasYouTube(conns) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "

Connect YouTube

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

Connect YouTube

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

Delete account

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

Delete account…

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

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

Delete account

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

Delete account…

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = Layout("Tapir — Account").Render(templ.WithChildren(ctx, templ_7745c5c3_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 { return templ_7745c5c3_Err } @@ -1434,51 +1672,51 @@ func summarizeModeControl(auto bool) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var62 := templ.GetChildren(ctx) - if templ_7745c5c3_Var62 == nil { - templ_7745c5c3_Var62 = templ.NopComponent + templ_7745c5c3_Var73 := templ.GetChildren(ctx) + if templ_7745c5c3_Var73 == nil { + templ_7745c5c3_Var73 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "

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

Current mode: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var63 string - templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeLabel(auto)) + var templ_7745c5c3_Var74 string + templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeLabel(auto)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 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 { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1506,117 +1744,117 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var66 := templ.GetChildren(ctx) - if templ_7745c5c3_Var66 == nil { - templ_7745c5c3_Var66 = templ.NopComponent + templ_7745c5c3_Var77 := templ.GetChildren(ctx) + if templ_7745c5c3_Var77 == nil { + templ_7745c5c3_Var77 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, v := range actionVerbs { - var templ_7745c5c3_Var69 = []any{"action", templ.KV("active", active[v])} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var69...) + var templ_7745c5c3_Var80 = []any{"action", templ.KV("active", active[v])} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var80...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }