style(role-editor): rewrite UI on Foundry Deck dark tokens

Replace hardcoded light-theme inline colors (#fff/#f8f9fa/#e8f5e9/#888…)
with semantic CSS classes that consume the existing design tokens
(--bg-card/--ember/--ember-soft/--accent/--text-dim/--steel/etc.). Fixes
the unreadable role names, faded checkmark cards, and washed-out success
banner — everything was rendering "white-on-cream" against the dark
Foundry Deck background.

Visual structure now:
- two-column grid (sidebar 280px / detail flex)
- role-card: dark surface, ember-soft + 3px ember edge + glow on selected,
  Saira Condensed display name + mono permission count
- perm-group card with dashed steel header
- perm-item: bg-sink default; ember-soft + accent border + ember-tint check
  glyph on checked; native checkbox restyled with the ember palette
- banner pill: success-green or danger-red token, mono text
- create-role card: ember left-edge, mono uppercase labels
- delete uses .btn-danger (already in the token set)

No state/logic changes — same fetch + toggle + save flow.

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

View File

@@ -513,6 +513,168 @@ dd { font-size: .92rem; font-family: 'JetBrains Mono', monospace; }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); } .tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-content { margin-top: 18px; animation: fadeIn .25s ease; } .tab-content { margin-top: 18px; animation: fadeIn .25s ease; }
/* ---- Role Editor -------------------------------------------------------- */
.role-editor-page { animation: deck-in .35s cubic-bezier(.16,1,.3,1) both; }
.role-editor-page h2 { display: flex; align-items: center; gap: 10px; }
.role-editor-page .lead {
color: var(--text-dim); font-size: .82rem;
text-transform: uppercase; letter-spacing: .12em; margin: 8px 0 22px;
}
.role-editor-banner {
border: var(--hair); border-radius: var(--radius);
padding: 12px 16px; margin-bottom: 18px;
font-family: 'JetBrains Mono', monospace; font-size: .85rem;
display: flex; align-items: center; gap: 10px;
animation: fadeIn .25s ease;
}
.role-editor-banner.ok { background: rgba(70,180,135,.10); border-color: var(--success); color: var(--success); }
.role-editor-banner.err { background: rgba(226,85,60,.10); border-color: var(--danger); color: var(--danger); }
.role-editor-create {
background: var(--bg-card); border: var(--hair); border-radius: var(--radius);
padding: 22px 22px 18px; margin-bottom: 22px;
position: relative; max-width: 480px;
animation: fadeIn .2s ease;
}
.role-editor-create::before {
content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--ember); border-radius: var(--radius) 0 0 var(--radius);
}
.role-editor-create h3 { margin-bottom: 14px; font-size: 1.05rem; letter-spacing: .04em; }
.role-editor-create label {
display: block; margin-bottom: 5px;
font-size: .72rem; text-transform: uppercase; letter-spacing: .12em;
color: var(--text-dim); font-family: 'JetBrains Mono', monospace;
}
.role-editor-create input {
width: 100%; padding: 9px 12px; margin-bottom: 14px;
background: var(--bg-sink); color: var(--text);
border: var(--hair); border-radius: var(--radius);
font-family: inherit; font-size: .9rem; transition: .15s;
}
.role-editor-create input:focus { border-color: var(--accent); outline: none; }
.role-editor-create .actions { display: flex; gap: 8px; }
/* ---- two-column body ---- */
.role-editor-grid {
display: grid; grid-template-columns: 280px 1fr; gap: 22px;
align-items: start;
}
@media (max-width: 980px) {
.role-editor-grid { grid-template-columns: 1fr; }
}
.role-editor-sidebar h3,
.role-editor-detail h3 {
font-size: .82rem; text-transform: uppercase; letter-spacing: .14em;
color: var(--text-dim); margin-bottom: 12px;
border-bottom: var(--hair); padding-bottom: 8px;
}
.role-editor-detail h3 .name {
color: var(--accent); font-family: 'JetBrains Mono', monospace;
letter-spacing: .04em; text-transform: none;
}
.role-list { display: flex; flex-direction: column; gap: 8px; }
.role-card {
position: relative; padding: 12px 14px;
background: var(--bg-card); border: var(--hair); border-radius: var(--radius);
cursor: pointer; transition: .15s; overflow: hidden;
}
.role-card:hover { background: var(--bg-hover); border-color: var(--border-bright); }
.role-card.active {
background: var(--ember-soft); border-color: var(--accent);
box-shadow: var(--glow);
}
.role-card.active::before {
content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--ember);
}
.role-card .role-name {
font-family: 'Saira Condensed', sans-serif; font-weight: 700;
font-size: .98rem; color: var(--text); letter-spacing: .04em;
display: flex; align-items: center; gap: 6px;
}
.role-card .role-desc {
color: var(--text-dim); font-size: .78rem; margin-top: 3px;
line-height: 1.35;
}
.role-card .role-meta {
color: var(--steel); font-size: .68rem; margin-top: 6px;
font-family: 'JetBrains Mono', monospace; letter-spacing: .04em;
}
.role-card .role-badge-global {
font-size: .72rem; color: var(--warning);
font-family: 'JetBrains Mono', monospace;
}
/* ---- permissions ---- */
.perm-group {
background: var(--bg-card); border: var(--hair); border-radius: var(--radius);
padding: 14px 16px 16px; margin-bottom: 14px;
}
.perm-group-header {
font-family: 'Saira Condensed', sans-serif; font-weight: 600;
font-size: .85rem; text-transform: uppercase; letter-spacing: .14em;
color: var(--steel); margin-bottom: 12px;
padding-bottom: 8px; border-bottom: 1px dashed var(--border);
}
.perm-grid {
display: grid; gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
}
.perm-item {
display: flex; align-items: flex-start; gap: 10px;
padding: 9px 11px;
background: var(--bg-sink); border: 1px solid var(--border); border-radius: 3px;
cursor: pointer; transition: .12s;
user-select: none;
}
.perm-item:hover { background: var(--bg-hover); border-color: var(--border-bright); }
.perm-item.checked {
background: var(--ember-soft);
border-color: var(--accent);
box-shadow: inset 0 0 0 1px rgba(255,122,31,.18);
}
.perm-item input[type="checkbox"] {
appearance: none; -webkit-appearance: none;
width: 14px; height: 14px; margin-top: 2px;
background: var(--bg); border: 1px solid var(--border-bright);
border-radius: 2px; cursor: pointer; flex: 0 0 14px;
position: relative; transition: .12s;
}
.perm-item input[type="checkbox"]:checked {
background: var(--accent); border-color: var(--accent);
}
.perm-item input[type="checkbox"]:checked::after {
content: ''; position: absolute; left: 3px; top: 0px;
width: 4px; height: 8px;
border: solid #1a0d02; border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.perm-item .perm-body { min-width: 0; }
.perm-item .perm-name {
font-family: 'JetBrains Mono', monospace; font-size: .82rem;
color: var(--text); font-weight: 500; word-break: break-word;
}
.perm-item.checked .perm-name { color: var(--accent); }
.perm-item .perm-desc {
color: var(--text-dim); font-size: .72rem; line-height: 1.35; margin-top: 2px;
}
.role-editor-actions {
display: flex; gap: 10px; margin-top: 22px;
padding-top: 18px; border-top: var(--hair);
}
.role-editor-empty {
background: var(--bg-card); border: 1px dashed var(--border);
border-radius: var(--radius); padding: 40px 28px;
text-align: center; color: var(--text-dim);
font-family: 'JetBrains Mono', monospace; font-size: .85rem;
}
/* ---- Responsive --------------------------------------------------------- */ /* ---- Responsive --------------------------------------------------------- */
@media (max-width: 860px) { @media (max-width: 860px) {
:root { --sidebar-w: 0px; } :root { --sidebar-w: 0px; }

View File

@@ -25,6 +25,7 @@ export default function RoleEditorPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [messageOk, setMessageOk] = useState(true)
const [showCreateForm, setShowCreateForm] = useState(false) const [showCreateForm, setShowCreateForm] = useState(false)
const [newRoleName, setNewRoleName] = useState('') const [newRoleName, setNewRoleName] = useState('')
const [newRoleDesc, setNewRoleDesc] = useState('') const [newRoleDesc, setNewRoleDesc] = useState('')
@@ -32,15 +33,13 @@ export default function RoleEditorPage() {
const isAdmin = user?.is_admin === true const isAdmin = user?.is_admin === true
useEffect(() => { useEffect(() => { fetchData() }, [])
fetchData()
}, [])
const fetchData = async () => { const fetchData = async () => {
try { try {
const [rolesRes, permsRes] = await Promise.all([ const [rolesRes, permsRes] = await Promise.all([
api.get('/roles'), api.get('/roles'),
api.get('/roles/permissions') api.get('/roles/permissions'),
]) ])
setRoles(rolesRes.data) setRoles(rolesRes.data)
setPermissions(permsRes.data) setPermissions(permsRes.data)
@@ -54,63 +53,67 @@ export default function RoleEditorPage() {
const handlePermissionToggle = (permId: number) => { const handlePermissionToggle = (permId: number) => {
if (!selectedRole) return if (!selectedRole) return
const newPermIds = selectedRole.permission_ids.includes(permId) const newPermIds = selectedRole.permission_ids.includes(permId)
? selectedRole.permission_ids.filter(id => id !== permId) ? selectedRole.permission_ids.filter((id) => id !== permId)
: [...selectedRole.permission_ids, permId] : [...selectedRole.permission_ids, permId]
setSelectedRole({ ...selectedRole, permission_ids: newPermIds }) setSelectedRole({ ...selectedRole, permission_ids: newPermIds })
} }
const flashMessage = (msg: string, ok: boolean) => {
setMessage(msg); setMessageOk(ok)
}
const handleSave = async () => { const handleSave = async () => {
if (!selectedRole) return if (!selectedRole) return
setSaving(true) setSaving(true); setMessage('')
setMessage('')
try { try {
await api.post(`/roles/${selectedRole.id}/permissions`, { await api.post(`/roles/${selectedRole.id}/permissions`, {
permission_ids: selectedRole.permission_ids permission_ids: selectedRole.permission_ids,
}) })
setMessage('Saved successfully!') flashMessage('Saved successfully', true)
fetchData() fetchData()
} catch (err: any) { } catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to save') flashMessage(err.response?.data?.detail || 'Failed to save', false)
} finally { } finally {
setSaving(false) setSaving(false)
} }
} }
const handleDeleteRole = async () => { const handleDeleteRole = async () => {
if (!selectedRole || !confirm(`Are you sure you want to delete the "${selectedRole.name}" role?`)) return if (!selectedRole) return
setSaving(true) if (!confirm(`Delete the "${selectedRole.name}" role?`)) return
setMessage('') setSaving(true); setMessage('')
try { try {
await api.delete(`/roles/${selectedRole.id}`) await api.delete(`/roles/${selectedRole.id}`)
setMessage('Role deleted successfully!') flashMessage('Role deleted', true)
setSelectedRole(null) setSelectedRole(null)
fetchData() fetchData()
} catch (err: any) { } catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to delete role') flashMessage(err.response?.data?.detail || 'Failed to delete role', false)
} finally { } finally {
setSaving(false) setSaving(false)
} }
} }
const canDeleteRole = selectedRole && !['admin', 'guest', 'account-manager'].includes(selectedRole.name) && isAdmin const canDeleteRole =
selectedRole &&
!['admin', 'guest', 'account-manager'].includes(selectedRole.name) &&
isAdmin
const handleCreateRole = async () => { const handleCreateRole = async () => {
if (!newRoleName.trim()) return if (!newRoleName.trim()) return
setCreating(true) setCreating(true); setMessage('')
setMessage('')
try { try {
await api.post('/roles', { await api.post('/roles', {
name: newRoleName.trim(), name: newRoleName.trim(),
description: newRoleDesc.trim() || null, description: newRoleDesc.trim() || null,
is_global: false is_global: false,
}) })
setMessage('Role created successfully!') flashMessage('Role created', true)
setShowCreateForm(false) setShowCreateForm(false)
setNewRoleName('') setNewRoleName(''); setNewRoleDesc('')
setNewRoleDesc('')
fetchData() fetchData()
} catch (err: any) { } catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to create role') flashMessage(err.response?.data?.detail || 'Failed to create role', false)
} finally { } finally {
setCreating(false) setCreating(false)
} }
@@ -122,195 +125,127 @@ export default function RoleEditorPage() {
return acc return acc
}, {} as Record<string, Permission[]>) }, {} as Record<string, Permission[]>)
if (loading) return <div className="p-4">Loading...</div> if (loading) return <div className="loading">Loading roles</div>
return ( return (
<div className="role-editor-page" style={{ padding: '20px' }}> <div className="role-editor-page">
<h2>🔐 Role Editor</h2> <h2>🔐 Role Editor</h2>
<p style={{ color: '#888', marginBottom: '20px' }}> <p className="lead">Configure permissions for each role. Only admins can edit roles.</p>
Configure permissions for each role. Only admins can edit roles.
</p>
{isAdmin && !showCreateForm && ( {isAdmin && !showCreateForm && (
<button <button className="btn-primary" onClick={() => setShowCreateForm(true)}
onClick={() => setShowCreateForm(true)} style={{ marginBottom: 18 }}>
className="btn-primary"
style={{ marginBottom: '20px' }}
>
+ Create New Role + Create New Role
</button> </button>
)} )}
{showCreateForm && ( {showCreateForm && (
<div style={{ <div className="role-editor-create">
border: '2px solid #007bff', <h3>Create new role</h3>
borderRadius: '8px', <label>Role name *</label>
padding: '20px', <input
marginBottom: '20px', type="text"
backgroundColor: '#f8f9fa' value={newRoleName}
}}> onChange={(e) => setNewRoleName(e.target.value)}
<h3 style={{ marginTop: 0 }}>Create New Role</h3> placeholder="e.g. developer, manager"
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', maxWidth: '400px' }}> />
<div> <label>Description</label>
<label style={{ display: 'block', marginBottom: '4px', fontWeight: 500 }}> <input
Role Name * type="text"
</label> value={newRoleDesc}
<input onChange={(e) => setNewRoleDesc(e.target.value)}
type="text" placeholder="(optional) short description"
value={newRoleName} />
onChange={(e) => setNewRoleName(e.target.value)} <div className="actions">
placeholder="e.g., developer, manager" <button className="btn-primary" disabled={creating || !newRoleName.trim()}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} onClick={handleCreateRole}>
/> {creating ? 'Creating…' : 'Create'}
</div> </button>
<div> <button className="btn-secondary"
<label style={{ display: 'block', marginBottom: '4px', fontWeight: 500 }}> onClick={() => { setShowCreateForm(false); setNewRoleName(''); setNewRoleDesc('') }}>
Description Cancel
</label> </button>
<input
type="text"
value={newRoleDesc}
onChange={(e) => setNewRoleDesc(e.target.value)}
placeholder="Role description"
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handleCreateRole}
disabled={creating || !newRoleName.trim()}
className="btn-primary"
>
{creating ? 'Creating...' : 'Create'}
</button>
<button
onClick={() => {
setShowCreateForm(false)
setNewRoleName('')
setNewRoleDesc('')
}}
className="btn-secondary"
style={{ padding: '8px 16px', borderRadius: '4px', border: '1px solid #ddd', background: '#fff' }}
>
Cancel
</button>
</div>
</div> </div>
</div> </div>
)} )}
{message && ( {message && (
<div style={{ <div className={`role-editor-banner ${messageOk ? 'ok' : 'err'}`}>
padding: '10px', <span>{messageOk ? '✓' : '✗'}</span>
marginBottom: '20px', <span>{message}</span>
backgroundColor: message.includes('success') ? '#d4edda' : '#f8d7da',
borderRadius: '4px'
}}>
{message}
</div> </div>
)} )}
<div style={{ display: 'flex', gap: '20px' }}> <div className="role-editor-grid">
{/* Role List */} {/* Roles sidebar */}
<div style={{ width: '250px' }}> <aside className="role-editor-sidebar">
<h3>Roles</h3> <h3>Roles</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}> <div className="role-list">
{roles.map(role => ( {roles.map((role) => {
<div const active = selectedRole?.id === role.id
key={role.id} return (
onClick={() => setSelectedRole({ ...role })} <div
style={{ key={role.id}
padding: '12px', className={`role-card${active ? ' active' : ''}`}
border: selectedRole?.id === role.id ? '2px solid #007bff' : '1px solid #ddd', onClick={() => setSelectedRole({ ...role })}
borderRadius: '6px', >
cursor: 'pointer', <div className="role-name">
backgroundColor: selectedRole?.id === role.id ? '#f0f8ff' : 'white' {role.name}
}} {role.is_global && <span className="role-badge-global" title="global"></span>}
> </div>
<strong>{role.name}</strong> {role.description && <div className="role-desc">{role.description}</div>}
{role.is_global && <span style={{ fontSize: '12px', marginLeft: '8px', color: '#ff6b6b' }}>🌟</span>} <div className="role-meta">{role.permission_ids.length} permissions</div>
<div style={{ fontSize: '12px', color: '#666' }}>{role.description}</div>
<div style={{ fontSize: '11px', color: '#999', marginTop: '4px' }}>
{role.permission_ids.length} permissions
</div> </div>
</div> )
))} })}
</div> </div>
</div> </aside>
{/* Permission Editor */} {/* Permission editor */}
<div style={{ flex: 1 }}> <section className="role-editor-detail">
{selectedRole ? ( {selectedRole ? (
<> <>
<h3>Permissions for: {selectedRole.name}</h3> <h3>Permissions for <span className="name">{selectedRole.name}</span></h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}> {Object.entries(groupedPermissions).map(([category, perms]) => (
{Object.entries(groupedPermissions).map(([category, perms]) => ( <div key={category} className="perm-group">
<div key={category} style={{ <div className="perm-group-header">{category}</div>
border: '1px solid #eee', <div className="perm-grid">
borderRadius: '8px', {perms.map((perm) => {
padding: '12px' const checked = selectedRole.permission_ids.includes(perm.id)
}}> return (
<h4 style={{ margin: '0 0 10px 0', textTransform: 'capitalize' }}> <label key={perm.id} className={`perm-item${checked ? ' checked' : ''}`}>
{category}
</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '8px' }}>
{perms.map(perm => (
<label key={perm.id} style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer',
padding: '6px',
borderRadius: '4px',
backgroundColor: selectedRole.permission_ids.includes(perm.id) ? '#e8f5e9' : 'transparent'
}}>
<input <input
type="checkbox" type="checkbox"
checked={selectedRole.permission_ids.includes(perm.id)} checked={checked}
onChange={() => handlePermissionToggle(perm.id)} onChange={() => handlePermissionToggle(perm.id)}
/> />
<div> <div className="perm-body">
<div style={{ fontWeight: 500 }}>{perm.name}</div> <div className="perm-name">{perm.name}</div>
<div style={{ fontSize: '11px', color: '#666' }}>{perm.description}</div> <div className="perm-desc">{perm.description}</div>
</div> </div>
</label> </label>
))} )
</div> })}
</div> </div>
))} </div>
</div> ))}
<button <div className="role-editor-actions">
onClick={handleSave} <button className="btn-primary" disabled={saving} onClick={handleSave}>
disabled={saving} {saving ? 'Saving…' : 'Save changes'}
className="btn-primary"
style={{ marginTop: '20px', marginRight: '10px' }}
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
{canDeleteRole && (
<button
onClick={handleDeleteRole}
disabled={saving}
style={{
marginTop: '20px',
padding: '10px 20px',
borderRadius: '4px',
border: '1px solid #dc3545',
backgroundColor: '#dc3545',
color: 'white',
cursor: 'pointer'
}}
>
Delete Role
</button> </button>
)} {canDeleteRole && (
<button className="btn-danger" disabled={saving} onClick={handleDeleteRole}>
Delete role
</button>
)}
</div>
</> </>
) : ( ) : (
<div style={{ color: '#888', textAlign: 'center', marginTop: '40px' }}> <div className="role-editor-empty">
Select a role to edit its permissions Select a role on the left to edit its permissions
</div> </div>
)} )}
</div> </section>
</div> </div>
</div> </div>
) )