NextMyDay/internal/core/account/account.go
Claus Lohmar 5dc0a0150e 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.
2026-10-15 07:58:18 +00:00

377 lines
14 KiB
Go

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