feat(panel): add domain-admin role with per-domain authorization
test / test (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-10 23:43:59 +03:00
parent c9076655b9
commit 15baa1e5d0
28 changed files with 1425 additions and 333 deletions
+3 -1
View File
@@ -23,6 +23,7 @@
<label for="new_password_confirm">Confirm new password</label>
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
{{if .ShowDMARC}}
<h3>DMARC aggregate reports</h3>
<p class="muted">Optional default <code>rua=</code> address for every sending
domain (can be overridden per domain). Use a mailbox on a domain that
@@ -58,11 +59,12 @@
{{end}}</span>{{end}}
{{end}}
{{end}}
{{end}}
<button type="submit">Save changes</button>
</form>
<p class="muted">Leave both new-password fields empty to change the username
or DMARC address only. Changing the password signs out every other session;
{{if .ShowDMARC}}or DMARC address {{end}}only. Changing the password signs out every other session;
this one stays signed in.</p>
</div>
{{end}}
+3 -1
View File
@@ -3,6 +3,7 @@
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
{{if .IsGlobal}}
<div class="card">
<h2>Add a sending domain</h2>
<form method="post" action="/domains">
@@ -16,6 +17,7 @@
<p class="muted">A DKIM key is generated for the domain; you then publish the
shown DNS record. Adding a domain does not create an application.</p>
</div>
{{end}}
<div class="card">
<h2>Domains</h2>
@@ -31,7 +33,7 @@
<td><a class="st st-{{.DNS}}" href="/domains/{{.ID}}#dns-status">{{.DNS}}</a></td>
<td class="muted">{{.DKIMSelector}}</td>
<td>{{.AppCount}}</td>
<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td>
{{if $.IsGlobal}}<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td>{{else}}<td></td>{{end}}
</tr>
{{end}}
</tbody>
+6 -1
View File
@@ -64,14 +64,19 @@
the column: the mark, the panel's pages, the current page's own sections,
and the session last. The mark goes to /status, the page the panel opens
on — named outright rather than as "/", which is only a redirect to it. */}}
<a class="brand" href="/status"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
<a class="brand" href="{{if .IsGlobal}}/status{{else}}/domains{{end}}"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
<div class="links">
{{if .IsGlobal}}
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
{{end}}
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
{{if .IsGlobal}}
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
{{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/system-log">{{template "icon-system-log"}}System log</a>{{end}}
{{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/backup">{{template "icon-backup"}}Backup</a>{{end}}
{{if eq .Active "users"}}<span aria-current="page">{{template "icon-account"}}Users</span>{{else}}<a href="/users">{{template "icon-account"}}Users</a>{{end}}
{{end}}
</div>
{{template "sections" .}}
<div class="session">
@@ -0,0 +1,38 @@
{{define "content"}}
<h1>{{if .IsEdit}}Edit user{{else}}Create user{{end}}</h1>
<div class="card narrow">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="{{if .IsEdit}}/users/{{.UserID}}{{else}}/users/new{{end}}">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username"
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
<label for="password">Password{{if .IsEdit}} (leave empty to keep){{end}}</label>
<input id="password" name="password" type="password" autocomplete="new-password" {{if not .IsEdit}}required{{end}}>
<label for="role">Role</label>
<select id="role" name="role">
<option value="domain_admin" {{if eq .FormRole "domain_admin"}}selected{{end}}>Domain administrator</option>
<option value="global" {{if eq .FormRole "global"}}selected{{end}}>Global administrator</option>
</select>
<fieldset id="domain-pick">
<legend>Assigned domains</legend>
<p class="muted">Required for domain administrators.</p>
{{range .Domains}}
<label class="checkbox">
<input type="checkbox" name="domain_ids" value="{{.ID}}" {{if index $.FormDomains .ID}}checked{{end}}>
{{.Name}}
</label>
{{end}}
</fieldset>
<button type="submit">{{if .IsEdit}}Save{{else}}Create{{end}}</button>
{{if .IsEdit}}
<button type="submit" name="action" value="delete" class="danger">Delete user</button>
{{end}}
</form>
<p class="muted"><a href="/users">Back to users</a></p>
</div>
{{end}}
+28
View File
@@ -0,0 +1,28 @@
{{define "content"}}
<h1>Panel users</h1>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
<div class="card">
<p><a href="/users/new">Create user</a></p>
{{if .Users}}
<table>
<thead>
<tr><th>Username</th><th>Role</th><th>Domains</th><th></th></tr>
</thead>
<tbody>
{{range .Users}}
<tr>
<td>{{.User.Username}}</td>
<td>{{if eq .User.Role "global"}}Global{{else}}Domain admin{{end}}</td>
<td class="muted">{{if eq .User.Role "global"}}All{{else}}{{range $i, $n := .DomainNames}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}</td>
<td class="actions"><a href="/users/{{.User.ID}}">Edit</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">No panel users besides the global administrator yet.</p>
{{end}}
</div>
{{end}}
+6 -4
View File
@@ -207,8 +207,9 @@ func TestNavMarksActivePage(t *testing.T) {
}
var buf bytes.Buffer
err = engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "mail_queue",
"User": "admin",
"Active": "mail_queue",
"IsGlobal": true,
})
if err != nil {
t.Fatalf("execute nav: %v", err)
@@ -234,8 +235,9 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
}
var buf bytes.Buffer
if err := engine.Page("status").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "status",
"User": "admin",
"Active": "status",
"IsGlobal": true,
}); err != nil {
t.Fatalf("execute nav: %v", err)
}
+2
View File
@@ -35,6 +35,8 @@ var pageFiles = map[string][]string{
"login": {"templates/login.html"},
"dashboard": {"templates/dashboard.html"},
"account": {"templates/account.html"},
"users": {"templates/users.html"},
"user_form": {"templates/user_form.html"},
"backup": {"templates/backup.html", "templates/encrypt_fields.html"},
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
"domain_delete": {"templates/domain_delete.html"},