Add HTMX-based UI templates and handlers for account and audit management
- Introduced `web/templates/` for HTMX-fragmented pages (`dashboard`, `accounts`, `account_detail`, `error_fragment`, etc.). - Implemented UI routes for account CRUD, audit log display, and login/logout with CSRF protection. - Added `internal/ui/` package for handlers, CSRF manager, session validation, and token issuance. - Updated documentation to include new UI features and templates directory structure. - Security: Double-submit CSRF cookies, constant-time HMAC validation, login password/Argon2id re-verification at all steps to prevent bypass.
This commit is contained in:
39
web/templates/fragments/account_row.html
Normal file
39
web/templates/fragments/account_row.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{{define "account_row"}}
|
||||
<tr id="account-row-{{.UUID}}">
|
||||
<td><a href="/accounts/{{.UUID}}">{{.Username}}</a></td>
|
||||
<td class="text-small text-muted">{{.AccountType}}</td>
|
||||
<td>
|
||||
<span class="badge {{if eq (string .Status) "active"}}badge-active{{else if eq (string .Status) "inactive"}}badge-inactive{{else}}badge-deleted{{end}}">
|
||||
{{.Status}}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-small text-muted">{{if .TOTPRequired}}Yes{{else}}No{{end}}</td>
|
||||
<td class="text-small text-muted">{{formatTime .CreatedAt}}</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<a class="btn btn-sm btn-secondary" href="/accounts/{{.UUID}}">View</a>
|
||||
{{if eq (string .Status) "active"}}
|
||||
<button class="btn btn-sm btn-secondary"
|
||||
hx-patch="/accounts/{{.UUID}}/status"
|
||||
hx-vals='{"status":"inactive"}'
|
||||
hx-target="#account-row-{{.UUID}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Deactivate this account?">Deactivate</button>
|
||||
{{else if eq (string .Status) "inactive"}}
|
||||
<button class="btn btn-sm btn-secondary"
|
||||
hx-patch="/accounts/{{.UUID}}/status"
|
||||
hx-vals='{"status":"active"}'
|
||||
hx-target="#account-row-{{.UUID}}"
|
||||
hx-swap="outerHTML">Activate</button>
|
||||
{{end}}
|
||||
{{if ne (string .Status) "deleted"}}
|
||||
<button class="btn btn-sm btn-danger"
|
||||
hx-delete="/accounts/{{.UUID}}"
|
||||
hx-target="#account-row-{{.UUID}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Permanently delete this account? This cannot be undone.">Delete</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
21
web/templates/fragments/account_status.html
Normal file
21
web/templates/fragments/account_status.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{{define "account_status"}}
|
||||
<span class="badge {{if eq (string .Account.Status) "active"}}badge-active{{else if eq (string .Account.Status) "inactive"}}badge-inactive{{else}}badge-deleted{{end}}">
|
||||
{{.Account.Status}}
|
||||
</span>
|
||||
{{if ne (string .Account.Status) "deleted"}}
|
||||
{{if eq (string .Account.Status) "active"}}
|
||||
<button class="btn btn-sm btn-secondary" style="margin-left:.5rem"
|
||||
hx-patch="/accounts/{{.Account.UUID}}/status"
|
||||
hx-vals='{"status":"inactive"}'
|
||||
hx-target="#status-cell"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Deactivate this account?">Deactivate</button>
|
||||
{{else}}
|
||||
<button class="btn btn-sm btn-secondary" style="margin-left:.5rem"
|
||||
hx-patch="/accounts/{{.Account.UUID}}/status"
|
||||
hx-vals='{"status":"active"}'
|
||||
hx-target="#status-cell"
|
||||
hx-swap="innerHTML">Activate</button>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
14
web/templates/fragments/audit_rows.html
Normal file
14
web/templates/fragments/audit_rows.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{{define "audit_rows"}}
|
||||
{{range .Events}}
|
||||
<tr>
|
||||
<td class="text-small text-muted">{{formatTime .EventTime}}</td>
|
||||
<td><code style="font-size:.8rem">{{.EventType}}</code></td>
|
||||
<td class="text-small text-muted">{{if .ActorUsername}}{{.ActorUsername}}{{else}}—{{end}}</td>
|
||||
<td class="text-small text-muted">{{if .TargetUsername}}{{.TargetUsername}}{{else}}—{{end}}</td>
|
||||
<td class="text-small text-muted">{{.IPAddress}}</td>
|
||||
<td class="text-small text-muted" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{.Details}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="6" class="text-muted text-small" style="text-align:center;padding:2rem">No events found.</td></tr>
|
||||
{{end}}
|
||||
{{end}}
|
||||
3
web/templates/fragments/error.html
Normal file
3
web/templates/fragments/error.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{{define "error_fragment"}}
|
||||
<div class="alert alert-error" role="alert">{{.Error}}</div>
|
||||
{{end}}
|
||||
27
web/templates/fragments/roles_editor.html
Normal file
27
web/templates/fragments/roles_editor.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{{define "roles_editor"}}
|
||||
<div id="roles-editor">
|
||||
<form hx-put="/accounts/{{.Account.UUID}}/roles"
|
||||
hx-target="#roles-editor"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<div class="d-flex gap-1 align-center" style="flex-wrap:wrap;margin-bottom:.75rem">
|
||||
{{range .AllRoles}}
|
||||
<label style="display:flex;align-items:center;gap:.35rem;font-size:.875rem;cursor:pointer">
|
||||
<input type="checkbox" name="roles" value="{{.}}"
|
||||
{{if hasRole $.Roles .}}checked{{end}}>
|
||||
{{.}}
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
<div style="margin-bottom:.75rem">
|
||||
<label style="font-size:.875rem;font-weight:600;display:block;margin-bottom:.25rem">Custom role</label>
|
||||
<div class="d-flex gap-1">
|
||||
<input class="form-control" type="text" name="custom_role" placeholder="e.g. editor"
|
||||
style="max-width:200px;font-size:.875rem">
|
||||
<span class="text-muted text-small" style="align-self:center">(optional)</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" type="submit">Save Roles</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
44
web/templates/fragments/token_list.html
Normal file
44
web/templates/fragments/token_list.html
Normal file
@@ -0,0 +1,44 @@
|
||||
{{define "token_list"}}
|
||||
<div id="token-list">
|
||||
{{if .Tokens}}
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>JTI</th><th>Issued</th><th>Expires</th><th>Status</th><th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Tokens}}
|
||||
<tr id="token-row-{{truncateJTI .JTI}}">
|
||||
<td><code style="font-size:.75rem">{{truncateJTI .JTI}}</code></td>
|
||||
<td class="text-small text-muted">{{formatTime .IssuedAt}}</td>
|
||||
<td class="text-small text-muted">{{formatTime .ExpiresAt}}</td>
|
||||
<td>
|
||||
{{if .IsRevoked}}
|
||||
<span class="badge badge-deleted">revoked</span>
|
||||
{{else if .IsExpired}}
|
||||
<span class="badge badge-inactive">expired</span>
|
||||
{{else}}
|
||||
<span class="badge badge-active">active</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if not .IsRevoked}}
|
||||
<button class="btn btn-sm btn-danger"
|
||||
hx-delete="/token/{{.JTI}}"
|
||||
hx-target="#token-row-{{truncateJTI .JTI}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Revoke this token?">Revoke</button>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-muted text-small">No tokens issued.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
18
web/templates/fragments/totp_step.html
Normal file
18
web/templates/fragments/totp_step.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{{define "totp_step"}}
|
||||
<form id="login-form" method="POST" action="/login"
|
||||
hx-post="/login" hx-target="#login-form" hx-swap="outerHTML">
|
||||
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
|
||||
<input type="hidden" name="username" value="{{.Username}}">
|
||||
<input type="hidden" name="password" value="{{.Password}}">
|
||||
<input type="hidden" name="totp_step" value="1">
|
||||
<div class="form-group">
|
||||
<label for="totp_code">Authenticator Code</label>
|
||||
<input class="form-control" type="text" id="totp_code" name="totp_code"
|
||||
autocomplete="one-time-code" inputmode="numeric" pattern="[0-9]{6}"
|
||||
maxlength="6" required autofocus placeholder="6-digit code">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit" style="width:100%">Verify</button>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user