feat(web): registration gate + per-request user-id seam (ADR-012)
Multi-user web surface. Two layered middlewares: Auth.Middleware (Dex session required) wraps registrationGate, which resolves the authenticated subject -> tapir user_id once per request via the new web.Identity port and stashes it. A subject with no tapir user is redirected to GET /register (display name + accept-terms); POST /register calls RegisterUser then redirects to /. /register is inside the auth guard but exempt from the gate (/auth/* and /healthz too). Current-user seam: CurrentUserID(r) (string, bool) returns the resolved id from the request context. The list/detail/action handlers now scope by it, replacing the single configured App.UserID (removed). App gains an Identity field; *store.Store satisfies both Store and Identity. cmd/tapir wires Identity: st and drops UserID. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+53
-15
@@ -23,15 +23,16 @@ type Store interface {
|
||||
ClearAction(ctx context.Context, userID, videoID, action string) error
|
||||
}
|
||||
|
||||
// App is the Stage-0 web surface: handlers over the store, gated by an Auth
|
||||
// implementation. UserID is the single configured tapir user every store
|
||||
// operation runs as (ADR-011 — Auth only gates access; it does not select the
|
||||
// store identity).
|
||||
// App is the Stage-1 web surface: handlers over the store, gated by an Auth
|
||||
// implementation (authentication) and a registration gate (which resolves the
|
||||
// authenticated subject to its tapir user_id and stashes it per request). Every
|
||||
// data handler scopes by that resolved id — CurrentUserID(r) — not by a single
|
||||
// configured user (ADR-012, multi-user with enforced isolation).
|
||||
type App struct {
|
||||
Store Store
|
||||
Auth Auth
|
||||
UserID string
|
||||
Log *slog.Logger
|
||||
Store Store
|
||||
Identity Identity
|
||||
Auth Auth
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
func (a *App) logger() *slog.Logger {
|
||||
@@ -54,8 +55,14 @@ func (a *App) Router() http.Handler {
|
||||
app.HandleFunc("GET /{$}", a.handleList)
|
||||
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
||||
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
|
||||
app.HandleFunc("GET /register", a.handleRegisterForm)
|
||||
app.HandleFunc("POST /register", a.handleRegister)
|
||||
|
||||
root.Handle("/", a.Auth.Middleware(app))
|
||||
// Two layers: Auth.Middleware requires a Dex session (you must be logged in);
|
||||
// registrationGate requires a tapir user (else → /register) and stashes the
|
||||
// resolved user_id. /register lives inside the auth guard but is exempt from
|
||||
// the registration gate (you must be able to reach it before you have a user).
|
||||
root.Handle("/", a.Auth.Middleware(a.registrationGate(app)))
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -69,6 +76,10 @@ func (a *App) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||
// query string. An HTMX request gets only the table fragment so the filter form
|
||||
// can swap #summary-list in place; a plain request gets the full page.
|
||||
func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := a.currentUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
f := Filter{
|
||||
Channel: q.Get("channel"),
|
||||
@@ -76,7 +87,7 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
To: q.Get("to"),
|
||||
}
|
||||
|
||||
rows, err := a.Store.ListSummaries(r.Context(), a.UserID, 0)
|
||||
rows, err := a.Store.ListSummaries(r.Context(), userID, 0)
|
||||
if err != nil {
|
||||
a.serverError(w, r, "list summaries", err)
|
||||
return
|
||||
@@ -92,8 +103,12 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleDetail renders one summary in full (highlights, takeaways, action group).
|
||||
func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := a.currentUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
videoID := r.PathValue("videoId")
|
||||
row, err := a.Store.GetSummaryByVideo(r.Context(), a.UserID, videoID)
|
||||
row, err := a.Store.GetSummaryByVideo(r.Context(), userID, videoID)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -110,6 +125,10 @@ func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) {
|
||||
// the refreshed button-group fragment for HTMX; without JS it redirects back to
|
||||
// the detail page (POST→redirect→GET).
|
||||
func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := a.currentUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
videoID := r.PathValue("videoId")
|
||||
action := r.FormValue("action")
|
||||
if !isActionVerb(action) {
|
||||
@@ -117,23 +136,23 @@ func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
current, err := a.Store.ActionsFor(r.Context(), a.UserID, []string{videoID})
|
||||
current, err := a.Store.ActionsFor(r.Context(), userID, []string{videoID})
|
||||
if err != nil {
|
||||
a.serverError(w, r, "read actions", err)
|
||||
return
|
||||
}
|
||||
|
||||
if actionSet(current[videoID])[action] {
|
||||
err = a.Store.ClearAction(r.Context(), a.UserID, videoID, action)
|
||||
err = a.Store.ClearAction(r.Context(), userID, videoID, action)
|
||||
} else {
|
||||
err = a.Store.SetAction(r.Context(), a.UserID, videoID, action)
|
||||
err = a.Store.SetAction(r.Context(), userID, videoID, action)
|
||||
}
|
||||
if err != nil {
|
||||
a.serverError(w, r, "toggle action", err)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := a.Store.ActionsFor(r.Context(), a.UserID, []string{videoID})
|
||||
updated, err := a.Store.ActionsFor(r.Context(), userID, []string{videoID})
|
||||
if err != nil {
|
||||
a.serverError(w, r, "read actions", err)
|
||||
return
|
||||
@@ -146,10 +165,29 @@ func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// currentUserID returns the tapir user_id the registration gate resolved for this
|
||||
// request. Behind the gate it is always present; a miss means a handler was
|
||||
// reached without scoping (a wiring bug), so it answers 500 and reports false.
|
||||
func (a *App) currentUserID(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
id, ok := CurrentUserID(r)
|
||||
if !ok {
|
||||
a.serverError(w, r, "current user", errNoCurrentUser)
|
||||
}
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// render writes a templ component as HTML. A render error is logged, not retried:
|
||||
// headers may already be flushed, so there is nothing useful to send the client.
|
||||
func (a *App) render(w http.ResponseWriter, r *http.Request, c templ.Component) {
|
||||
a.renderStatus(w, r, http.StatusOK, c)
|
||||
}
|
||||
|
||||
// renderStatus writes a templ component as HTML with an explicit status code (the
|
||||
// Content-Type must be set before WriteHeader, so this is the single place that
|
||||
// orders them correctly).
|
||||
func (a *App) renderStatus(w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err := c.Render(r.Context(), w); err != nil {
|
||||
a.logger().Error("render", "path", r.URL.Path, "err", err)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ const (
|
||||
userID = "11111111-1111-1111-1111-111111111111"
|
||||
videoX = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
videoY = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
// stubSubject is the StubAuth Dex subject the registration gate resolves to
|
||||
// the fixed userID (mapping seeded by resetDB).
|
||||
stubSubject = "stub-subject-xyz"
|
||||
)
|
||||
|
||||
func newStore(t *testing.T) *store.Store {
|
||||
@@ -63,21 +66,48 @@ func rawPool(t *testing.T) *pgxpool.Pool {
|
||||
return p
|
||||
}
|
||||
|
||||
func resetDB(t *testing.T, p *pgxpool.Pool) {
|
||||
// truncateAll wipes every table to a pristine state (user_identities is cleared
|
||||
// via the ON DELETE CASCADE from users). Registration tests use this directly so
|
||||
// no subject is pre-registered.
|
||||
func truncateAll(t *testing.T, p *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
_, err := p.Exec(context.Background(),
|
||||
`TRUNCATE summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// newApp builds the App under test: the real store, StubAuth (allow-all) keyed to
|
||||
// the configured user. This is exactly cmd/tapir's serve wiring minus Dex.
|
||||
// resetDB truncates, then seeds the StubAuth identity (stubSubject → userID) so
|
||||
// the registration gate resolves the stub user and the existing handler tests can
|
||||
// keep seeding and scoping by the fixed userID.
|
||||
func resetDB(t *testing.T, p *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
truncateAll(t, p)
|
||||
ctx := context.Background()
|
||||
_, err := p.Exec(ctx, `INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, userID)
|
||||
require.NoError(t, err)
|
||||
_, err = p.Exec(ctx,
|
||||
`INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)
|
||||
ON CONFLICT (dex_subject) DO NOTHING`, stubSubject, userID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// newApp builds the App under test as the registered stub user (subject
|
||||
// stubSubject, resolved to userID by resetDB). This is cmd/tapir's serve wiring
|
||||
// minus Dex: the store is both the Store and the Identity port.
|
||||
func newApp(t *testing.T) *web.App {
|
||||
t.Helper()
|
||||
return newAppAs(t, stubSubject)
|
||||
}
|
||||
|
||||
// newAppAs builds the App under test with a specific StubAuth Dex subject, so
|
||||
// registration-gate tests can drive registered vs unregistered subjects.
|
||||
func newAppAs(t *testing.T, subject string) *web.App {
|
||||
t.Helper()
|
||||
s := newStore(t)
|
||||
return &web.App{
|
||||
Store: newStore(t),
|
||||
Auth: web.StubAuth{U: web.User{Subject: userID}},
|
||||
UserID: userID,
|
||||
Store: s,
|
||||
Identity: s,
|
||||
Auth: web.StubAuth{U: web.User{Subject: subject}},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUnregisteredSubjectRedirectedToRegister(t *testing.T) {
|
||||
app := newAppAs(t, "unregistered-sub")
|
||||
truncateAll(t, rawPool(t))
|
||||
|
||||
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
require.Equal(t, http.StatusFound, rec.Code)
|
||||
require.Equal(t, "/register", rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
func TestRegisterPageReachableWhenUnregistered(t *testing.T) {
|
||||
app := newAppAs(t, "unregistered-sub")
|
||||
truncateAll(t, rawPool(t))
|
||||
|
||||
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/register", nil))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "/register is exempt from the gate")
|
||||
require.Contains(t, body(t, rec), "Complete your registration")
|
||||
}
|
||||
|
||||
func TestRegisteredSubjectPassesThrough(t *testing.T) {
|
||||
app := newApp(t) // stubSubject
|
||||
resetDB(t, rawPool(t))
|
||||
|
||||
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, body(t, rec), "<html", "registered subject gets the app, not a redirect")
|
||||
}
|
||||
|
||||
func TestRegisterCreatesExactlyOneUserAndIdentity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const sub = "brand-new-subject"
|
||||
app := newAppAs(t, sub)
|
||||
p := rawPool(t)
|
||||
truncateAll(t, p)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/register",
|
||||
strings.NewReader("display_name=Newbie&accept_terms=yes"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := do(t, app, req)
|
||||
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
require.Equal(t, "/", rec.Header().Get("Location"))
|
||||
|
||||
// Exactly one identity row for the subject, and its user exists.
|
||||
var idents int
|
||||
var newID string
|
||||
require.NoError(t, p.QueryRow(ctx,
|
||||
`SELECT count(*), coalesce(max(user_id::text), '') FROM user_identities WHERE dex_subject = $1`,
|
||||
sub).Scan(&idents, &newID))
|
||||
require.Equal(t, 1, idents)
|
||||
|
||||
var users int
|
||||
require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM users WHERE id = $1`, newID).Scan(&users))
|
||||
require.Equal(t, 1, users)
|
||||
|
||||
// Returning subject resolves straight through — no second user created.
|
||||
rec = do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var totalUsers, totalIdents int
|
||||
require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&totalUsers))
|
||||
require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM user_identities`).Scan(&totalIdents))
|
||||
require.Equal(t, 1, totalUsers, "a second request must not register again")
|
||||
require.Equal(t, 1, totalIdents)
|
||||
}
|
||||
|
||||
func TestRegisterRejectsMissingFields(t *testing.T) {
|
||||
app := newAppAs(t, "incomplete-subject")
|
||||
truncateAll(t, rawPool(t))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/register",
|
||||
strings.NewReader("display_name=&accept_terms=")) // both missing
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := do(t, app, req)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Identity is the narrow port the web layer uses to resolve a Dex subject to a
|
||||
// tapir user and to register new ones (ADR-012). *store.Store satisfies it; tests
|
||||
// can substitute a fake. It is deliberately separate from Store: identity
|
||||
// resolution runs pre-scope (un-RLS'd map), whereas Store runs user-scoped.
|
||||
type Identity interface {
|
||||
UserBySubject(ctx context.Context, subject string) (userID string, found bool, err error)
|
||||
RegisterUser(ctx context.Context, subject, displayName string) (userID string, err error)
|
||||
}
|
||||
|
||||
// errNoCurrentUser indicates a scoped handler ran without a resolved user_id —
|
||||
// only possible if it was reached outside the registration gate (a wiring bug).
|
||||
var errNoCurrentUser = errors.New("web: no current user in request context")
|
||||
|
||||
// userIDCtxKey types the per-request resolved tapir user_id stored by the
|
||||
// registration gate. Unexported so only this package can set it.
|
||||
type userIDCtxKey struct{}
|
||||
|
||||
func withUserID(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, userIDCtxKey{}, id)
|
||||
}
|
||||
|
||||
// CurrentUserID returns the tapir user_id (UUID) the registration gate resolved
|
||||
// for the request from the authenticated Dex subject. ok is false for requests
|
||||
// that never passed the gate (e.g. /register, /auth/*). This is the seam handlers
|
||||
// — and downstream features (per-user YouTube connect, account management) —
|
||||
// scope every store access by.
|
||||
func CurrentUserID(r *http.Request) (string, bool) {
|
||||
id, ok := r.Context().Value(userIDCtxKey{}).(string)
|
||||
return id, ok && id != ""
|
||||
}
|
||||
|
||||
// registrationGate sits inside Auth.Middleware. For a gated request it resolves
|
||||
// the authenticated subject → tapir user_id once and stashes it for handlers; a
|
||||
// subject with no tapir user is redirected to /register. Exempt paths pass
|
||||
// straight through (/register so an unregistered user can reach the form; /auth/*
|
||||
// and /healthz are already public but listed for safety).
|
||||
func (a *App) registrationGate(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isRegistrationExempt(r.URL.Path) {
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
user, ok := a.Auth.CurrentUser(r)
|
||||
if !ok {
|
||||
// Auth.Middleware should have caught this; redirect defensively.
|
||||
http.Redirect(w, r, loginPath, http.StatusFound)
|
||||
return
|
||||
}
|
||||
userID, found, err := a.Identity.UserBySubject(r.Context(), user.Subject)
|
||||
if err != nil {
|
||||
a.serverError(w, r, "resolve identity", err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.Redirect(w, r, registerPath, http.StatusFound)
|
||||
return
|
||||
}
|
||||
h.ServeHTTP(w, r.WithContext(withUserID(r.Context(), userID)))
|
||||
})
|
||||
}
|
||||
|
||||
// handleRegisterForm renders the registration form for an authenticated, not-yet-
|
||||
// registered subject. An already-registered subject is sent to the app root.
|
||||
func (a *App) handleRegisterForm(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.Auth.CurrentUser(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, loginPath, http.StatusFound)
|
||||
return
|
||||
}
|
||||
if _, found, err := a.Identity.UserBySubject(r.Context(), user.Subject); err != nil {
|
||||
a.serverError(w, r, "resolve identity", err)
|
||||
return
|
||||
} else if found {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.render(w, r, RegisterPage(user.Email, ""))
|
||||
}
|
||||
|
||||
// handleRegister creates the tapir user for the authenticated subject from the
|
||||
// submitted display name (terms must be accepted), then redirects to the app
|
||||
// root. A double-submit by an already-registered subject is idempotent.
|
||||
func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.Auth.CurrentUser(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, loginPath, http.StatusFound)
|
||||
return
|
||||
}
|
||||
if _, found, err := a.Identity.UserBySubject(r.Context(), user.Subject); err != nil {
|
||||
a.serverError(w, r, "resolve identity", err)
|
||||
return
|
||||
} else if found {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
displayName := strings.TrimSpace(r.FormValue("display_name"))
|
||||
accepted := r.FormValue("accept_terms") != ""
|
||||
if displayName == "" || !accepted {
|
||||
a.renderStatus(w, r, http.StatusBadRequest,
|
||||
RegisterPage(user.Email, "Enter a display name and accept the terms to continue."))
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := a.Identity.RegisterUser(r.Context(), user.Subject, displayName); err != nil {
|
||||
a.serverError(w, r, "register user", err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
const (
|
||||
registerPath = "/register"
|
||||
loginPath = "/auth/login"
|
||||
)
|
||||
|
||||
func isRegistrationExempt(p string) bool {
|
||||
return p == registerPath || p == "/healthz" || strings.HasPrefix(p, "/auth/")
|
||||
}
|
||||
@@ -153,6 +153,35 @@ templ DetailPage(r store.SummaryRow) {
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterPage is the explicit registration step (ADR-012): an authenticated Dex
|
||||
// subject with no tapir user picks a display name and accepts the terms to create
|
||||
// their account. errMsg, when set, reports a validation problem on the prior POST.
|
||||
templ RegisterPage(email, errMsg string) {
|
||||
@Layout("Tapir — Register") {
|
||||
<article class="register">
|
||||
<h1>Complete your registration</h1>
|
||||
if email != "" {
|
||||
<p class="meta">Signed in as { email }.</p>
|
||||
}
|
||||
<p>Choose a display name to finish setting up your Tapir account.</p>
|
||||
if errMsg != "" {
|
||||
<p class="error" role="alert">{ errMsg }</p>
|
||||
}
|
||||
<form method="post" action="/register" class="register-form">
|
||||
<label>
|
||||
Display name
|
||||
<input type="text" name="display_name" required autofocus/>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="accept_terms" value="yes" required/>
|
||||
I accept the terms of use
|
||||
</label>
|
||||
<button type="submit" class="btn">Register</button>
|
||||
</form>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
// ActionButtons is the toggle group fragment returned by POST /v/{id}/action.
|
||||
// Each button submits its verb; HTMX swaps this element in place (outerHTML),
|
||||
// and without JS the form POSTs and the handler redirects back to the detail
|
||||
|
||||
+142
-46
@@ -595,11 +595,10 @@ func DetailPage(r store.SummaryRow) templ.Component {
|
||||
})
|
||||
}
|
||||
|
||||
// ActionButtons is the toggle group fragment returned by POST /v/{id}/action.
|
||||
// Each button submits its verb; HTMX swaps this element in place (outerHTML),
|
||||
// and without JS the form POSTs and the handler redirects back to the detail
|
||||
// page. active marks the verbs currently set for (user, video).
|
||||
func ActionButtons(videoID string, active map[string]bool) templ.Component {
|
||||
// RegisterPage is the explicit registration step (ADR-012): an authenticated Dex
|
||||
// subject with no tapir user picks a display name and accepts the terms to create
|
||||
// their account. errMsg, when set, reports a validation problem on the prior POST.
|
||||
func RegisterPage(email, 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 {
|
||||
@@ -620,112 +619,209 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component {
|
||||
templ_7745c5c3_Var26 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<form id=\"action-buttons\" class=\"actions\" method=\"post\" action=\"")
|
||||
templ_7745c5c3_Var27 := 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, 50, "<article class=\"register\"><h1>Complete your registration</h1>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if email != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<p class=\"meta\">Signed in as ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(email)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 164, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ".</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<p>Choose a display name to finish setting up your Tapir account.</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if errMsg != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<p class=\"error\" role=\"alert\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 168, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<form method=\"post\" action=\"/register\" class=\"register-form\"><label>Display name <input type=\"text\" name=\"display_name\" required autofocus></label> <label class=\"checkbox\"><input type=\"checkbox\" name=\"accept_terms\" value=\"yes\" required> I accept the terms of use</label> <button type=\"submit\" class=\"btn\">Register</button></form></article>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = Layout("Tapir — Register").Render(templ.WithChildren(ctx, templ_7745c5c3_Var27), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var27 templ.SafeURL
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(actionURL(videoID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 165, Col: 29}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ActionButtons is the toggle group fragment returned by POST /v/{id}/action.
|
||||
// Each button submits its verb; HTMX swaps this element in place (outerHTML),
|
||||
// and without JS the form POSTs and the handler redirects back to the detail
|
||||
// page. active marks the verbs currently set for (user, video).
|
||||
func ActionButtons(videoID string, active map[string]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_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||
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_Var30 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var30 == nil {
|
||||
templ_7745c5c3_Var30 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<form id=\"action-buttons\" class=\"actions\" method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" hx-post=\"")
|
||||
var templ_7745c5c3_Var31 templ.SafeURL
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(actionURL(videoID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 194, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(actionURL(videoID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 166, Col: 38}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\">")
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(actionURL(videoID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 195, Col: 38}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, v := range actionVerbs {
|
||||
var templ_7745c5c3_Var29 = []any{"action", templ.KV("active", active[v])}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var29...)
|
||||
var templ_7745c5c3_Var33 = []any{"action", templ.KV("active", active[v])}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var33...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<button type=\"submit\" name=\"action\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<button type=\"submit\" name=\"action\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(v)
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(v)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 174, Col: 13}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 203, Col: 13}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var29).String())
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var33).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" aria-pressed=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" aria-pressed=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(ariaPressed(active[v]))
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(ariaPressed(active[v]))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 176, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 205, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if active[v] {
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs("✓ " + actionLabel(v))
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs("✓ " + actionLabel(v))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 179, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 208, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(actionLabel(v))
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(actionLabel(v))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 181, Col: 21}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 210, Col: 21}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user