Skip to content

Commit a4d71f8

Browse files
committed
feat: user role management
1 parent 7500a58 commit a4d71f8

File tree

9 files changed

+838
-3
lines changed

9 files changed

+838
-3
lines changed

common/schemas/identity-api.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"type": "array",
1919
"items": {
2020
"type": "string",
21-
"const": "admin"
21+
"enum": ["admin", "media-manager", "viewer", "iot-manager"]
2222
}
2323
},
2424
"IsAuthenticatedAction": {

common/schemas/identity-entities.json

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "array",
66
"items": {
77
"type": "string",
8-
"const": "admin"
8+
"enum": ["admin", "media-manager", "viewer", "iot-manager"]
99
}
1010
},
1111
"User": {
@@ -26,6 +26,63 @@
2626
},
2727
"required": ["username", "roles", "createdAt", "updatedAt"],
2828
"additionalProperties": false
29+
},
30+
"RoleMetadata": {
31+
"type": "object",
32+
"properties": {
33+
"displayName": {
34+
"type": "string",
35+
"description": "Human-readable name for display in UI"
36+
},
37+
"description": {
38+
"type": "string",
39+
"description": "Brief description of what this role allows"
40+
}
41+
},
42+
"required": ["displayName", "description"],
43+
"additionalProperties": false,
44+
"description": "Metadata for a role (displayName, description)"
45+
},
46+
"RoleDefinition": {
47+
"type": "object",
48+
"additionalProperties": false,
49+
"properties": {
50+
"name": {
51+
"type": "string",
52+
"enum": ["admin", "media-manager", "viewer", "iot-manager"],
53+
"description": "Role name (matches values in Roles type)"
54+
},
55+
"displayName": {
56+
"type": "string",
57+
"description": "Human-readable name for display in UI"
58+
},
59+
"description": {
60+
"type": "string",
61+
"description": "Brief description of what this role allows"
62+
}
63+
},
64+
"required": ["description", "displayName", "name"],
65+
"description": "Full role definition including the name"
66+
},
67+
"getRoleDefinition": {
68+
"$comment": "(name: Roles[number]) => RoleDefinition",
69+
"type": "object",
70+
"properties": {
71+
"namedArgs": {
72+
"type": "object",
73+
"properties": {
74+
"name": {
75+
"type": "string",
76+
"enum": ["admin", "media-manager", "viewer", "iot-manager"]
77+
}
78+
},
79+
"required": ["name"],
80+
"additionalProperties": false
81+
}
82+
}
83+
},
84+
"getAllRoleDefinitions": {
85+
"$comment": "() => RoleDefinition[]"
2986
}
3087
}
3188
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './user.js'
2+
export * from './roles.js'
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { Roles } from './user.js'
2+
3+
/**
4+
* Metadata for a role (displayName, description)
5+
*/
6+
export type RoleMetadata = {
7+
/** Human-readable name for display in UI */
8+
displayName: string
9+
/** Brief description of what this role allows */
10+
description: string
11+
}
12+
13+
/**
14+
* Full role definition including the name
15+
*/
16+
export type RoleDefinition = RoleMetadata & {
17+
/** Role name (matches values in Roles type) */
18+
name: Roles[number]
19+
}
20+
21+
/**
22+
* All available roles in the system with their metadata.
23+
* Using Record<Roles[number], ...> ensures TS will error if a role is missing.
24+
*/
25+
export const AVAILABLE_ROLES: Record<Roles[number], RoleMetadata> = {
26+
admin: {
27+
displayName: 'Application Admin',
28+
description: 'Full system access including user management and all settings',
29+
},
30+
'media-manager': {
31+
displayName: 'Media Manager',
32+
description: 'Can manage movies, series, and encoding tasks',
33+
},
34+
viewer: {
35+
displayName: 'Viewer',
36+
description: 'Can browse and watch media content',
37+
},
38+
'iot-manager': {
39+
displayName: 'IoT Manager',
40+
description: 'Can manage IoT devices and their settings',
41+
},
42+
}
43+
44+
/**
45+
* Helper to get full role definition by name
46+
*/
47+
export const getRoleDefinition = (name: Roles[number]): RoleDefinition => ({
48+
name,
49+
...AVAILABLE_ROLES[name],
50+
})
51+
52+
/**
53+
* Get all role definitions as an array (useful for dropdowns)
54+
*/
55+
export const getAllRoleDefinitions = (): RoleDefinition[] =>
56+
(Object.keys(AVAILABLE_ROLES) as Array<Roles[number]>).map(getRoleDefinition)

common/src/models/identity/user.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type Roles = Array<'admin'>
1+
export type Roles = Array<'admin' | 'media-manager' | 'viewer' | 'iot-manager'>
22

33
export class User {
44
public username!: string
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { createComponent, Shade } from '@furystack/shades'
2+
import type { Roles } from 'common'
3+
import { getRoleDefinition } from 'common'
4+
5+
type RoleTagProps = {
6+
roleName: Roles[number]
7+
variant: 'default' | 'added' | 'removed'
8+
onRemove?: () => void
9+
onRestore?: () => void
10+
}
11+
12+
export const RoleTag = Shade<RoleTagProps>({
13+
shadowDomName: 'role-tag',
14+
render: ({ props }) => {
15+
const role = getRoleDefinition(props.roleName)
16+
const baseStyle: Partial<CSSStyleDeclaration> = {
17+
display: 'inline-flex',
18+
alignItems: 'center',
19+
gap: '6px',
20+
padding: '4px 12px',
21+
borderRadius: '16px',
22+
fontSize: '13px',
23+
fontWeight: '500',
24+
transition: 'all 0.2s ease',
25+
}
26+
27+
const variantStyles: Record<RoleTagProps['variant'], Partial<CSSStyleDeclaration>> = {
28+
default: {
29+
backgroundColor: 'var(--theme-background-paper)',
30+
border: '1px solid var(--theme-border-default)',
31+
color: 'var(--theme-text-primary)',
32+
},
33+
added: {
34+
backgroundColor: 'rgba(76, 175, 80, 0.15)',
35+
border: '1px solid var(--theme-success-main, #4caf50)',
36+
color: 'var(--theme-success-dark, #2e7d32)',
37+
},
38+
removed: {
39+
backgroundColor: 'rgba(244, 67, 54, 0.15)',
40+
border: '1px solid var(--theme-error-main, #f44336)',
41+
color: 'var(--theme-error-dark, #c62828)',
42+
textDecoration: 'line-through',
43+
},
44+
}
45+
46+
const buttonBaseStyle: Partial<CSSStyleDeclaration> = {
47+
background: 'none',
48+
border: 'none',
49+
cursor: 'pointer',
50+
padding: '0',
51+
margin: '0',
52+
marginLeft: '4px',
53+
fontSize: '14px',
54+
lineHeight: '1',
55+
opacity: '0.7',
56+
transition: 'opacity 0.2s ease',
57+
}
58+
59+
const style = { ...baseStyle, ...variantStyles[props.variant] }
60+
61+
return (
62+
<span style={style} title={role.description}>
63+
{role.displayName}
64+
{props.variant === 'removed' && props.onRestore && (
65+
<button
66+
type="button"
67+
onclick={(e) => {
68+
e.stopPropagation()
69+
props.onRestore?.()
70+
}}
71+
title="Restore role"
72+
style={{
73+
...buttonBaseStyle,
74+
color: 'var(--theme-error-dark, #c62828)',
75+
}}
76+
onmouseenter={(e) => {
77+
;(e.target as HTMLButtonElement).style.opacity = '1'
78+
}}
79+
onmouseleave={(e) => {
80+
;(e.target as HTMLButtonElement).style.opacity = '0.7'
81+
}}
82+
>
83+
84+
</button>
85+
)}
86+
{props.variant !== 'removed' && props.onRemove && (
87+
<button
88+
type="button"
89+
onclick={(e) => {
90+
e.stopPropagation()
91+
props.onRemove?.()
92+
}}
93+
title="Remove role"
94+
style={{
95+
...buttonBaseStyle,
96+
color: props.variant === 'added' ? 'var(--theme-success-dark, #2e7d32)' : 'var(--theme-text-secondary)',
97+
}}
98+
onmouseenter={(e) => {
99+
;(e.target as HTMLButtonElement).style.opacity = '1'
100+
}}
101+
onmouseleave={(e) => {
102+
;(e.target as HTMLButtonElement).style.opacity = '0.7'
103+
}}
104+
>
105+
×
106+
</button>
107+
)}
108+
</span>
109+
)
110+
},
111+
})

frontend/src/pages/admin/app-settings.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,28 @@ const settingsRoutes = [
4747
/>
4848
),
4949
},
50+
{
51+
url: '/app-settings/users/:username',
52+
component: () => (
53+
<PiRatLazyLoad
54+
component={async () => {
55+
const { UserDetailsPage } = await import('./user-details.js')
56+
return <UserDetailsPage />
57+
}}
58+
/>
59+
),
60+
},
61+
{
62+
url: '/app-settings/users',
63+
component: () => (
64+
<PiRatLazyLoad
65+
component={async () => {
66+
const { UserListPage } = await import('./user-list.js')
67+
return <UserListPage />
68+
}}
69+
/>
70+
),
71+
},
5072
{
5173
url: '/app-settings',
5274
component: () => (
@@ -103,6 +125,9 @@ export const AppSettingsPage = Shade({
103125
<SettingsMenuSection title="AI">
104126
<SettingsMenuItem icon="🤖" label="Ollama Settings" href="/app-settings/ai" />
105127
</SettingsMenuSection>
128+
<SettingsMenuSection title="Identity">
129+
<SettingsMenuItem icon="👥" label="Users" href="/app-settings/users" />
130+
</SettingsMenuSection>
106131
</SettingsSidebar>
107132

108133
<div

0 commit comments

Comments
 (0)