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.
110 lines
3.9 KiB
Go
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'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&action=account.passkey_opt_out">
|
|
<input type="hidden" name="csrf" value="{{.Token}}">
|
|
<button type="submit" class="secondary">Don'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&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'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))
|
|
)
|