From a7ff882e21d253e3828a187b41b752462940c61f Mon Sep 17 00:00:00 2001 From: cproudlock Date: Mon, 20 Jul 2026 10:05:58 -0400 Subject: [PATCH] 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. --- frontend/src/views/settings/UsersList.vue | 39 +++++++++++++++++- migrations/versions/7d27_roles_color.py | 49 +++++++++++++++++++++++ shopdb/core/api/users.py | 14 ++++++- shopdb/core/models/user.py | 1 + tests/test_core/test_users_api.py | 20 +++++++++ 5 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 migrations/versions/7d27_roles_color.py diff --git a/frontend/src/views/settings/UsersList.vue b/frontend/src/views/settings/UsersList.vue index f9dc557..66fb6f0 100644 --- a/frontend/src/views/settings/UsersList.vue +++ b/frontend/src/views/settings/UsersList.vue @@ -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 }} @@ -83,7 +84,7 @@ - + {{ role.rolename }} @@ -214,6 +215,11 @@ +
+ + +
+

@@ -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 } diff --git a/migrations/versions/7d27_roles_color.py b/migrations/versions/7d27_roles_color.py new file mode 100644 index 0000000..111afb7 --- /dev/null +++ b/migrations/versions/7d27_roles_color.py @@ -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') diff --git a/shopdb/core/api/users.py b/shopdb/core/api/users.py index e8eab95..42f3d31 100644 --- a/shopdb/core/api/users.py +++ b/shopdb/core/api/users.py @@ -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 } diff --git a/shopdb/core/models/user.py b/shopdb/core/models/user.py index f672c16..7e2b635 100644 --- a/shopdb/core/models/user.py +++ b/shopdb/core/models/user.py @@ -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( diff --git a/tests/test_core/test_users_api.py b/tests/test_core/test_users_api.py index 8158ee1..52964c8 100644 --- a/tests/test_core/test_users_api.py +++ b/tests/test_core/test_users_api.py @@ -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'