ui: /settings/users/new + /setup-link page

Adds handleUIUserNewGet, handleUIUserNewPost, handleUIUserSetupLinkGet
to ui_users.go; creates web/templates/pages/user_edit.html (multi-mode
new/edit/setup-link); wires three routes in the admin band of server.go.
This commit is contained in:
2026-05-05 09:59:20 +01:00
parent 211f11e460
commit 04a413eb55
4 changed files with 281 additions and 1 deletions
+3
View File
@@ -265,6 +265,9 @@ 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/users/new", s.handleUIUserNewGet)
r.Post("/settings/users/new", s.handleUIUserNewPost)
r.Get("/settings/users/{id}/setup-link", s.handleUIUserSetupLinkGet)
r.Get("/settings/notifications", s.handleUINotificationsList)
r.Get("/settings/notifications/new", s.handleUINotificationNewGet)
r.Post("/settings/notifications/new", s.handleUINotificationNewPost)
+172
View File
@@ -15,8 +15,17 @@
package http
import (
"errors"
"log/slog"
stdhttp "net/http"
"net/mail"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
type usersPage struct {
@@ -74,3 +83,166 @@ func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request)
slog.Error("ui users: render", "err", err)
}
}
type userFormPage struct {
Mode string // "new" | "edit" | "setup-link"
ID string
Username string
Email string
Role string
Disabled bool
HasSetup bool
SetupURL string
SetupExpAt time.Time
Error string
}
func (s *Server) handleUIUserNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
view := s.baseView(r, u)
view.Title = "New user · restic-manager"
view.Active = "settings"
view.Page = userFormPage{Mode: "new", Role: "operator"}
_ = s.deps.UI.Render(w, "user_edit", view)
}
func (s *Server) handleUIUserNewPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
uname := strings.ToLower(strings.TrimSpace(r.PostForm.Get("username")))
email := strings.TrimSpace(r.PostForm.Get("email"))
role, ok := validRole(r.PostForm.Get("role"))
if uname == "" || !ok {
view := s.baseView(r, u)
view.Title = "New user · restic-manager"
view.Active = "settings"
view.Page = userFormPage{
Mode: "new", Username: uname, Email: email,
Role: r.PostForm.Get("role"),
Error: "Username is required and role must be admin/operator/viewer.",
}
_ = s.deps.UI.Render(w, "user_edit", view)
return
}
if email != "" {
if _, err := mail.ParseAddress(email); err != nil {
view := s.baseView(r, u)
view.Title = "New user · restic-manager"
view.Active = "settings"
view.Page = userFormPage{
Mode: "new", Username: uname, Email: email,
Role: r.PostForm.Get("role"),
Error: "Email is not a valid address.",
}
_ = s.deps.UI.Render(w, "user_edit", view)
return
}
}
// Same collision logic as the API.
existing, err := s.deps.Store.GetUserByUsername(r.Context(), uname)
if err == nil {
if existing.DisabledAt != nil {
// Punt the admin to the edit page where Re-enable is one click.
stdhttp.Redirect(w, r, "/settings/users/"+existing.ID+
"/edit?reenable=1", stdhttp.StatusSeeOther)
return
}
view := s.baseView(r, u)
view.Title = "New user · restic-manager"
view.Active = "settings"
view.Page = userFormPage{
Mode: "new", Username: uname, Email: email,
Role: r.PostForm.Get("role"),
Error: "A user with that name already exists.",
}
_ = s.deps.UI.Render(w, "user_edit", view)
return
} else if !errors.Is(err, store.ErrNotFound) {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
id := ulid.Make().String()
now := time.Now().UTC()
var emailPtr *string
if email != "" {
em := strings.ToLower(email)
emailPtr = &em
}
if err := s.deps.Store.CreateUser(r.Context(), store.User{
ID: id, Username: uname, PasswordHash: "",
Role: role, Email: emailPtr, CreatedAt: now,
MustChangePassword: true,
}); err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
rawToken, err := generateSetupToken()
if err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{
UserID: id, TokenHash: hashSetupToken(rawToken),
ExpiresAt: now.Add(time.Hour),
CreatedAt: now, CreatedBy: &u.ID,
}); err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
Action: "user.created", TargetKind: ptr("user"), TargetID: &id,
TS: now,
})
stdhttp.Redirect(w, r,
"/settings/users/"+id+"/setup-link?token="+rawToken,
stdhttp.StatusSeeOther)
}
func (s *Server) handleUIUserSetupLinkGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
id := chi.URLParam(r, "id")
target, err := s.deps.Store.GetUserByID(r.Context(), id)
if err != nil {
stdhttp.NotFound(w, r)
return
}
rawToken := r.URL.Query().Get("token")
tok, err := s.deps.Store.GetSetupTokenByUserID(r.Context(), id)
if err != nil || rawToken == "" {
w.WriteHeader(stdhttp.StatusGone)
view := s.baseView(r, u)
view.Title = "Link expired · restic-manager"
view.Active = "settings"
view.Page = userFormPage{
Mode: "setup-link", ID: target.ID, Username: target.Username,
Error: "expired",
}
_ = s.deps.UI.Render(w, "user_edit", view)
return
}
view := s.baseView(r, u)
view.Title = "Setup link · restic-manager"
view.Active = "settings"
view.Page = userFormPage{
Mode: "setup-link", ID: target.ID, Username: target.Username,
Role: string(target.Role), HasSetup: true,
SetupURL: s.deps.Cfg.BaseURL + "/setup?token=" + rawToken,
SetupExpAt: tok.ExpiresAt,
}
_ = s.deps.UI.Render(w, "user_edit", view)
}
File diff suppressed because one or more lines are too long
+105
View File
@@ -0,0 +1,105 @@
{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{$page := .Page}}
<div class="max-w-[760px] 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>
<a href="/settings/users">Users</a><span class="sep">/</span>
<span class="text-ink-mid">{{if eq $page.Mode "new"}}new{{else if eq $page.Mode "setup-link"}}setup link{{else}}{{$page.Username}}{{end}}</span>
</div>
<h1 class="text-[22px] font-medium tracking-[-0.005em] mt-3.5">
{{if eq $page.Mode "new"}}New user
{{else if eq $page.Mode "setup-link"}}Setup link for <span class="mono">{{$page.Username}}</span>
{{else}}Edit <span class="mono">{{$page.Username}}</span>{{end}}
</h1>
{{if eq $page.Mode "setup-link"}}
{{if eq $page.Error "expired"}}
<div class="panel mt-7 rounded-[7px] p-6"
style="border-color: color-mix(in oklch, var(--bad), transparent 60%);">
<div class="text-[13px] font-medium text-bad mb-2">Link expired or already used</div>
<p class="text-pretty text-[12.5px] text-ink-mute leading-[1.6]">
This user's setup token is no longer valid. Open their Edit page and click
<span class="mono">Regenerate setup link</span> to issue a new one.
</p>
<a href="/settings/users/{{$page.ID}}/edit" class="btn btn-primary mt-5">Open edit page</a>
</div>
{{else}}
<div class="panel mt-7 rounded-[7px] p-6">
<p class="text-pretty text-[13px] text-ink-mute leading-[1.6] mb-3">
Send this link to the user. It expires at
<span class="mono text-ink-mid">{{absTime $page.SetupExpAt}}</span> UTC
(~1 hour from now). This is the only time you'll see it — if you lose
it, regenerate from the Edit page.
</p>
<div class="mono text-[13px] text-ink p-3 rounded"
style="background: var(--bg); border: 1px solid var(--line-soft); word-break: break-all;"
id="setup-url">{{$page.SetupURL}}</div>
<button type="button" class="btn btn-primary mt-4"
onclick="navigator.clipboard.writeText(document.getElementById('setup-url').textContent.trim()).then(function(){var b=event.target;b.textContent='Copied';setTimeout(function(){b.textContent='Copy link';},1500)})">Copy link</button>
<a href="/settings/users" class="btn ml-2">Done</a>
</div>
{{end}}
{{else}}
{{/* new + edit form. */}}
<form method="post"
action="{{if eq $page.Mode "new"}}/settings/users/new{{else}}/settings/users/{{$page.ID}}/edit{{end}}"
class="panel mt-7 rounded-[7px] p-6 space-y-4">
<div>
<label class="field-label" for="username">Username</label>
<input id="username" name="username" type="text"
class="field mono"
{{if ne $page.Mode "new"}}readonly disabled{{end}}
value="{{$page.Username}}"
autocomplete="off" required />
<div class="field-help">Lowercased automatically.</div>
</div>
<div>
<label class="field-label" for="email">Email <span class="text-ink-fade font-normal">· optional</span></label>
<input id="email" name="email" type="email" class="field"
value="{{$page.Email}}" autocomplete="off" />
</div>
<div>
<label class="field-label" for="role">Role</label>
<select id="role" name="role" class="field">
<option value="admin" {{if eq $page.Role "admin"}}selected{{end}}>admin</option>
<option value="operator" {{if eq $page.Role "operator"}}selected{{end}}>operator</option>
<option value="viewer" {{if eq $page.Role "viewer"}}selected{{end}}>viewer</option>
</select>
</div>
{{if $page.Error}}<div class="text-bad text-[12.5px]">{{$page.Error}}</div>{{end}}
<div class="flex gap-2 pt-2">
<button type="submit" class="btn btn-primary">{{if eq $page.Mode "new"}}Create user{{else}}Save changes{{end}}</button>
<a href="/settings/users" class="btn">Cancel</a>
</div>
</form>
{{if eq $page.Mode "edit"}}
{{/* Side actions: regenerate setup link, disable / re-enable, force logout. */}}
<div class="panel mt-5 rounded-[7px] p-6">
<div class="text-[12.5px] text-ink mb-3 font-medium">Other actions</div>
<div class="flex gap-2 flex-wrap">
<form method="post" action="/settings/users/{{$page.ID}}/regenerate-setup">
<button type="submit" class="btn">Regenerate setup link</button>
</form>
<form method="post" action="/settings/users/{{$page.ID}}/force-logout">
<button type="submit" class="btn">Force logout</button>
</form>
{{if $page.Disabled}}
<form method="post" action="/settings/users/{{$page.ID}}/enable">
<button type="submit" class="btn">Re-enable user</button>
</form>
{{else}}
<form method="post" action="/settings/users/{{$page.ID}}/disable">
<button type="submit" class="btn btn-danger">Disable user</button>
</form>
{{end}}
</div>
</div>
{{end}}
{{end}}
</div>
{{end}}