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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}}
|
||||
Reference in New Issue
Block a user