feat(role-editor): "Use as template" — copy another role's perm set

Adds a select+button next to the permission editor: pick any other role
from the dropdown, click "Use as template" → all checkboxes are replaced
with that role's permission set. Local-only (no API call); the user still
hits "Save changes" to persist. Includes a banner confirming the load
with the source role name + perm count.

Selector excludes the currently-edited role. Hidden for non-admins.

UI: dark card row matching the Foundry Deck token system (--bg-card,
--text-dim mono label, --bg-sink select with --accent focus border).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-24 19:38:18 +01:00
parent 766474f4e9
commit 04bb0c6f94
2 changed files with 70 additions and 0 deletions

View File

@@ -663,6 +663,32 @@ dd { font-size: .92rem; font-family: 'JetBrains Mono', monospace; }
color: var(--text-dim); font-size: .72rem; line-height: 1.35; margin-top: 2px; color: var(--text-dim); font-size: .72rem; line-height: 1.35; margin-top: 2px;
} }
.role-editor-template {
display: flex; align-items: center; gap: 10px;
background: var(--bg-card); border: var(--hair); border-radius: var(--radius);
padding: 10px 14px; margin-bottom: 14px;
font-size: .82rem;
}
.role-editor-template label {
color: var(--text-dim); text-transform: uppercase;
letter-spacing: .12em; font-size: .72rem;
font-family: 'JetBrains Mono', monospace;
white-space: nowrap;
}
.role-editor-template select {
flex: 1; min-width: 0;
padding: 7px 10px;
background: var(--bg-sink); color: var(--text);
border: var(--hair); border-radius: var(--radius);
font-family: 'JetBrains Mono', monospace; font-size: .82rem;
cursor: pointer; transition: .12s;
}
.role-editor-template select:hover { border-color: var(--border-bright); }
.role-editor-template select:focus { border-color: var(--accent); outline: none; }
.role-editor-template button:disabled {
opacity: .4; cursor: not-allowed; border-color: var(--border);
}
.role-editor-actions { .role-editor-actions {
display: flex; gap: 10px; margin-top: 22px; display: flex; gap: 10px; margin-top: 22px;
padding-top: 18px; border-top: var(--hair); padding-top: 18px; border-top: var(--hair);

View File

@@ -30,6 +30,7 @@ export default function RoleEditorPage() {
const [newRoleName, setNewRoleName] = useState('') const [newRoleName, setNewRoleName] = useState('')
const [newRoleDesc, setNewRoleDesc] = useState('') const [newRoleDesc, setNewRoleDesc] = useState('')
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [templateRoleId, setTemplateRoleId] = useState<string>('')
const isAdmin = user?.is_admin === true const isAdmin = user?.is_admin === true
@@ -58,6 +59,20 @@ export default function RoleEditorPage() {
setSelectedRole({ ...selectedRole, permission_ids: newPermIds }) setSelectedRole({ ...selectedRole, permission_ids: newPermIds })
} }
// Copy a source role's permission_ids over the current selection.
// Replaces (not merges) — the user explicitly clicked "use as template".
// Local-only: nothing persists until they hit "Save changes".
const handleApplyTemplate = () => {
if (!selectedRole || !templateRoleId) return
const src = roles.find((r) => String(r.id) === templateRoleId)
if (!src) return
setSelectedRole({ ...selectedRole, permission_ids: [...src.permission_ids] })
flashMessage(
`Loaded ${src.permission_ids.length} permissions from "${src.name}" — not saved yet`,
true,
)
}
const flashMessage = (msg: string, ok: boolean) => { const flashMessage = (msg: string, ok: boolean) => {
setMessage(msg); setMessageOk(ok) setMessage(msg); setMessageOk(ok)
} }
@@ -206,6 +221,35 @@ export default function RoleEditorPage() {
{selectedRole ? ( {selectedRole ? (
<> <>
<h3>Permissions for <span className="name">{selectedRole.name}</span></h3> <h3>Permissions for <span className="name">{selectedRole.name}</span></h3>
{isAdmin && roles.length > 1 && (
<div className="role-editor-template">
<label htmlFor="tpl-select">Copy from template:</label>
<select
id="tpl-select"
value={templateRoleId}
onChange={(e) => setTemplateRoleId(e.target.value)}
>
<option value=""> select a role </option>
{roles
.filter((r) => r.id !== selectedRole.id)
.map((r) => (
<option key={r.id} value={r.id}>
{r.name} ({r.permission_ids.length} perms)
</option>
))}
</select>
<button
className="btn-secondary"
disabled={!templateRoleId}
onClick={handleApplyTemplate}
title="Replace current checkboxes with the template role's permission set (local; not saved until you click Save changes)"
>
Use as template
</button>
</div>
)}
{Object.entries(groupedPermissions).map(([category, perms]) => ( {Object.entries(groupedPermissions).map(([category, perms]) => (
<div key={category} className="perm-group"> <div key={category} className="perm-group">
<div className="perm-group-header">{category}</div> <div className="perm-group-header">{category}</div>