-
-
- A user can hold any combination. Owner can change other users' roles.
-
-
-
-
-
{{ errors.roles }}
+
+
+
+
+ {{ form.role }}
+
+
+ The Owner role is fixed — it cannot be granted or revoked.
+
+
+
+
+ Admins can manage the workspace; the single Owner is set at install time.
+
+
+
+
+
@@ -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 }}
- {{ role }}
+ {{ authStore.role }}