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:
parent
b8ead2bf2d
commit
5dc0a0150e
8 changed files with 583 additions and 9 deletions
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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/audit"
|
||||||
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
|
||||||
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/flow"
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/flow"
|
||||||
|
|
@ -314,7 +315,24 @@ func run() error {
|
||||||
return err
|
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{
|
srv, err := server.New(server.Deps{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
|
|
@ -322,11 +340,14 @@ func run() error {
|
||||||
Registry: reg,
|
Registry: reg,
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
Identities: ids,
|
Identities: ids,
|
||||||
Users: us,
|
// SPEC-010 §6: the passkey banner needs "does this identity have a
|
||||||
Audit: aud,
|
// passkey?" — the identity service also implements Credentials.
|
||||||
CSRF: csrf,
|
Credentials: ids,
|
||||||
PreSession: preSession,
|
Users: us,
|
||||||
SetupMode: setupMode,
|
Audit: aud,
|
||||||
|
CSRF: csrf,
|
||||||
|
PreSession: preSession,
|
||||||
|
SetupMode: setupMode,
|
||||||
// D-045: the non-claimable locked state — `setup: false` and no admin.
|
// D-045: the non-claimable locked state — `setup: false` and no admin.
|
||||||
LockedMode: locked,
|
LockedMode: locked,
|
||||||
// D-044/D-045: the first-run gate — the OPTIONAL setup code + the
|
// 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.
|
// authenticated user; role-ABOVE rules live in the services.
|
||||||
// SPEC-008: the Modules page is admin-only — toggling modules changes
|
// SPEC-008: the Modules page is admin-only — toggling modules changes
|
||||||
// what the whole instance can do.
|
// 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().
|
RoleGate: server.NewTiers().
|
||||||
Require(module.ConsoleModule, module.FunctionModules, users.RoleAdmin).
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -367,7 +392,7 @@ func run() error {
|
||||||
|
|
||||||
// buildRegistry assembles the door's dispatch registry (SPEC-008 owns the
|
// buildRegistry assembles the door's dispatch registry (SPEC-008 owns the
|
||||||
// discovered set; this is the surface the door dispatches against).
|
// 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()
|
reg := server.NewMapRegistry()
|
||||||
|
|
||||||
// Core level-1 workflows (reserved: home · access · setup · static).
|
// 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)
|
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
|
return reg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
377
internal/core/account/account.go
Normal file
377
internal/core/account/account.go
Normal 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
|
||||||
|
}
|
||||||
110
internal/core/account/render.go
Normal file
110
internal/core/account/render.go
Normal 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'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&action=account.passkey_opt_out">
|
||||||
|
<input type="hidden" name="csrf" value="{{.Token}}">
|
||||||
|
<button type="submit" class="secondary">Don'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&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'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))
|
||||||
|
)
|
||||||
|
|
@ -99,6 +99,11 @@ type Identities interface {
|
||||||
// its bounded invite window (SPEC-004 §4, A1). SPEC-006 refuses enrollment
|
// its bounded invite window (SPEC-004 §4, A1). SPEC-006 refuses enrollment
|
||||||
// outside it. A non-pending identity reports false.
|
// outside it. A non-pending identity reports false.
|
||||||
WithinEnrollmentWindow(ctx context.Context, id int64) (bool, error)
|
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
|
// Sessions is the session service contract (SPEC-004 §5/§6). Per ruling R-1
|
||||||
|
|
|
||||||
|
|
@ -97,3 +97,22 @@ func (s *IdentitiesService) MarkTOTPVerified(ctx context.Context, identityID int
|
||||||
})
|
})
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,12 +37,14 @@ var (
|
||||||
// setup — the SPEC-005 wizard's steps
|
// setup — the SPEC-005 wizard's steps
|
||||||
// recovery — the SPEC-007 approve/deny actions
|
// recovery — the SPEC-007 approve/deny actions
|
||||||
// core — the SPEC-008 Modules page
|
// 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
|
// 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
|
// 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
|
// would shadow the core. Either way it would be a SILENT loss of precedence, so
|
||||||
// the load fails closed instead.
|
// 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
|
// 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
|
// discovered module may NOT claim (SPEC-009 §13 Q-1). It is DERIVED from
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,31 @@ func (s *Store) Get(ctx context.Context, id int64) (User, error) {
|
||||||
return scanUser(row)
|
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
|
// SetRole implements Users.SetRole. The role ripples down (D-034); the
|
||||||
// one-admin invariant is preserved (AC-6) and user 1 is protected (A14).
|
// 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 {
|
func (s *Store) SetRole(ctx context.Context, id int64, role Role) error {
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,10 @@ type Users interface {
|
||||||
// Get returns one user.
|
// Get returns one user.
|
||||||
Get(ctx context.Context, id int64) (User, error)
|
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
|
// 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
|
// the one-admin invariant: only user 1 may be admin, and user 1 may not be
|
||||||
// demoted (A14).
|
// demoted (A14).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue