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

110 lines
3.9 KiB
Go

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&#39;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&amp;action=account.passkey_opt_out">
<input type="hidden" name="csrf" value="{{.Token}}">
<button type="submit" class="secondary">Don&#39;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&amp;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&#39;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))
)