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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
49
migrations/versions/7d27_roles_color.py
Normal file
49
migrations/versions/7d27_roles_color.py
Normal 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')
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user