NextMyDay/internal/core/users/users.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

172 lines
6.1 KiB
Go

// Package users is MyDay's user service: the stable person record, the role
// ladder, and the manager chain (PROJECT_PLAN.md §7, D-020/D-024/D-034).
//
// The user is the stable identity anchor; credentials hang off a rotating
// identity owned by the auth package (D-008). This package never touches
// credentials.
//
// Spec: docs/specs/spec-004-identity-sessions.md §3, §5.
package users
import (
"context"
"errors"
"fmt"
"time"
)
// Role is a user's single tier in the ladder admin > director > manager > team
// (D-034: one role per user, no ACL — the role ripples down).
type Role string
const (
RoleAdmin Role = "admin"
RoleDirector Role = "director"
RoleManager Role = "manager"
RoleTeam Role = "team"
)
// roleRank orders the ladder. A higher number implies every lower role.
var roleRank = map[Role]int{
RoleTeam: 1,
RoleManager: 2,
RoleDirector: 3,
RoleAdmin: 4,
}
// Valid reports whether r is one of the four defined roles.
func (r Role) Valid() bool {
_, ok := roleRank[r]
return ok
}
// Rank returns the ladder position (higher = more authority); 0 for invalid.
func (r Role) Rank() int { return roleRank[r] }
// Above reports whether r is strictly higher than other on the ladder.
func (r Role) Above(other Role) bool { return r.Rank() > other.Rank() }
// String renders the role.
func (r Role) String() string { return string(r) }
// ParseRole validates a stored/entered role string.
func ParseRole(s string) (Role, error) {
r := Role(s)
if !r.Valid() {
return "", fmt.Errorf("%w: %q", ErrInvalidRole, s)
}
return r, nil
}
// Optional carries a value that may be absent (Go has no nullable generics).
// It models the manager chain: the top admin has NO manager (D-021).
type Optional[T any] struct {
Value T
Set bool
}
// Some returns a set Optional.
func Some[T any](v T) Optional[T] { return Optional[T]{Value: v, Set: true} }
// None returns an unset Optional.
func None[T any]() Optional[T] { return Optional[T]{} }
// UserID is the internal, stable person id. The first user is 1 (D-013).
type UserID = int64
// User statuses. A disabled user keeps their record and history but may not act.
const (
StatusActive = "active"
StatusDisabled = "disabled"
)
// CanApprove reports whether approver may decide a recovery request for
// requester (SPEC-007 §4, ruling R-7). The authority is the CHAIN, not the
// ladder: the approver must be an ANCESTOR of the requester — on their manager
// line, upward — or the admin. The requester may never approve their own
// request (AC-2).
//
// approverIsAncestor is computed by the caller, which owns the chain lookup
// (users.Users.Manager); this predicate stays pure. A role merely *above* the
// requester is NOT sufficient — that was R-7's correction: an unrelated
// director must not be able to recover a team member outside their subtree.
func CanApprove(approver, requester User, approverIsAncestor bool) bool {
if approver.ID == requester.ID {
return false
}
if approver.Role == RoleAdmin {
return true // the admin is the root of the chain and approves anyone (§4)
}
return approverIsAncestor
}
// User is the stable person record (SPEC-004 §4 `users`).
type User struct {
ID UserID
Email string
DisplayName string
Role Role
// Manager is the chain link: who approves this user (D-020). Unset only
// for the top admin.
Manager Optional[UserID]
// PasskeyOptOut records that the user declined the passkey nudge (D-041).
// It is a USER preference (not a credential), so it survives identity
// rotation (D-008): an opted-out user is never re-nudged after recovery.
PasskeyOptOut bool
Status string
CreatedAt time.Time
}
// Users is the service contract (SPEC-004 §5).
//
// Per ruling R-1 every method takes the request context: the door attaches
// audit.Meta (actor · client IP · correlation) before calling, and each
// mutation writes its audit row through it (A11).
type Users interface {
// Create creates a user below the creator on the chain.
//
// * creatorID != 0 — the creator must hold a role strictly ABOVE role
// (D-019/§7.3); the new user's manager defaults to the creator. A
// second admin is always rejected (AC-6).
// * creatorID == 0 — bootstrap: allowed ONLY while no user exists, and
// it must create the top admin (user 1, role admin, manager NULL).
Create(ctx context.Context, creatorID int64, email, displayName string, role Role) (UserID int64, err error)
// 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).
SetRole(ctx context.Context, id int64, role Role) error
// Manager returns the user above id in the chain.
Manager(ctx context.Context, id int64) (Optional[UserID], error)
// PasskeyOptOut reports whether the user has declined the passkey nudge
// (D-041). The marker is a USER preference, so it survives identity
// rotation.
PasskeyOptOut(ctx context.Context, id int64) (bool, error)
// SetPasskeyOptOut records the user's choice about the passkey nudge
// (D-041) and audits it (A11).
SetPasskeyOptOut(ctx context.Context, id int64, optOut bool) error
}
// Sentinel errors.
var (
ErrNotFound = errors.New("users: user not found")
ErrInvalidRole = errors.New("users: invalid role")
ErrRoleNotAllowed = errors.New("users: creator's role is not above the new user's role")
ErrOneAdmin = errors.New("users: exactly one admin is allowed")
ErrTopAdmin = errors.New("users: the top admin (user 1) cannot be demoted")
ErrDuplicateEmail = errors.New("users: email already in use")
ErrEmailRequired = errors.New("users: email is required")
ErrNameRequired = errors.New("users: display name is required")
ErrBootstrapClosed = errors.New("users: bootstrap is only allowed while no users exist")
ErrBootstrapAdmin = errors.New("users: the bootstrap user must be the admin")
)