diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..42e62935 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "prisma.pinToPrisma6": true +} \ No newline at end of file diff --git a/admin/package.json b/admin/package.json index b78bc01f..f9b2f036 100644 --- a/admin/package.json +++ b/admin/package.json @@ -21,7 +21,7 @@ "@tanstack/vue-table": "^8.21.3", "@unovis/ts": "^1.6.4", "@unovis/vue": "^1.6.4", - "@vueuse/core": "^14.2.1", + "@vueuse/core": "^14.3.0", "axios": "^1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/admin/slices/common/components/date/TimeAgo.vue b/admin/slices/common/components/date/TimeAgo.vue new file mode 100644 index 00000000..560c6745 --- /dev/null +++ b/admin/slices/common/components/date/TimeAgo.vue @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/admin/slices/common/components/date/TimeAt.vue b/admin/slices/common/components/date/TimeAt.vue new file mode 100644 index 00000000..9bfce14e --- /dev/null +++ b/admin/slices/common/components/date/TimeAt.vue @@ -0,0 +1,12 @@ + + + diff --git a/admin/slices/common/utils/formatDate.ts b/admin/slices/common/utils/formatDate.ts new file mode 100644 index 00000000..10694400 --- /dev/null +++ b/admin/slices/common/utils/formatDate.ts @@ -0,0 +1,4 @@ +export const formatDateTime = (date?: string) => { + if (!date) return ''; + return new Date(date).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' }); +}; \ No newline at end of file diff --git a/admin/slices/common/utils/index.ts b/admin/slices/common/utils/index.ts new file mode 100644 index 00000000..60c9bc22 --- /dev/null +++ b/admin/slices/common/utils/index.ts @@ -0,0 +1 @@ +export * from './formatDate'; \ No newline at end of file diff --git a/admin/slices/setup/api/data/repositories/api/schemas.gen.ts b/admin/slices/setup/api/data/repositories/api/schemas.gen.ts index ff51c746..76385a04 100644 --- a/admin/slices/setup/api/data/repositories/api/schemas.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/schemas.gen.ts @@ -1552,6 +1552,11 @@ export const SetAgentChannelsDtoSchema = { required: ["channels"], } as const; +export const AssignableUserRoleTypesSchema = { + type: "string", + enum: ["Admin", "User"], +} as const; + export const CreateUserDtoSchema = { type: "object", properties: { @@ -1568,12 +1573,13 @@ export const CreateUserDtoSchema = { example: "strongPassword1", minLength: 8, }, - roles: { - type: "array", - example: ["User"], - items: { - $ref: "#/components/schemas/UserRoleTypes", - }, + role: { + example: "User", + allOf: [ + { + $ref: "#/components/schemas/AssignableUserRoleTypes", + }, + ], }, }, required: ["name", "email", "password"], @@ -1602,18 +1608,19 @@ export const UpdateUserDtoSchema = { }, } as const; -export const UpdateUserRolesDtoSchema = { +export const UpdateUserRoleDtoSchema = { type: "object", properties: { - roles: { - type: "array", - example: ["User"], - items: { - $ref: "#/components/schemas/UserRoleTypes", - }, + role: { + example: "User", + allOf: [ + { + $ref: "#/components/schemas/AssignableUserRoleTypes", + }, + ], }, }, - required: ["roles"], + required: ["role"], } as const; export const SaveTemplateFileDtoSchema = { diff --git a/admin/slices/setup/api/data/repositories/api/sdk.gen.ts b/admin/slices/setup/api/data/repositories/api/sdk.gen.ts index 66301824..0a0d8a01 100644 --- a/admin/slices/setup/api/data/repositories/api/sdk.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/sdk.gen.ts @@ -156,7 +156,7 @@ import type { UserControllerRemoveData, UserControllerFindByIdData, UserControllerUpdateData, - UserControllerUpdateRolesData, + UserControllerUpdateRoleData, TemplateFileControllerListData, TemplateFileControllerReadData, TemplateFileControllerSaveData, @@ -2300,7 +2300,7 @@ export class UsersService { } /** - * Invite a new user + * Create a user (admin sets the password) */ public static userControllerCreate( options: Options, @@ -2352,7 +2352,7 @@ export class UsersService { } /** - * Update user (name, email, password, status). Use /roles to change roles. + * Update user (name, email, password, status). Use /role to change the role. */ public static userControllerUpdate( options: Options, @@ -2372,17 +2372,17 @@ export class UsersService { } /** - * Replace the user's roles. Owner only. + * Set the user's role. Owner only. */ - public static userControllerUpdateRoles( - options: Options, + public static userControllerUpdateRole( + options: Options, ) { return (options.client ?? _heyApiClient).put< unknown, unknown, ThrowOnError >({ - url: "/users/{id}/roles", + url: "/users/{id}/role", ...options, headers: { "Content-Type": "application/json", diff --git a/admin/slices/setup/api/data/repositories/api/types.gen.ts b/admin/slices/setup/api/data/repositories/api/types.gen.ts index e4d193e6..fefc72e0 100644 --- a/admin/slices/setup/api/data/repositories/api/types.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/types.gen.ts @@ -718,11 +718,16 @@ export type SetAgentChannelsDto = { channels: Array; }; +export enum AssignableUserRoleTypes { + ADMIN = "Admin", + USER = "User", +} + export type CreateUserDto = { name: string; email: string; password: string; - roles?: Array; + role?: AssignableUserRoleTypes; }; export type UpdateUserDto = { @@ -732,8 +737,8 @@ export type UpdateUserDto = { status?: "active" | "invited" | "disabled"; }; -export type UpdateUserRolesDto = { - roles: Array; +export type UpdateUserRoleDto = { + role: AssignableUserRoleTypes; }; export type SaveTemplateFileDto = { @@ -2914,16 +2919,16 @@ export type UserControllerUpdateResponses = { 200: unknown; }; -export type UserControllerUpdateRolesData = { - body: UpdateUserRolesDto; +export type UserControllerUpdateRoleData = { + body: UpdateUserRoleDto; path: { id: string; }; query?: never; - url: "/users/{id}/roles"; + url: "/users/{id}/role"; }; -export type UserControllerUpdateRolesResponses = { +export type UserControllerUpdateRoleResponses = { 200: unknown; }; diff --git a/admin/slices/setup/theme/components/ui/radio-group/RadioGroup.vue b/admin/slices/setup/theme/components/ui/radio-group/RadioGroup.vue new file mode 100644 index 00000000..6f6c27a4 --- /dev/null +++ b/admin/slices/setup/theme/components/ui/radio-group/RadioGroup.vue @@ -0,0 +1,24 @@ + + + diff --git a/admin/slices/setup/theme/components/ui/radio-group/RadioGroupItem.vue b/admin/slices/setup/theme/components/ui/radio-group/RadioGroupItem.vue new file mode 100644 index 00000000..31544ecf --- /dev/null +++ b/admin/slices/setup/theme/components/ui/radio-group/RadioGroupItem.vue @@ -0,0 +1,31 @@ + + + diff --git a/admin/slices/setup/theme/components/ui/radio-group/index.ts b/admin/slices/setup/theme/components/ui/radio-group/index.ts new file mode 100644 index 00000000..7cb0e3b1 --- /dev/null +++ b/admin/slices/setup/theme/components/ui/radio-group/index.ts @@ -0,0 +1,2 @@ +export { default as RadioGroup } from "./RadioGroup.vue" +export { default as RadioGroupItem } from "./RadioGroupItem.vue" diff --git a/admin/slices/user/auth/data/auth.mapper.ts b/admin/slices/user/auth/data/auth.mapper.ts index 974b847f..73a650d9 100644 --- a/admin/slices/user/auth/data/auth.mapper.ts +++ b/admin/slices/user/auth/data/auth.mapper.ts @@ -4,7 +4,7 @@ const EMPTY_USER: IAuthUser = { id: '', name: '', email: '', - roles: [], + role: '', status: '', }; @@ -22,9 +22,7 @@ export class AuthMapper { id: o.id, name: str(o.name), email: str(o.email), - roles: Array.isArray(o.roles) - ? o.roles.filter((r): r is string => typeof r === 'string') - : [], + role: str(o.role), status: str(o.status), }; } diff --git a/admin/slices/user/auth/domain/auth.types.ts b/admin/slices/user/auth/domain/auth.types.ts index 08046456..15b7146b 100644 --- a/admin/slices/user/auth/domain/auth.types.ts +++ b/admin/slices/user/auth/domain/auth.types.ts @@ -4,7 +4,7 @@ export interface IAuthUser { id: string; name: string; email: string; - roles: string[]; + role: string; status: string; } diff --git a/admin/slices/user/auth/stores/auth.ts b/admin/slices/user/auth/stores/auth.ts index 45477fe7..3b077772 100644 --- a/admin/slices/user/auth/stores/auth.ts +++ b/admin/slices/user/auth/stores/auth.ts @@ -22,9 +22,7 @@ export const useAuthStore = defineStore('auth', () => { const isAuthenticated = computed(() => !!accessToken.value); const hasAdminAccess = computed(() => - !!user.value?.roles?.some((role) => - (ADMIN_ROLES as readonly string[]).includes(role), - ), + (ADMIN_ROLES as readonly string[]).includes(user.value?.role ?? ''), ); function applyToken(token: string | null) { diff --git a/admin/slices/user/user/components/userCreate/Provider.vue b/admin/slices/user/user/components/user/create/Provider.vue similarity index 84% rename from admin/slices/user/user/components/userCreate/Provider.vue rename to admin/slices/user/user/components/user/create/Provider.vue index 226afee0..2d985c7d 100644 --- a/admin/slices/user/user/components/userCreate/Provider.vue +++ b/admin/slices/user/user/components/user/create/Provider.vue @@ -1,5 +1,5 @@ @@ -99,13 +70,13 @@ function onSubmit() {
- {{ cardTitle ?? (mode === 'edit' ? 'Profile' : 'Invite') }} + {{ cardTitle ?? (mode === 'edit' ? 'Profile' : 'New user') }} {{ cardDescription ?? (mode === 'edit' ? 'Update the user\'s profile.' - : 'An invitation email will be sent to the address below.') + : 'Set a password and share it with the user — no email is sent.') }} @@ -132,29 +103,32 @@ function onSubmit() { />

{{ errors.password }}

-
- -

- A user can hold any combination. Owner can change other users' roles. -

-
- -
-

{{ errors.roles }}

+
+ + +
@@ -163,8 +137,8 @@ function onSubmit() { diff --git a/admin/slices/user/user/components/user/Provider.vue b/admin/slices/user/user/components/user/item/Provider.vue similarity index 76% rename from admin/slices/user/user/components/user/Provider.vue rename to admin/slices/user/user/components/user/item/Provider.vue index 6eb51be1..c160838b 100644 --- a/admin/slices/user/user/components/user/Provider.vue +++ b/admin/slices/user/user/components/user/item/Provider.vue @@ -1,16 +1,6 @@ diff --git a/admin/slices/user/user/pages/users/index.vue b/admin/slices/user/user/pages/users/index.vue index 5d89c30f..e7e9eae3 100644 --- a/admin/slices/user/user/pages/users/index.vue +++ b/admin/slices/user/user/pages/users/index.vue @@ -1,5 +1,3 @@ diff --git a/admin/slices/user/user/stores/user.ts b/admin/slices/user/user/stores/user.ts index 730a020b..d86f42e3 100644 --- a/admin/slices/user/user/stores/user.ts +++ b/admin/slices/user/user/stores/user.ts @@ -7,12 +7,6 @@ import type { UserService, } from '#user/domain'; -// Re-export the domain enums/types so consumers importing them from -// `#user/stores/user` (user Form/Provider, userList/Create/Edit) keep working. -// The enums are used as runtime values, so they're value re-exports. -export { ALL_USER_ROLES, UserRoleTypes, UserStatusTypes } from '#user/domain'; -export type { ICreateUserData, IUpdateUserData, IUserData } from '#user/domain'; - const getService = createServiceGetter('$userService'); export const useUserStore = defineStore('user', () => { @@ -39,8 +33,8 @@ export const useUserStore = defineStore('user', () => { return updated; } - async function updateRoles(id: string, roles: UserRoleTypes[]) { - const updated = await getService().updateRoles(id, roles); + async function updateRole(id: string, role: UserRoleTypes) { + const updated = await getService().updateRole(id, role); users.value = users.value.map((u) => (u.id === id ? updated : u)); return updated; } @@ -50,5 +44,5 @@ export const useUserStore = defineStore('user', () => { users.value = users.value.filter((u) => u.id !== id); } - return { users, fetchAll, fetchById, create, update, updateRoles, remove }; + return { users, fetchAll, fetchById, create, update, updateRole, remove }; }); diff --git a/api/prisma/migrations/20260724131635_user_single_role/migration.sql b/api/prisma/migrations/20260724131635_user_single_role/migration.sql new file mode 100644 index 00000000..a996919e --- /dev/null +++ b/api/prisma/migrations/20260724131635_user_single_role/migration.sql @@ -0,0 +1,12 @@ +-- Collapse the multi-role array to a single hierarchical role. +-- Highest role wins: Owner > Admin > User. Legacy lowercase values +-- (pre-20260430 single-role column) are folded in defensively. +ALTER TABLE "User" ADD COLUMN "role" TEXT NOT NULL DEFAULT 'User'; + +UPDATE "User" SET "role" = CASE + WHEN 'Owner' = ANY("roles") OR 'owner' = ANY("roles") THEN 'Owner' + WHEN 'Admin' = ANY("roles") OR 'admin' = ANY("roles") THEN 'Admin' + ELSE 'User' +END; + +ALTER TABLE "User" DROP COLUMN "roles"; diff --git a/api/src/slices/setup/init/domain/init.service.ts b/api/src/slices/setup/init/domain/init.service.ts index 03232317..6add410c 100644 --- a/api/src/slices/setup/init/domain/init.service.ts +++ b/api/src/slices/setup/init/domain/init.service.ts @@ -18,7 +18,7 @@ export class InitService { async getStatus(): Promise<{ requiresInit: boolean }> { const ownerCount = await this.prisma.user.count({ - where: { roles: { has: UserRoleTypes.Owner } }, + where: { role: UserRoleTypes.Owner }, }); return { requiresInit: ownerCount === 0 }; } @@ -38,7 +38,7 @@ export class InitService { name, email: email.toLowerCase(), password: await bcrypt.hash(password, BCRYPT_ROUNDS), - roles: [UserRoleTypes.Owner], + role: UserRoleTypes.Owner, status: 'active', }, }); @@ -47,7 +47,7 @@ export class InitService { const payload: IAuthTokenPayload = { sub: user.id, email: user.email, - roles: user.roles, + roles: [user.role], }; return { diff --git a/api/src/slices/user/auth/domain/auth.service.ts b/api/src/slices/user/auth/domain/auth.service.ts index 1f87ca98..61e21e78 100644 --- a/api/src/slices/user/auth/domain/auth.service.ts +++ b/api/src/slices/user/auth/domain/auth.service.ts @@ -58,7 +58,17 @@ export class AuthService { throw new UnauthorizedException('Account disabled'); } - return this.issueToken(this.mapper.toEntity(record)); + // Invited accounts are activated by their first successful sign-in — + // there is no separate accept-invite flow. + let current = record; + if (current.status === 'invited') { + current = await this.prisma.user.update({ + where: { id: current.id }, + data: { status: 'active' }, + }); + } + + return this.issueToken(this.mapper.toEntity(current)); } async register( @@ -86,7 +96,7 @@ export class AuthService { name, email: normalizedEmail, password: hashed, - roles: [UserRoleTypes.User], + role: UserRoleTypes.User, }), status: 'active', }, @@ -108,7 +118,7 @@ export class AuthService { const payload: IAuthTokenPayload = { sub: user.id, email: user.email, - roles: user.roles, + roles: [user.role], }; return { accessToken: await this.jwt.signAsync(payload), diff --git a/api/src/slices/user/auth/domain/auth.types.ts b/api/src/slices/user/auth/domain/auth.types.ts index 3778d1a2..6477c2d0 100644 --- a/api/src/slices/user/auth/domain/auth.types.ts +++ b/api/src/slices/user/auth/domain/auth.types.ts @@ -3,6 +3,8 @@ import { IUserData, UserRoleTypes } from '../../user/domain'; export interface IAuthTokenPayload { sub: string; email: string; + // Always a singleton `[user.role]` for user tokens. Kept as an array for + // wire compat with long-lived agent service tokens and embed tokens. roles: UserRoleTypes[]; } diff --git a/api/src/slices/user/auth/guards/roles.guard.ts b/api/src/slices/user/auth/guards/roles.guard.ts index 36d97b56..79a32d5b 100644 --- a/api/src/slices/user/auth/guards/roles.guard.ts +++ b/api/src/slices/user/auth/guards/roles.guard.ts @@ -7,12 +7,14 @@ import { import { Reflector } from '@nestjs/core'; import { Request } from 'express'; import { IAuthTokenPayload } from '../domain/auth.types'; -import { UserRoleTypes } from '../../user/domain'; +import { hasAtLeastRole, UserRoleTypes } from '../../user/domain'; import { ROLES_METADATA_KEY } from './roles.decorator'; /** - * Pairs with @Roles(...). Allows the request when the JWT carries at least - * one of the required roles. Must run AFTER JwtAuthGuard so that req.user is set. + * Pairs with @Roles(...). The decorator lists thresholds (OR-ed): the request + * is allowed when the JWT carries a role at or above any of them — roles are + * hierarchical (Owner > Admin > User), Agent is exact-match only. Must run + * AFTER JwtAuthGuard so that req.user is set. */ @Injectable() export class RolesGuard implements CanActivate { @@ -29,7 +31,9 @@ export class RolesGuard implements CanActivate { .switchToHttp() .getRequest(); const userRoles = req.user?.roles ?? []; - const allowed = required.some((r) => userRoles.includes(r)); + const allowed = required.some((needed) => + userRoles.some((actual) => hasAtLeastRole(actual, needed)), + ); if (!allowed) { throw new ForbiddenException('Insufficient role'); } diff --git a/api/src/slices/user/user/data/user.gateway.ts b/api/src/slices/user/user/data/user.gateway.ts index b46e40c7..e42cefac 100644 --- a/api/src/slices/user/user/data/user.gateway.ts +++ b/api/src/slices/user/user/data/user.gateway.ts @@ -49,7 +49,7 @@ export class UserGateway extends IUserGateway { ...(data.password && { password: await bcrypt.hash(data.password, BCRYPT_ROUNDS), }), - ...(data.roles && { roles: this.mapper.normalizeRoles(data.roles) }), + ...(data.role && { role: this.mapper.toAssignableRole(data.role) }), ...(data.status && { status: data.status }), }, }); diff --git a/api/src/slices/user/user/data/user.mapper.ts b/api/src/slices/user/user/data/user.mapper.ts index 1399dc23..877deed2 100644 --- a/api/src/slices/user/user/data/user.mapper.ts +++ b/api/src/slices/user/user/data/user.mapper.ts @@ -5,9 +5,11 @@ import { ICreateUserData, UserRoleTypes, ALL_USER_ROLES, + ASSIGNABLE_USER_ROLES, } from '../domain'; const VALID_ROLES = new Set(ALL_USER_ROLES); +const ASSIGNABLE_ROLES = new Set(ASSIGNABLE_USER_ROLES); @Injectable() export class UserMapper { @@ -16,7 +18,7 @@ export class UserMapper { id: record.id, name: record.name, email: record.email, - roles: this.normalizeRoles(record.roles), + role: this.toRole(record.role), status: record.status as IUserData['status'], createdAt: record.createdAt, updatedAt: record.updatedAt, @@ -29,17 +31,22 @@ export class UserMapper { name: data.name, email: data.email.toLowerCase(), password: data.password, - roles: this.normalizeRoles(data.roles ?? [UserRoleTypes.User]), + role: this.toAssignableRole(data.role), status: 'invited', }; } - /** Drop unknown values, dedupe, fall back to ['User'] when empty. */ - normalizeRoles(roles: readonly string[] | null | undefined): UserRoleTypes[] { - if (!roles?.length) return [UserRoleTypes.User]; - const filtered = Array.from( - new Set(roles.filter((r): r is UserRoleTypes => VALID_ROLES.has(r))), - ); - return filtered.length ? filtered : [UserRoleTypes.User]; + /** Any valid enum member (Owner included — stored rows carry it), else User. */ + toRole(role: string | null | undefined): UserRoleTypes { + return role && VALID_ROLES.has(role) + ? (role as UserRoleTypes) + : UserRoleTypes.User; + } + + /** Only Admin/User can be written through create/update paths, else User. */ + toAssignableRole(role: string | null | undefined): UserRoleTypes { + return role && ASSIGNABLE_ROLES.has(role) + ? (role as UserRoleTypes) + : UserRoleTypes.User; } } diff --git a/api/src/slices/user/user/domain/user.types.ts b/api/src/slices/user/user/domain/user.types.ts index 44c8b7c7..72623c11 100644 --- a/api/src/slices/user/user/domain/user.types.ts +++ b/api/src/slices/user/user/domain/user.types.ts @@ -16,11 +16,41 @@ export const ALL_USER_ROLES: UserRoleTypes[] = [ UserRoleTypes.Agent, ]; +/** + * Roles an admin may assign to a user. Owner exists only once and is created + * exclusively via POST /init; Agent lives only inside JWTs. + */ +export const ASSIGNABLE_USER_ROLES = [ + UserRoleTypes.Admin, + UserRoleTypes.User, +] as const; + +export const ROLE_LEVELS: Record = { + [UserRoleTypes.Owner]: 3, + [UserRoleTypes.Admin]: 2, + [UserRoleTypes.User]: 1, + // Outside the hierarchy — matched exactly, never by level. + [UserRoleTypes.Agent]: 0, +}; + +/** + * Hierarchical role check: Owner > Admin > User, a higher role implies the + * lower ones. Agent is exact-match only, in both directions. + */ +export function hasAtLeastRole( + actual: UserRoleTypes, + required: UserRoleTypes, +): boolean { + if (required === UserRoleTypes.Agent) return actual === UserRoleTypes.Agent; + if (actual === UserRoleTypes.Agent) return false; + return ROLE_LEVELS[actual] >= ROLE_LEVELS[required]; +} + export interface IUserData { id: string; name: string; email: string; - roles: UserRoleTypes[]; + role: UserRoleTypes; status: UserStatusTypes; createdAt: Date; updatedAt: Date; @@ -30,13 +60,13 @@ export interface ICreateUserData { name: string; email: string; password: string; - roles?: UserRoleTypes[]; + role?: UserRoleTypes; } export interface IUpdateUserData { name?: string; email?: string; password?: string; - roles?: UserRoleTypes[]; + role?: UserRoleTypes; status?: UserStatusTypes; } diff --git a/api/src/slices/user/user/dtos/createUser.dto.ts b/api/src/slices/user/user/dtos/createUser.dto.ts index e15d3f17..eb98ab2d 100644 --- a/api/src/slices/user/user/dtos/createUser.dto.ts +++ b/api/src/slices/user/user/dtos/createUser.dto.ts @@ -1,14 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - ArrayUnique, - IsArray, - IsEmail, - IsEnum, - IsOptional, - IsString, - MinLength, -} from 'class-validator'; -import { UserRoleTypes } from '../domain'; +import { IsEmail, IsIn, IsOptional, IsString, MinLength } from 'class-validator'; +import { ASSIGNABLE_USER_ROLES, UserRoleTypes } from '../domain'; export class CreateUserDto { @ApiProperty({ example: 'Jane Doe' }) @@ -25,15 +17,13 @@ export class CreateUserDto { @MinLength(8) password: string; + // Owner is deliberately not assignable — it exists once, created via /init. @ApiPropertyOptional({ - isArray: true, - enum: UserRoleTypes, - enumName: 'UserRoleTypes', - example: [UserRoleTypes.User], + enum: ASSIGNABLE_USER_ROLES, + enumName: 'AssignableUserRoleTypes', + example: UserRoleTypes.User, }) @IsOptional() - @IsArray() - @ArrayUnique() - @IsEnum(UserRoleTypes, { each: true }) - roles?: UserRoleTypes[]; + @IsIn(ASSIGNABLE_USER_ROLES) + role?: UserRoleTypes; } diff --git a/api/src/slices/user/user/dtos/index.ts b/api/src/slices/user/user/dtos/index.ts index f569f0cc..3f6b0baf 100644 --- a/api/src/slices/user/user/dtos/index.ts +++ b/api/src/slices/user/user/dtos/index.ts @@ -1,4 +1,4 @@ export { UserDto } from './user.dto'; export { CreateUserDto } from './createUser.dto'; export { UpdateUserDto } from './updateUser.dto'; -export { UpdateUserRolesDto } from './updateUserRoles.dto'; +export { UpdateUserRoleDto } from './updateUserRole.dto'; diff --git a/api/src/slices/user/user/dtos/updateUser.dto.ts b/api/src/slices/user/user/dtos/updateUser.dto.ts index da4b4747..5bbea25f 100644 --- a/api/src/slices/user/user/dtos/updateUser.dto.ts +++ b/api/src/slices/user/user/dtos/updateUser.dto.ts @@ -3,11 +3,11 @@ import { IsEnum, IsOptional } from 'class-validator'; import { CreateUserDto } from './createUser.dto'; /** - * Mutable user fields *except* roles — role changes require the dedicated - * UpdateUserRolesDto endpoint guarded by Owner. + * Mutable user fields *except* the role — role changes require the dedicated + * UpdateUserRoleDto endpoint guarded by Owner. */ export class UpdateUserDto extends PartialType( - OmitType(CreateUserDto, ['roles'] as const), + OmitType(CreateUserDto, ['role'] as const), ) { @ApiPropertyOptional({ enum: ['active', 'invited', 'disabled'] }) @IsOptional() diff --git a/api/src/slices/user/user/dtos/updateUserRole.dto.ts b/api/src/slices/user/user/dtos/updateUserRole.dto.ts new file mode 100644 index 00000000..0ca07182 --- /dev/null +++ b/api/src/slices/user/user/dtos/updateUserRole.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn } from 'class-validator'; +import { ASSIGNABLE_USER_ROLES, UserRoleTypes } from '../domain'; + +export class UpdateUserRoleDto { + // Owner is deliberately not assignable — it exists once, created via /init. + @ApiProperty({ + enum: ASSIGNABLE_USER_ROLES, + enumName: 'AssignableUserRoleTypes', + example: UserRoleTypes.User, + }) + @IsIn(ASSIGNABLE_USER_ROLES) + role: UserRoleTypes; +} diff --git a/api/src/slices/user/user/dtos/updateUserRoles.dto.ts b/api/src/slices/user/user/dtos/updateUserRoles.dto.ts deleted file mode 100644 index b57baf82..00000000 --- a/api/src/slices/user/user/dtos/updateUserRoles.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { ArrayUnique, IsArray, IsEnum } from 'class-validator'; -import { UserRoleTypes } from '../domain'; - -export class UpdateUserRolesDto { - @ApiProperty({ - isArray: true, - enum: UserRoleTypes, - enumName: 'UserRoleTypes', - example: [UserRoleTypes.User], - }) - @IsArray() - @ArrayUnique() - @IsEnum(UserRoleTypes, { each: true }) - roles: UserRoleTypes[]; -} diff --git a/api/src/slices/user/user/dtos/user.dto.ts b/api/src/slices/user/user/dtos/user.dto.ts index c36ee7c1..28fafb47 100644 --- a/api/src/slices/user/user/dtos/user.dto.ts +++ b/api/src/slices/user/user/dtos/user.dto.ts @@ -12,12 +12,11 @@ export class UserDto { email: string; @ApiProperty({ - isArray: true, enum: UserRoleTypes, enumName: 'UserRoleTypes', - example: [UserRoleTypes.User], + example: UserRoleTypes.User, }) - roles: UserRoleTypes[]; + role: UserRoleTypes; @ApiProperty() status: string; diff --git a/api/src/slices/user/user/user.controller.ts b/api/src/slices/user/user/user.controller.ts index 1b7363d5..973d541a 100644 --- a/api/src/slices/user/user/user.controller.ts +++ b/api/src/slices/user/user/user.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, + ForbiddenException, Get, NotFoundException, Param, @@ -11,14 +12,14 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { IUserGateway, UserRoleTypes } from './domain'; -import { CreateUserDto, UpdateUserDto, UpdateUserRolesDto } from './dtos'; +import { CreateUserDto, UpdateUserDto, UpdateUserRoleDto } from './dtos'; import { JwtAuthGuard, Roles, RolesGuard } from '../auth/guards'; @ApiTags('users') @ApiBearerAuth() @Controller('users') @UseGuards(JwtAuthGuard, RolesGuard) -@Roles(UserRoleTypes.Owner, UserRoleTypes.Admin) +@Roles(UserRoleTypes.Admin) export class UserController { constructor(private userGateway: IUserGateway) {} @@ -37,7 +38,7 @@ export class UserController { } @Post() - @ApiOperation({ summary: 'Invite a new user' }) + @ApiOperation({ summary: 'Create a user (admin sets the password)' }) create(@Body() dto: CreateUserDto) { return this.userGateway.create(dto); } @@ -45,25 +46,41 @@ export class UserController { @Put(':id') @ApiOperation({ summary: - 'Update user (name, email, password, status). Use /roles to change roles.', + 'Update user (name, email, password, status). Use /role to change the role.', }) - update(@Param('id') id: string, @Body() dto: UpdateUserDto) { + async update(@Param('id') id: string, @Body() dto: UpdateUserDto) { + const target = await this.getTargetOrThrow(id); + if (dto.status !== undefined && target.role === UserRoleTypes.Owner) { + throw new ForbiddenException('The Owner account status cannot be changed'); + } return this.userGateway.update(id, dto); } - @Put(':id/roles') + @Put(':id/role') @Roles(UserRoleTypes.Owner) - @ApiOperation({ summary: "Replace the user's roles. Owner only." }) - updateRoles(@Param('id') id: string, @Body() dto: UpdateUserRolesDto) { - return this.userGateway.update(id, { roles: dto.roles }); + @ApiOperation({ summary: "Set the user's role. Owner only." }) + async updateRole(@Param('id') id: string, @Body() dto: UpdateUserRoleDto) { + const target = await this.getTargetOrThrow(id); + if (target.role === UserRoleTypes.Owner) { + throw new ForbiddenException('The Owner role cannot be changed'); + } + return this.userGateway.update(id, { role: dto.role }); } @Delete(':id') @Roles(UserRoleTypes.Owner) @ApiOperation({ summary: 'Remove user. Owner only.' }) async remove(@Param('id') id: string) { + const target = await this.getTargetOrThrow(id); + if (target.role === UserRoleTypes.Owner) { + throw new ForbiddenException('The Owner account cannot be removed'); + } + await this.userGateway.delete(id); + } + + private async getTargetOrThrow(id: string) { const user = await this.userGateway.findById(id); if (!user) throw new NotFoundException('User not found'); - await this.userGateway.delete(id); + return user; } } diff --git a/api/src/slices/user/user/user.prisma b/api/src/slices/user/user/user.prisma index a6cd3837..f8585728 100644 --- a/api/src/slices/user/user/user.prisma +++ b/api/src/slices/user/user/user.prisma @@ -3,7 +3,7 @@ model User { name String email String @unique password String - roles String[] @default(["User"]) + role String @default("User") status String @default("invited") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/api/src/slices/workflow/data/argo-workflow.gateway.ts b/api/src/slices/workflow/data/argo-workflow.gateway.ts index 551135da..ab411bd8 100644 --- a/api/src/slices/workflow/data/argo-workflow.gateway.ts +++ b/api/src/slices/workflow/data/argo-workflow.gateway.ts @@ -69,7 +69,7 @@ export class ArgoWorkflowGateway extends IWorkflowGateway { private async resolveOwnerUserId(): Promise { const all = await this.userGateway.findAll(); const owner = all - .filter((u) => u.roles.includes(UserRoleTypes.Owner)) + .filter((u) => u.role === UserRoleTypes.Owner) .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())[0]; return owner?.id ?? 'admin'; } diff --git a/app/slices/common/components/layout/Provider.vue b/app/slices/common/components/layout/Provider.vue index 671cc994..8950ac8f 100644 --- a/app/slices/common/components/layout/Provider.vue +++ b/app/slices/common/components/layout/Provider.vue @@ -38,11 +38,10 @@ {{ authStore.user?.name ?? authStore.user?.email }}