Add per-role badge colors
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