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