feat(account): add the account page and the admin Users page

The live instance had no way to add a passkey or a user. Two core pages now
make the signed-in experience usable (SPEC-010):

  * the ACCOUNT page (GET /{GUID}/account) shows the user's factors (passkey
    count · TOTP enrolled), an "Add a passkey" button that LINKS to the
    existing D-041 ceremony screen — the WebAuthn flow is never duplicated —
    and a "Don't ask again" control writing users.passkey_opt_out;
  * the admin USERS page (GET /{GUID}/users) lists every user (name · email ·
    role · manager · identity status) and creates one: only roles below the
    creator (D-019), the manager defaulting to the creator, then users.Create
    -> identities.Mint -> the /{GUID} invite is EMAILED and shown for
    copy/paste.

Both ride the door as core dispatch modules, so the door still gates them
(session + role + CSRF). The entrypoint requires admin for the users
functions; account stays at the team floor.

Additive support: auth.Identities.TOTPEnrolled, users.Users.List,
server.Deps.Credentials (the banner's "has a passkey?" read) and the
CoreDispatchNames guard now covers account/users.
This commit is contained in:
Claus Lohmar 2026-10-15 07:58:18 +00:00
parent b8ead2bf2d
commit 5dc0a0150e
8 changed files with 583 additions and 9 deletions

View file

@ -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
}

View file

@ -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
}

View file

@ -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 = `<h1>Account</h1>
<section aria-label="factors">
<h2>Your factors</h2>
<p>Passkeys registered: {{.Passkeys}}</p>
<p>Authenticator app (TOTP): {{if .TOTP}}enrolled{{else}}not enrolled{{end}}</p>
</section>
<section aria-label="passkey">
<h2>Passkey</h2>
<p>Add a passkey to sign in without the one-time code. It is bound to this device and this site.</p>
<p><a class="button" href="/{{.GUID}}/auth/passkey">Add a passkey</a></p>
</section>
<section aria-label="opt-out">
<h2>Don&#39;t ask again</h2>
{{if .OptOut}}<p>You have opted out of passkey reminders.</p>
{{else}}<p>Stop the "add a passkey" reminders.</p>
<form method="post" action="/{{.GUID}}?do=account&amp;action=account.passkey_opt_out">
<input type="hidden" name="csrf" value="{{.Token}}">
<button type="submit" class="secondary">Don&#39;t ask again</button>
</form>{{end}}
</section>
`
// usersTemplateHTML is the Users page BODY only (SPEC-010 §5).
const usersTemplateHTML = `<h1>Users</h1>
{{if .Notice}}<p class="flash {{.NoticeKind}}" role="status">{{.Notice}}</p>{{end}}
{{if .InviteLink}}<p>Invitation link (share it if email is not configured):<br><span class="invite">{{.InviteLink}}</span></p>{{end}}
<table>
<thead><tr><th>Name</th><th>Email</th><th>Role</th><th>Manager</th><th>Identity</th></tr></thead>
<tbody>
{{range .Rows}}<tr><td>{{.Name}}</td><td>{{.Email}}</td><td>{{.Role}}</td><td>{{.Manager}}</td><td>{{.Status}}</td></tr>
{{end}}</tbody>
</table>
<section aria-label="create-user">
<h2>Add a user</h2>
<form method="post" action="/{{.GUID}}?do=users&amp;action=users.create">
<input type="hidden" name="csrf" value="{{.Token}}">
<label>Name <input name="display_name" autocomplete="name" required></label>
<label>Email <input name="email" type="email" autocomplete="email" required></label>
<label>Role <select name="role">{{range .Roles}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
<button type="submit">Create and invite</button>
</form>
<p>The new user&#39;s manager defaults to you. Only roles below your own can be created.</p>
</section>
`
// 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))
)

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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 {

View file

@ -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).