Add per-role badge colors

This commit is contained in:
2026-07-20 10:08:14 -04:00
parent ffe9fa1cce
commit 03c6adc77e
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'