Phase 4 — P4-03/04: RBAC + user management #14
@@ -264,6 +264,7 @@ func (s *Server) routes(r chi.Router) {
|
||||
|
||||
if s.deps.UI != nil {
|
||||
r.Get("/settings", s.handleUISettings)
|
||||
r.Get("/settings/users", s.handleUIUsersList)
|
||||
r.Get("/settings/notifications", s.handleUINotificationsList)
|
||||
r.Get("/settings/notifications/new", s.handleUINotificationNewGet)
|
||||
r.Post("/settings/notifications/new", s.handleUINotificationNewPost)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// ui_users.go — Settings → Users HTML handlers (admin-only).
|
||||
//
|
||||
// Routes (wired in server.go's admin band):
|
||||
//
|
||||
// GET /settings/users → handleUIUsersList (this task)
|
||||
// GET /settings/users/new → F2
|
||||
// POST /settings/users/new → F2
|
||||
// GET /settings/users/{id}/edit → F3
|
||||
// POST /settings/users/{id}/edit → F3
|
||||
// GET /settings/users/{id}/setup-link → F2
|
||||
// POST /settings/users/{id}/disable → F3
|
||||
// POST /settings/users/{id}/enable → F3
|
||||
// POST /settings/users/{id}/regenerate-setup → F3
|
||||
// POST /settings/users/{id}/force-logout → F3
|
||||
package http
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
stdhttp "net/http"
|
||||
)
|
||||
|
||||
type usersPage struct {
|
||||
Users []userRow
|
||||
ShowDisabled bool
|
||||
}
|
||||
|
||||
type userRow struct {
|
||||
ID string
|
||||
Username string
|
||||
Email string
|
||||
Role string
|
||||
LastLoginAt string // pre-formatted "2006-01-02 15:04:05" or "never"
|
||||
Disabled bool
|
||||
MustChangePassword bool
|
||||
}
|
||||
|
||||
func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
showDisabled := r.URL.Query().Get("show_disabled") == "1"
|
||||
users, err := s.deps.Store.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("ui users: list", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rows := make([]userRow, 0, len(users))
|
||||
for _, ux := range users {
|
||||
if !showDisabled && ux.DisabledAt != nil {
|
||||
continue
|
||||
}
|
||||
em := ""
|
||||
if ux.Email != nil {
|
||||
em = *ux.Email
|
||||
}
|
||||
ll := "never"
|
||||
if ux.LastLoginAt != nil {
|
||||
ll = ux.LastLoginAt.UTC().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
rows = append(rows, userRow{
|
||||
ID: ux.ID, Username: ux.Username, Email: em,
|
||||
Role: string(ux.Role), LastLoginAt: ll,
|
||||
Disabled: ux.DisabledAt != nil,
|
||||
MustChangePassword: ux.MustChangePassword,
|
||||
})
|
||||
}
|
||||
view := s.baseView(r, u)
|
||||
view.Title = "Users · restic-manager"
|
||||
view.Active = "settings"
|
||||
view.Page = usersPage{Users: rows, ShowDisabled: showDisabled}
|
||||
if err := s.deps.UI.Render(w, "users", view); err != nil {
|
||||
slog.Error("ui users: render", "err", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -563,6 +563,25 @@
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- user-management rows (/settings/users) ---------- */
|
||||
.user-row {
|
||||
display: grid; align-items: center;
|
||||
grid-template-columns: 180px 1fr 110px 160px 120px 90px;
|
||||
column-gap: 16px;
|
||||
padding: 11px 16px; font-size: 13px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
transition: background 100ms ease;
|
||||
}
|
||||
.user-row:hover { background: var(--panel-hi); }
|
||||
.user-row:last-child { border-bottom: 0; }
|
||||
.user-row.head {
|
||||
cursor: default; padding-top: 9px; padding-bottom: 9px;
|
||||
font-size: 11px; color: var(--ink-fade);
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
}
|
||||
.user-row.head:hover { background: transparent; }
|
||||
.user-row.disabled { opacity: 0.55; }
|
||||
|
||||
/* ---------- test-result pills (notification test button) ---------- */
|
||||
.test-pill {
|
||||
display: inline-block;
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
Notifications
|
||||
{{if not $page.Form}}<span class="mono text-ink-fade text-[11px] ml-1">{{len $page.Channels}}</span>{{end}}
|
||||
</a>
|
||||
<span class="sub-tab text-ink-fade cursor-default" title="lands later">Users</span>
|
||||
<a href="/settings/users" class="sub-tab {{if eq $page.ActiveTab "users"}}active{{end}}">Users</a>
|
||||
<span class="sub-tab text-ink-fade cursor-default" title="lands later">Authentication</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
{{define "title"}}Users · restic-manager{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{$page := .Page}}
|
||||
<div class="max-w-[1280px] mx-auto px-8 pb-14">
|
||||
<div class="crumbs pt-6">
|
||||
<a href="/">Dashboard</a><span class="sep">/</span>
|
||||
<a href="/settings">Settings</a><span class="sep">/</span>
|
||||
<span class="text-ink-mid">users</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-baseline justify-between mt-3.5">
|
||||
<h1 class="text-[22px] font-medium tracking-[-0.005em]">
|
||||
Users
|
||||
<span class="text-ink-fade font-normal text-[14px] ml-2">{{len $page.Users}}</span>
|
||||
</h1>
|
||||
<div class="flex gap-2">
|
||||
<a href="/settings/users/new" class="btn btn-primary">+ Add user</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/settings/users" class="mt-3 text-[12px] text-ink-mute">
|
||||
<label class="cursor-pointer flex items-center gap-2">
|
||||
<input type="checkbox" name="show_disabled" value="1"
|
||||
{{if $page.ShowDisabled}}checked{{end}}
|
||||
onchange="this.form.submit()" />
|
||||
Show disabled users
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<div class="panel mt-4 rounded-[7px] overflow-hidden">
|
||||
<div class="user-row head">
|
||||
<div>Username</div>
|
||||
<div>Email</div>
|
||||
<div>Role</div>
|
||||
<div>Last login</div>
|
||||
<div>Status</div>
|
||||
<div></div>
|
||||
</div>
|
||||
{{range $page.Users}}
|
||||
<div class="user-row{{if .Disabled}} disabled{{end}}">
|
||||
<div class="mono text-ink">
|
||||
<a href="/settings/users/{{.ID}}/edit" class="hover:underline">{{.Username}}</a>
|
||||
</div>
|
||||
<div class="mono text-ink-mid text-[12px]">{{if .Email}}{{.Email}}{{else}}<span class="text-ink-fade">—</span>{{end}}</div>
|
||||
<div class="mono text-[12px] text-ink-mid">{{.Role}}</div>
|
||||
<div class="mono text-[12px] text-ink-mute">
|
||||
{{if eq .LastLoginAt "never"}}<span class="text-ink-fade">never</span>{{else}}{{.LastLoginAt}}{{end}}
|
||||
</div>
|
||||
<div>
|
||||
{{if .Disabled}}<span class="tag" style="color: var(--ink-fade);">disabled</span>
|
||||
{{else if .MustChangePassword}}<span class="tag" style="color: var(--warn); border-color: color-mix(in oklch, var(--warn), transparent 60%); background: color-mix(in oklch, var(--warn), transparent 92%);">setup pending</span>
|
||||
{{else}}<span class="tag" style="color: var(--ok);">enabled</span>{{end}}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<a href="/settings/users/{{.ID}}/edit" class="btn">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user