Add per-role badge colors

Role badges rendered gray for everything except admin, with no way to tell
roles apart. Add an optional color per role, matching how statuses and types
carry one: new roles.color column (migration 7d27_roles_color), color threaded
through the role API and the user serializer, and a ColorSwatchPicker in the
role editor. Badges use the role's color with contrast-aware text and fall
back to the old admin/gray classes when unset.
This commit is contained in:
cproudlock
2026-07-20 10:05:58 -04:00
parent 0b247ed96f
commit a7ff882e21
5 changed files with 119 additions and 4 deletions

View File

@@ -34,7 +34,8 @@
v-for="role in user.roles"
:key="role.roleid"
class="badge"
:class="role.rolename === 'admin' ? 'badge-primary' : 'badge-secondary'"
:class="roleBadgeClass(role)"
:style="roleBadgeStyle(role)"
>
{{ role.rolename }}
</span>
@@ -83,7 +84,7 @@
<tbody>
<tr v-for="role in roles" :key="role.roleid">
<td>
<span class="badge" :class="role.rolename === 'admin' ? 'badge-primary' : 'badge-secondary'">
<span class="badge" :class="roleBadgeClass(role)" :style="roleBadgeStyle(role)">
{{ role.rolename }}
</span>
</td>
@@ -214,6 +215,11 @@
</div>
</div>
<div class="form-group">
<label>Badge color</label>
<ColorSwatchPicker v-model="roleForm.color" />
</div>
<div class="form-group" v-if="!editingRole?.isadmin">
<label>Permissions</label>
<p class="text-muted" style="font-size: 0.85rem; margin: 0 0 0.5rem 0">
@@ -289,6 +295,9 @@ import { ref, reactive, onMounted } from 'vue'
import { usersApi } from '../../api'
import { useAuthStore } from '../../stores/auth'
import { apiError } from '../../utils/apiError'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
const DEFAULT_ROLE_COLOR = '#0d6efd'
const authStore = useAuthStore()
const currentUserId = authStore.user?.userid
@@ -323,9 +332,32 @@ const userForm = reactive({
const roleForm = reactive({
rolename: '',
description: '',
color: DEFAULT_ROLE_COLOR,
permissions: []
})
// Role badge: use the role's own color when set (contrast-aware text), else
// fall back to the legacy admin=primary / other=gray classes.
function isLightColor(color) {
if (!color) return false
const hex = color.replace('#', '')
if (hex.length !== 6) return false
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
return (r * 299 + g * 587 + b * 114) / 1000 > 155
}
function roleBadgeClass(role) {
if (role.color) return ''
return role.rolename === 'admin' ? 'badge-primary' : 'badge-secondary'
}
function roleBadgeStyle(role) {
if (!role.color) return null
return { background: role.color, color: isLightColor(role.color) ? '#000' : '#fff' }
}
async function loadData() {
loading.value = true
try {
@@ -459,6 +491,7 @@ function editRole(role) {
editingRole.value = role
roleForm.rolename = role.rolename
roleForm.description = role.description || ''
roleForm.color = role.color || DEFAULT_ROLE_COLOR
roleForm.permissions = role.permissions ? [...role.permissions] : []
}
@@ -467,6 +500,7 @@ function closeRoleModal() {
editingRole.value = null
roleForm.rolename = ''
roleForm.description = ''
roleForm.color = DEFAULT_ROLE_COLOR
roleForm.permissions = []
}
@@ -476,6 +510,7 @@ async function saveRole() {
try {
const data = {
description: roleForm.description,
color: roleForm.color,
permissions: roleForm.permissions
}

View File

@@ -0,0 +1,49 @@
"""Add roles.color for per-role badge colors
Roles rendered as gray badges everywhere except admin (hardcoded). Give each
role an optional CSS color, matching how statuses/types carry a color column,
so role badges are visually distinct and admin-editable.
Idempotent guard on column presence; real downgrade drops the column.
Revision ID: 7d27_roles_color
Revises: 7d26_settings_description_text
Create Date: 2026-07-20
"""
from alembic import op
import sqlalchemy as sa
revision = '7d27_roles_color'
down_revision = '7d26_settings_description_text'
branch_labels = None
depends_on = None
def _has_color(insp):
return any(c['name'] == 'color' for c in insp.get_columns('roles'))
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'roles' not in insp.get_table_names():
return
if _has_color(insp):
return
op.add_column('roles', sa.Column('color', sa.String(length=20), nullable=True))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'roles' not in insp.get_table_names():
return
if not _has_color(insp):
return
op.drop_column('roles', 'color')

View File

@@ -270,6 +270,7 @@ def list_roles():
'roleid': r.roleid,
'rolename': r.rolename,
'description': r.description,
'color': r.color,
'usercount': r.users.count(),
'permissions': [p.name for p in r.permissions],
'isadmin': r.rolename == 'admin'
@@ -290,7 +291,8 @@ def create_role():
role = Role(
rolename=data['rolename'],
description=data.get('description')
description=data.get('description'),
color=data.get('color')
)
# Assign permissions
@@ -308,6 +310,7 @@ def create_role():
'roleid': role.roleid,
'rolename': role.rolename,
'description': role.description,
'color': role.color,
'permissions': [p.name for p in role.permissions]
}, message='Role created', http_code=201)
@@ -333,6 +336,12 @@ def update_role(roleid: int):
changes['description'] = {'old': role.description, 'new': data['description']}
role.description = data['description']
# Color is cosmetic - editable on any role, including admin
if 'color' in data:
if data['color'] != role.color:
changes['color'] = {'old': role.color, 'new': data['color']}
role.color = data['color']
# Update permissions
if 'permissions' in data and role.rolename != 'admin':
old_perms = [p.name for p in role.permissions]
@@ -351,6 +360,7 @@ def update_role(roleid: int):
'roleid': role.roleid,
'rolename': role.rolename,
'description': role.description,
'color': role.color,
'permissions': [p.name for p in role.permissions]
}, message='Role updated')
@@ -393,7 +403,7 @@ def user_to_dict(user: User) -> dict:
'mustchangepassword': bool(user.mustchangepassword),
'lastlogindate': user.lastlogindate.isoformat() + 'Z' if user.lastlogindate else None,
'failedlogins': user.failedlogins,
'roles': [{'roleid': r.roleid, 'rolename': r.rolename} for r in user.roles],
'roles': [{'roleid': r.roleid, 'rolename': r.rolename, 'color': r.color} for r in user.roles],
'createddate': user.createddate.isoformat() + 'Z' if user.createddate else None,
'modifieddate': user.modifieddate.isoformat() + 'Z' if user.modifieddate else None
}

View File

@@ -156,6 +156,7 @@ class Role(BaseModel):
roleid = db.Column(db.Integer, primary_key=True)
rolename = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.Text)
color = db.Column(db.String(20), comment='CSS color for the role badge')
# Permissions relationship
permissions = db.relationship(

View File

@@ -27,3 +27,23 @@ def test_delete_user_with_tokens_and_audit_history(client, auth_headers, app, db
assert ApiToken.query.filter_by(userid=userid).count() == 0
detached = AuditLog.query.filter_by(entityname='imported thing').one()
assert detached.userid is None
def test_role_color_create_list_update(client, auth_headers):
"""A role carries an optional badge color through create, list, and update."""
created = client.post('/api/users/roles', headers=auth_headers, json={
'rolename': 'painters', 'description': 'colorful', 'color': '#ff8800',
'permissions': [],
})
assert created.status_code == 201, created.get_json()
roleid = created.get_json()['data']['roleid']
assert created.get_json()['data']['color'] == '#ff8800'
listed = client.get('/api/users/roles', headers=auth_headers)
row = next(r for r in listed.get_json()['data'] if r['roleid'] == roleid)
assert row['color'] == '#ff8800'
updated = client.put(f'/api/users/roles/{roleid}', headers=auth_headers,
json={'color': '#00aa55'})
assert updated.status_code == 200, updated.get_json()
assert updated.get_json()['data']['color'] == '#00aa55'