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 @@
@@ -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'