Compare commits

...

1 Commits

Author SHA1 Message Date
766474f4e9 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>
2026-05-24 19:25:05 +01:00
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-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 --------------------------------------------------------- */
@media (max-width: 860px) {
:root { --sidebar-w: 0px; }

View File

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