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.
377 lines
14 KiB
Go
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
|
|
}
|