Add per-role badge colors
This commit is contained in:
@@ -34,7 +34,8 @@
|
|||||||
v-for="role in user.roles"
|
v-for="role in user.roles"
|
||||||
:key="role.roleid"
|
:key="role.roleid"
|
||||||
class="badge"
|
class="badge"
|
||||||
:class="role.rolename === 'admin' ? 'badge-primary' : 'badge-secondary'"
|
:class="roleBadgeClass(role)"
|
||||||
|
:style="roleBadgeStyle(role)"
|
||||||
>
|
>
|
||||||
{{ role.rolename }}
|
{{ role.rolename }}
|
||||||
</span>
|
</span>
|
||||||
@@ -83,7 +84,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="role in roles" :key="role.roleid">
|
<tr v-for="role in roles" :key="role.roleid">
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" :class="role.rolename === 'admin' ? 'badge-primary' : 'badge-secondary'">
|
<span class="badge" :class="roleBadgeClass(role)" :style="roleBadgeStyle(role)">
|
||||||
{{ role.rolename }}
|
{{ role.rolename }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -214,6 +215,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Badge color</label>
|
||||||
|
<ColorSwatchPicker v-model="roleForm.color" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group" v-if="!editingRole?.isadmin">
|
<div class="form-group" v-if="!editingRole?.isadmin">
|
||||||
<label>Permissions</label>
|
<label>Permissions</label>
|
||||||
<p class="text-muted" style="font-size: 0.85rem; margin: 0 0 0.5rem 0">
|
<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 { usersApi } from '../../api'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import { apiError } from '../../utils/apiError'
|
import { apiError } from '../../utils/apiError'
|
||||||
|
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||||
|
|
||||||
|
const DEFAULT_ROLE_COLOR = '#0d6efd'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const currentUserId = authStore.user?.userid
|
const currentUserId = authStore.user?.userid
|
||||||
@@ -323,9 +332,32 @@ const userForm = reactive({
|
|||||||
const roleForm = reactive({
|
const roleForm = reactive({
|
||||||
rolename: '',
|
rolename: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
color: DEFAULT_ROLE_COLOR,
|
||||||
permissions: []
|
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() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -459,6 +491,7 @@ function editRole(role) {
|
|||||||
editingRole.value = role
|
editingRole.value = role
|
||||||
roleForm.rolename = role.rolename
|
roleForm.rolename = role.rolename
|
||||||
roleForm.description = role.description || ''
|
roleForm.description = role.description || ''
|
||||||
|
roleForm.color = role.color || DEFAULT_ROLE_COLOR
|
||||||
roleForm.permissions = role.permissions ? [...role.permissions] : []
|
roleForm.permissions = role.permissions ? [...role.permissions] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,6 +500,7 @@ function closeRoleModal() {
|
|||||||
editingRole.value = null
|
editingRole.value = null
|
||||||
roleForm.rolename = ''
|
roleForm.rolename = ''
|
||||||
roleForm.description = ''
|
roleForm.description = ''
|
||||||
|
roleForm.color = DEFAULT_ROLE_COLOR
|
||||||
roleForm.permissions = []
|
roleForm.permissions = []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,6 +510,7 @@ async function saveRole() {
|
|||||||
try {
|
try {
|
||||||
const data = {
|
const data = {
|
||||||
description: roleForm.description,
|
description: roleForm.description,
|
||||||
|
color: roleForm.color,
|
||||||
permissions: roleForm.permissions
|
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,
|
'roleid': r.roleid,
|
||||||
'rolename': r.rolename,
|
'rolename': r.rolename,
|
||||||
'description': r.description,
|
'description': r.description,
|
||||||
|
'color': r.color,
|
||||||
'usercount': r.users.count(),
|
'usercount': r.users.count(),
|
||||||
'permissions': [p.name for p in r.permissions],
|
'permissions': [p.name for p in r.permissions],
|
||||||
'isadmin': r.rolename == 'admin'
|
'isadmin': r.rolename == 'admin'
|
||||||
@@ -290,7 +291,8 @@ def create_role():
|
|||||||
|
|
||||||
role = Role(
|
role = Role(
|
||||||
rolename=data['rolename'],
|
rolename=data['rolename'],
|
||||||
description=data.get('description')
|
description=data.get('description'),
|
||||||
|
color=data.get('color')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assign permissions
|
# Assign permissions
|
||||||
@@ -308,6 +310,7 @@ def create_role():
|
|||||||
'roleid': role.roleid,
|
'roleid': role.roleid,
|
||||||
'rolename': role.rolename,
|
'rolename': role.rolename,
|
||||||
'description': role.description,
|
'description': role.description,
|
||||||
|
'color': role.color,
|
||||||
'permissions': [p.name for p in role.permissions]
|
'permissions': [p.name for p in role.permissions]
|
||||||
}, message='Role created', http_code=201)
|
}, message='Role created', http_code=201)
|
||||||
|
|
||||||
@@ -333,6 +336,12 @@ def update_role(roleid: int):
|
|||||||
changes['description'] = {'old': role.description, 'new': data['description']}
|
changes['description'] = {'old': role.description, 'new': data['description']}
|
||||||
role.description = 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
|
# Update permissions
|
||||||
if 'permissions' in data and role.rolename != 'admin':
|
if 'permissions' in data and role.rolename != 'admin':
|
||||||
old_perms = [p.name for p in role.permissions]
|
old_perms = [p.name for p in role.permissions]
|
||||||
@@ -351,6 +360,7 @@ def update_role(roleid: int):
|
|||||||
'roleid': role.roleid,
|
'roleid': role.roleid,
|
||||||
'rolename': role.rolename,
|
'rolename': role.rolename,
|
||||||
'description': role.description,
|
'description': role.description,
|
||||||
|
'color': role.color,
|
||||||
'permissions': [p.name for p in role.permissions]
|
'permissions': [p.name for p in role.permissions]
|
||||||
}, message='Role updated')
|
}, message='Role updated')
|
||||||
|
|
||||||
@@ -393,7 +403,7 @@ def user_to_dict(user: User) -> dict:
|
|||||||
'mustchangepassword': bool(user.mustchangepassword),
|
'mustchangepassword': bool(user.mustchangepassword),
|
||||||
'lastlogindate': user.lastlogindate.isoformat() + 'Z' if user.lastlogindate else None,
|
'lastlogindate': user.lastlogindate.isoformat() + 'Z' if user.lastlogindate else None,
|
||||||
'failedlogins': user.failedlogins,
|
'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,
|
'createddate': user.createddate.isoformat() + 'Z' if user.createddate else None,
|
||||||
'modifieddate': user.modifieddate.isoformat() + 'Z' if user.modifieddate 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)
|
roleid = db.Column(db.Integer, primary_key=True)
|
||||||
rolename = db.Column(db.String(50), unique=True, nullable=False)
|
rolename = db.Column(db.String(50), unique=True, nullable=False)
|
||||||
description = db.Column(db.Text)
|
description = db.Column(db.Text)
|
||||||
|
color = db.Column(db.String(20), comment='CSS color for the role badge')
|
||||||
|
|
||||||
# Permissions relationship
|
# Permissions relationship
|
||||||
permissions = db.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
|
assert ApiToken.query.filter_by(userid=userid).count() == 0
|
||||||
detached = AuditLog.query.filter_by(entityname='imported thing').one()
|
detached = AuditLog.query.filter_by(entityname='imported thing').one()
|
||||||
assert detached.userid is None
|
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