diff --git a/cmd/myday/main.go b/cmd/myday/main.go index 3eb3e0c..2c20ae4 100644 --- a/cmd/myday/main.go +++ b/cmd/myday/main.go @@ -16,6 +16,7 @@ import ( "path/filepath" "strings" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/account" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/flow" @@ -314,7 +315,24 @@ func run() error { return err } - reg := buildRegistry(authFlow, setupFlow, recoveryFlow, modules, console) + // SPEC-010: the account page (factors · add a passkey · "don't ask again") + // and the admin Users page (list · create + email the enrolment invite). + // The account page reuses the SPEC-006 §8 passkey ceremony by linking to + // it; the invite goes through the same mailer recovery uses. + accounts, err := account.New(account.Deps{ + Users: us, + Identities: ids, + Credentials: ids, + Mailer: mailer, + BaseURL: cfg.Server.BaseURL, + Audit: aud, + Logger: slog.Default(), + }) + if err != nil { + return err + } + + reg := buildRegistry(authFlow, setupFlow, recoveryFlow, modules, console, accounts) srv, err := server.New(server.Deps{ Config: cfg, @@ -322,11 +340,14 @@ func run() error { Registry: reg, Sessions: sessions, Identities: ids, - Users: us, - Audit: aud, - CSRF: csrf, - PreSession: preSession, - SetupMode: setupMode, + // SPEC-010 §6: the passkey banner needs "does this identity have a + // passkey?" — the identity service also implements Credentials. + Credentials: ids, + Users: us, + Audit: aud, + CSRF: csrf, + PreSession: preSession, + SetupMode: setupMode, // D-045: the non-claimable locked state — `setup: false` and no admin. LockedMode: locked, // D-044/D-045: the first-run gate — the OPTIONAL setup code + the @@ -337,9 +358,13 @@ func run() error { // authenticated user; role-ABOVE rules live in the services. // SPEC-008: the Modules page is admin-only — toggling modules changes // what the whole instance can do. + // SPEC-010: the Users page and its create action are admin-only + // (D-019 creation authority is still enforced by users.Create). RoleGate: server.NewTiers(). Require(module.ConsoleModule, module.FunctionModules, users.RoleAdmin). - Require(module.ConsoleModule, module.FunctionToggle, users.RoleAdmin), + Require(module.ConsoleModule, module.FunctionToggle, users.RoleAdmin). + Require(account.UsersModule, account.FunctionList, users.RoleAdmin). + Require(account.UsersModule, account.FunctionCreate, users.RoleAdmin), }) if err != nil { return err @@ -367,7 +392,7 @@ func run() error { // buildRegistry assembles the door's dispatch registry (SPEC-008 owns the // discovered set; this is the surface the door dispatches against). -func buildRegistry(authFlow *flow.Actions, setupFlow *setup.Actions, recoveryFlow *recovery.Actions, mods *module.Registry, console *module.Page) *server.MapRegistry { +func buildRegistry(authFlow *flow.Actions, setupFlow *setup.Actions, recoveryFlow *recovery.Actions, mods *module.Registry, console *module.Page, accounts *account.Actions) *server.MapRegistry { reg := server.NewMapRegistry() // Core level-1 workflows (reserved: home · access · setup · static). @@ -408,6 +433,13 @@ func buildRegistry(authFlow *flow.Actions, setupFlow *setup.Actions, recoveryFlo console.Register(reg) } + // SPEC-010: the account page (GET /{GUID}/account · POST + // /{GUID}?do=account&action=account.passkey_opt_out) and the admin Users + // page (GET /{GUID}/users · POST /{GUID}?do=users&action=users.create). + if accounts != nil { + accounts.Register(reg) + } + return reg } diff --git a/internal/core/account/account.go b/internal/core/account/account.go new file mode 100644 index 0000000..e68ac37 --- /dev/null +++ b/internal/core/account/account.go @@ -0,0 +1,377 @@ +// Package account implements SPEC-010: the two human-facing pages that make the +// signed-in experience usable. +// +// - the ACCOUNT page (GET /{GUID}/account) — the user's factors, an "Add a +// passkey" button that reuses the SPEC-006 §8 / D-041 WebAuthn ceremony (it +// links to the existing nudge screen; the ceremony is never duplicated), +// and a "Don't ask again" control writing users.passkey_opt_out. +// - the admin USERS page (GET /{GUID}/users) — list every user, and create one +// (users.Create → identities.Mint → email the /{GUID} enrolment invite, the +// link shown in the UI too). +// +// Both ride the door under dedicated CORE dispatch modules (`account` · `users`) +// so the door still gates them (session + role + CSRF) exactly like any other +// function; the role requirements are installed with the entrypoint's +// server.Tiers (the Users page and its create action are admin-only). +// +// Spec: docs/specs/spec-010-admin-account-ui.md. +package account + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/mail" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web" +) + +// The account page's core dispatch module + its functions (SPEC-010 §4). +const ( + // Module is the `do`/PARAM2 module carrying the account page. + Module = "account" + // FunctionPage is the account page — the module's DEFAULT route, so it + // answers GET /{GUID}/account (PARAM3 absent). + FunctionPage = "account" + // FunctionOptOut is the "Don't ask again" action + // (POST /{GUID}?do=account&action=account.passkey_opt_out). + FunctionOptOut = "account.passkey_opt_out" +) + +// The admin Users page's core dispatch module + its functions (SPEC-010 §5). +const ( + // UsersModule is the `do`/PARAM2 module carrying the admin Users page. + UsersModule = "users" + // FunctionList is the Users page — the module's DEFAULT route, so it + // answers GET /{GUID}/users (PARAM3 absent). + FunctionList = "users" + // FunctionCreate is the create-user action + // (POST /{GUID}?do=users&action=users.create). + FunctionCreate = "users.create" +) + +// Users is the narrow slice of the user service the pages need. +type Users interface { + Get(ctx context.Context, id int64) (users.User, error) + List(ctx context.Context) ([]users.User, error) + Create(ctx context.Context, creatorID int64, email, displayName string, role users.Role) (users.UserID, error) + SetPasskeyOptOut(ctx context.Context, id int64, optOut bool) error +} + +// Credentials is the WebAuthn credential store, read-only here: the account +// page COUNTS the user's passkeys. It never runs a ceremony — the "Add a +// passkey" button links to the existing SPEC-006 §8 flow. +type Credentials interface { + WebAuthnCredentials(ctx context.Context, identityID int64) ([]webauthn.Credential, error) +} + +// Deps assembles the pages. +type Deps struct { + // Users lists, creates and reads the passkey opt-out. + Users Users + // Identities resolves a user's identity, reads the TOTP factor, and mints + // the enrolment invite (auth.Identities). + Identities auth.Identities + // Credentials counts the user's passkeys (auth.Credentials). + Credentials Credentials + // Mailer delivers the enrolment invite. nil disables delivery (the link is + // still shown in the UI, so the invite is never lost when mail is off). + Mailer mail.Mailer + // BaseURL prefixes the emailed invite link (the configured public origin). + BaseURL string + // Audit receives the page's events (optional). + Audit audit.Audit + // Logger receives structured logs. Defaults to slog.Default(). + Logger *slog.Logger +} + +// Actions is the account + Users pages. +type Actions struct { + d Deps +} + +// New validates and builds the pages. +func New(d Deps) (*Actions, error) { + switch { + case d.Users == nil: + return nil, errors.New("account: Users is required") + case d.Identities == nil: + return nil, errors.New("account: Identities is required") + case d.Credentials == nil: + return nil, errors.New("account: Credentials is required") + } + if d.Logger == nil { + d.Logger = slog.Default() + } + return &Actions{d: d}, nil +} + +// Register mounts both pages into the door registry. The account page is the +// `account` module's DEFAULT route (GET /{GUID}/account) with its opt-out +// action; the Users page is the `users` module's DEFAULT route +// (GET /{GUID}/users) with its create action. The caller must install the role +// requirements (server.Tiers): the `users` functions are admin-only. +func (a *Actions) Register(reg *server.MapRegistry) { + if reg == nil { + return + } + reg.RegisterModule(server.NewModule(Module). + SetDefault(FunctionPage, a.Page). + AddAction(FunctionOptOut, FunctionOptOut, a.OptOut)) + reg.RegisterModule(server.NewModule(UsersModule). + SetDefault(FunctionList, a.List). + AddAction(FunctionCreate, FunctionCreate, a.Create)) +} + +// ── the account page (SPEC-010 §4) ────────────────────────────────────────── + +// Page renders GET /{GUID}/account: the user's factors, the add-a-passkey +// button and the "Don't ask again" control. +func (a *Actions) Page(w http.ResponseWriter, r *http.Request, rc *server.RequestContext) error { + ctx := r.Context() + u, err := a.d.Users.Get(ctx, rc.UserID) + if err != nil { + return err + } + idn, err := a.d.Identities.ByUserID(ctx, rc.UserID) + if err != nil { + return err + } + creds, err := a.d.Credentials.WebAuthnCredentials(ctx, idn.ID) + if err != nil { + return err + } + totp, err := a.d.Identities.TOTPEnrolled(ctx, idn.ID) + if err != nil { + return err + } + return a.renderAccount(w, r, rc, accountData{ + GUID: rc.Workflow, + Token: httpx.TokenFromRequest(r), + Passkeys: len(creds), + TOTP: totp, + OptOut: u.PasskeyOptOut, + }) +} + +// OptOut handles POST /{GUID}?do=account&action=account.passkey_opt_out: it sets +// users.passkey_opt_out, which stops the shell's passkey banner (SPEC-010 §6) +// and the SPEC-006 §8 login nudge. A user preference on the stable user record, +// so it survives identity rotation (D-041/D-008). +func (a *Actions) OptOut(w http.ResponseWriter, r *http.Request, rc *server.RequestContext) error { + if err := a.d.Users.SetPasskeyOptOut(r.Context(), rc.UserID, true); err != nil { + return err + } + http.Redirect(w, r, "/"+rc.Workflow+"/account", http.StatusSeeOther) + return nil +} + +// ── the admin Users page (SPEC-010 §5) ────────────────────────────────────── + +// List renders GET /{GUID}/users: the user table and the create form. +func (a *Actions) List(w http.ResponseWriter, r *http.Request, rc *server.RequestContext) error { + return a.renderList(w, r, rc, http.StatusOK, "", "", "") +} + +// Create handles POST /{GUID}?do=users&action=users.create: it creates the user +// (users.Create enforces the D-019 role-above rule), mints the enrolment invite +// (identities.Mint) and emails the /{GUID} link — the same link is shown in the +// UI so mail being unconfigured never strands the invite. +func (a *Actions) Create(w http.ResponseWriter, r *http.Request, rc *server.RequestContext) error { + ctx := r.Context() + creator, err := a.d.Users.Get(ctx, rc.UserID) + if err != nil { + return err + } + + name := strings.TrimSpace(r.FormValue("display_name")) + email := strings.TrimSpace(r.FormValue("email")) + role, roleErr := users.ParseRole(strings.TrimSpace(r.FormValue("role"))) + if name == "" || email == "" || roleErr != nil { + return a.renderList(w, r, rc, http.StatusBadRequest, + "Please provide a name, an email and a role.", "error", "") + } + // D-019: only roles strictly BELOW the creator. users.Create enforces this + // too; checking here lets the page explain rather than 500. + if !creator.Role.Above(role) { + return a.renderList(w, r, rc, http.StatusForbidden, + "You may only create roles below your own.", "error", "") + } + + userID, err := a.d.Users.Create(ctx, rc.UserID, email, name, role) + if err != nil { + status, msg := createError(err) + if status == 0 { + return err + } + return a.renderList(w, r, rc, status, msg, "error", "") + } + + _, guid, err := a.d.Identities.Mint(ctx, userID) + if err != nil { + return err + } + link := a.inviteLink(guid) + a.sendInvite(ctx, name, email, link) + return a.renderList(w, r, rc, http.StatusOK, + fmt.Sprintf("Created %s and emailed the invitation.", name), "notice", link) +} + +// createError maps a users.Create sentinel to an HTTP status + a human message. +// status == 0 means "not a client error" — propagate it as a 500. +func createError(err error) (status int, msg string) { + switch { + case errors.Is(err, users.ErrDuplicateEmail), errors.Is(err, users.ErrOneAdmin): + return http.StatusConflict, "A user with that email already exists." + case errors.Is(err, users.ErrEmailRequired), errors.Is(err, users.ErrNameRequired), + errors.Is(err, users.ErrInvalidRole): + return http.StatusBadRequest, "Please provide a name, an email and a role." + case errors.Is(err, users.ErrRoleNotAllowed): + return http.StatusForbidden, "You may only create roles below your own." + default: + return 0, "" + } +} + +// creatableRoles returns the roles strictly below creator, highest first — the +// options the create form may offer (D-019). +func creatableRoles(creator users.Role) []users.Role { + all := []users.Role{users.RoleDirector, users.RoleManager, users.RoleTeam} + out := make([]users.Role, 0, len(all)) + for _, role := range all { + if creator.Above(role) { + out = append(out, role) + } + } + return out +} + +// inviteLink renders the user's invitation URL: the configured public origin +// plus the GUID (SPEC-006: the invite carries /{GUID}). With no configured +// origin it degrades to the path, which the admin can prefix by hand. +func (a *Actions) inviteLink(guid string) string { + base := strings.TrimRight(strings.TrimSpace(a.d.BaseURL), "/") + return base + "/" + guid +} + +// sendInvite emails the enrolment invitation. Delivery is best-effort: a +// failure is logged, never surfaced — the link is shown in the UI regardless. +func (a *Actions) sendInvite(ctx context.Context, name, email, link string) { + if a.d.Mailer == nil { + return + } + body := fmt.Sprintf(`Hello %s, + +You have been invited to %s, your self-hosted workspace. + +Set up your passkey and authenticator app here: + + %s + +This link is personal — please do not share it. It expires if it is not used +within the enrolment window. +`, name, web.AppName, link) + if err := a.d.Mailer.Send(ctx, mail.Message{ + To: email, + Subject: "MyDay: your invitation", + Body: body, + }); err != nil { + a.d.Logger.Warn("account: invite mail failed", "to", email, "error", err) + } +} + +// ── rendering ─────────────────────────────────────────────────────────────── + +func (a *Actions) renderAccount(w http.ResponseWriter, r *http.Request, rc *server.RequestContext, data accountData) error { + var body strings.Builder + if err := accountTemplate.Execute(&body, data); err != nil { + return err + } + return webRenderStatus(w, r, rc, http.StatusOK, "Account", "account", data.Token, body.String()) +} + +func (a *Actions) renderList(w http.ResponseWriter, r *http.Request, rc *server.RequestContext, status int, notice, kind, invite string) error { + all, err := a.d.Users.List(r.Context()) + if err != nil { + return err + } + data := usersData{ + GUID: rc.Workflow, + Token: httpx.TokenFromRequest(r), + Rows: a.rows(r.Context(), all), + Roles: creatableRoles(a.creatorRole(r, rc)), + Notice: notice, + NoticeKind: kind, + InviteLink: invite, + } + var body strings.Builder + if err := usersTemplate.Execute(&body, data); err != nil { + return err + } + return webRenderStatus(w, r, rc, status, "Users", "users", data.Token, body.String()) +} + +// rows builds the display rows: name · email · role · manager · identity status. +// A user with no live identity (never invited, or recovered and not yet re- +// enrolled) reports "none"; a missing manager (the top admin) reports "—". +func (a *Actions) rows(ctx context.Context, all []users.User) []userRow { + names := make(map[int64]string, len(all)) + for _, u := range all { + names[u.ID] = u.DisplayName + } + out := make([]userRow, 0, len(all)) + for _, u := range all { + manager := "—" + if u.Manager.Set { + if n, ok := names[u.Manager.Value]; ok { + manager = n + } else { + manager = fmt.Sprintf("user:%d", u.Manager.Value) + } + } + out = append(out, userRow{ + Name: u.DisplayName, + Email: u.Email, + Role: string(u.Role), + Manager: manager, + Status: a.identityStatus(ctx, u.ID), + }) + } + return out +} + +// identityStatus renders a user's current identity status; a user with no live +// identity reports "none". A lookup fault degrades to "unknown" rather than +// failing the whole page. +func (a *Actions) identityStatus(ctx context.Context, userID int64) string { + idn, err := a.d.Identities.ByUserID(ctx, userID) + if err != nil { + if errors.Is(err, auth.ErrIdentityNotFound) { + return "none" + } + a.d.Logger.Warn("account: identity status lookup failed", "user_id", userID, "error", err) + return "unknown" + } + return string(idn.Status) +} + +// creatorRole returns the signed-in admin's role (the create form offers only +// roles below it). A lookup fault yields an invalid role, so the form offers +// nothing rather than over-offering. +func (a *Actions) creatorRole(r *http.Request, rc *server.RequestContext) users.Role { + u, err := a.d.Users.Get(r.Context(), rc.UserID) + if err != nil { + a.d.Logger.Warn("account: creator lookup failed", "user_id", rc.UserID, "error", err) + return "" + } + return u.Role +} diff --git a/internal/core/account/render.go b/internal/core/account/render.go new file mode 100644 index 0000000..1ed6034 --- /dev/null +++ b/internal/core/account/render.go @@ -0,0 +1,110 @@ +package account + +import ( + "html/template" + "net/http" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web" +) + +// accountData is the account template's input (SPEC-010 §4). +type accountData struct { + GUID string + Token string + Passkeys int + TOTP bool + OptOut bool +} + +// usersData is the Users page template's input (SPEC-010 §5). +type usersData struct { + GUID string + Token string + Rows []userRow + Roles []users.Role + Notice string + NoticeKind string + InviteLink string +} + +// userRow is one user's line on the Users page. +type userRow struct { + Name string + Email string + Role string + Manager string + Status string +} + +// webRenderStatus draws a page inside the core shell, applying the request's +// shell state (the role-gated nav + the passkey banner) with web.PageFor. Every +// page is `no-store`: the account and Users pages carry a session and a CSRF +// token. +func webRenderStatus(w http.ResponseWriter, r *http.Request, rc *server.RequestContext, status int, title, active, token, body string) error { + w.Header().Set("Cache-Control", "no-store") + return web.Render(w, status, web.PageFor(r, web.Page{ + Title: title, + GUID: rc.Workflow, + Active: active, + CSRF: token, + Content: web.Markup(body), + })) +} + +// accountTemplateHTML is the account page BODY only: the shell (SPEC-009 §4) +// owns the document, the header and the nav; this is the content slot. +// +// "Add a passkey" is a LINK to the existing SPEC-006 §8 / D-041 passkey screen +// (/{GUID}/auth/passkey) — the WebAuthn ceremony is never duplicated here. +const accountTemplateHTML = `

Account

+
+

Your factors

+

Passkeys registered: {{.Passkeys}}

+

Authenticator app (TOTP): {{if .TOTP}}enrolled{{else}}not enrolled{{end}}

+
+
+

Passkey

+

Add a passkey to sign in without the one-time code. It is bound to this device and this site.

+

Add a passkey

+
+
+

Don't ask again

+{{if .OptOut}}

You have opted out of passkey reminders.

+{{else}}

Stop the "add a passkey" reminders.

+
+ + +
{{end}} +
+` + +// usersTemplateHTML is the Users page BODY only (SPEC-010 §5). +const usersTemplateHTML = `

Users

+{{if .Notice}}

{{.Notice}}

{{end}} +{{if .InviteLink}}

Invitation link (share it if email is not configured):
{{.InviteLink}}

{{end}} + + + +{{range .Rows}} +{{end}} +
NameEmailRoleManagerIdentity
{{.Name}}{{.Email}}{{.Role}}{{.Manager}}{{.Status}}
+
+

Add a user

+
+ + + + + +
+

The new user's manager defaults to you. Only roles below your own can be created.

+
+` + +// Templates are parsed once; a parse failure of a constant is impossible. +var ( + accountTemplate = template.Must(template.New("account").Parse(accountTemplateHTML)) + usersTemplate = template.Must(template.New("users").Parse(usersTemplateHTML)) +) diff --git a/internal/core/auth/auth.go b/internal/core/auth/auth.go index 313bdf5..5568375 100644 --- a/internal/core/auth/auth.go +++ b/internal/core/auth/auth.go @@ -99,6 +99,11 @@ type Identities interface { // its bounded invite window (SPEC-004 §4, A1). SPEC-006 refuses enrollment // outside it. A non-pending identity reports false. WithinEnrollmentWindow(ctx context.Context, id int64) (bool, error) + + // TOTPEnrolled reports whether an identity has a VERIFIED TOTP secret — its + // second factor is enrolled (SPEC-010 §4, the account page's factors). A + // missing secret and an issued-but-unverified one both read as false. + TOTPEnrolled(ctx context.Context, identityID int64) (bool, error) } // Sessions is the session service contract (SPEC-004 §5/§6). Per ruling R-1 diff --git a/internal/core/auth/totp.go b/internal/core/auth/totp.go index 9cb76ad..c750da0 100644 --- a/internal/core/auth/totp.go +++ b/internal/core/auth/totp.go @@ -97,3 +97,22 @@ func (s *IdentitiesService) MarkTOTPVerified(ctx context.Context, identityID int }) return nil } + +// TOTPEnrolled reports whether an identity has a VERIFIED TOTP secret — the +// second factor is enrolled (SPEC-010 §4). A missing secret and an issued-but- +// unverified one both read as not-enrolled. It reads a boolean, never the +// secret (A9). +func (s *IdentitiesService) TOTPEnrolled(ctx context.Context, identityID int64) (bool, error) { + var one int + err := s.db.QueryRow( + `SELECT 1 FROM totp_secrets WHERE identity_id = ? AND verified_at IS NOT NULL LIMIT 1`, + identityID, + ).Scan(&one) + switch { + case errors.Is(err, sql.ErrNoRows): + return false, nil + case err != nil: + return false, fmt.Errorf("auth: totp enrolled lookup: %w", err) + } + return true, nil +} diff --git a/internal/core/module/registry.go b/internal/core/module/registry.go index 5378693..0e48800 100644 --- a/internal/core/module/registry.go +++ b/internal/core/module/registry.go @@ -37,12 +37,14 @@ var ( // setup — the SPEC-005 wizard's steps // recovery — the SPEC-007 approve/deny actions // core — the SPEC-008 Modules page +// account — the SPEC-010 account page (factors · add a passkey · opt out) +// users — the SPEC-010 admin Users page (list · create + invite) // // A discovered module may not claim one of them: the door would either drop the // discovered module (RegisterModule overwrites by name) or the discovered module // would shadow the core. Either way it would be a SILENT loss of precedence, so // the load fails closed instead. -var CoreDispatchNames = []string{"auth", "setup", "recovery", "core"} +var CoreDispatchNames = []string{"auth", "setup", "recovery", "core", "account", "users"} // ReservedWorkflowNames are the reserved level-1 workflows (SPEC-002 §4) that a // discovered module may NOT claim (SPEC-009 §13 Q-1). It is DERIVED from diff --git a/internal/core/users/store.go b/internal/core/users/store.go index bd658e1..d5b713a 100644 --- a/internal/core/users/store.go +++ b/internal/core/users/store.go @@ -106,6 +106,31 @@ func (s *Store) Get(ctx context.Context, id int64) (User, error) { return scanUser(row) } +// List implements Users.List — every user, ordered by user_id so the page is +// deterministic (never map iteration, SPEC-010 §5). +func (s *Store) List(ctx context.Context) ([]User, error) { + rows, err := s.db.Query(` + SELECT user_id, email, display_name, role, manager_user_id, passkey_opt_out, status, created_at + FROM users ORDER BY user_id`) + if err != nil { + return nil, fmt.Errorf("users: list: %w", err) + } + defer rows.Close() + + var out []User + for rows.Next() { + u, err := scanUser(rows) + if err != nil { + return nil, err + } + out = append(out, u) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("users: iterate: %w", err) + } + return out, nil +} + // SetRole implements Users.SetRole. The role ripples down (D-034); the // one-admin invariant is preserved (AC-6) and user 1 is protected (A14). func (s *Store) SetRole(ctx context.Context, id int64, role Role) error { diff --git a/internal/core/users/users.go b/internal/core/users/users.go index 0899222..144e004 100644 --- a/internal/core/users/users.go +++ b/internal/core/users/users.go @@ -135,6 +135,10 @@ type Users interface { // Get returns one user. Get(ctx context.Context, id int64) (User, error) + // List returns every user, ordered by user_id (stable). It is the admin + // Users page's read (SPEC-010 §5). + List(ctx context.Context) ([]User, error) + // SetRole changes a user's role (ripple-down model, D-034). It preserves // the one-admin invariant: only user 1 may be admin, and user 1 may not be // demoted (A14).