Skip to content

Commit 86ac7b7

Browse files
authored
AI-1065 Frontend Group Management (#51)
1 parent abcbfb2 commit 86ac7b7

81 files changed

Lines changed: 10761 additions & 982 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/frontend-qa.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ on:
1212

1313
jobs:
1414
test-and-lint:
15-
name: Linting
15+
name: Test and Lint
1616
runs-on: ubuntu-latest
1717
outputs:
1818
environment: ${{ steps.env.outputs.environment }}

apps/backend-services/src/api-key/api-key.controller.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,7 @@ export class ApiKeyController {
8787
@Req() req: Request,
8888
@Body() body: GenerateApiKeyRequestDto,
8989
): Promise<{ apiKey: GeneratedApiKeyDto }> {
90-
const user = req.user;
91-
const userId = user?.sub as string;
90+
const userId = req.resolvedIdentity?.userId ?? "";
9291
if (!userId) {
9392
throw new BadRequestException(
9493
"User ID is required to generate an API key",
@@ -147,8 +146,7 @@ export class ApiKeyController {
147146
@Req() req: Request,
148147
@Body() body: ApiKeyByIdRequestDto,
149148
): Promise<{ apiKey: GeneratedApiKeyDto }> {
150-
const user = req.user;
151-
const userId = user?.sub as string;
149+
const userId = req.resolvedIdentity?.userId ?? "";
152150
if (!userId) {
153151
throw new BadRequestException(
154152
"User ID is required to regenerate an API key",

apps/backend-services/src/auth/auth.controller.spec.ts

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { GroupRole } from "@generated/client";
12
import { Test, TestingModule } from "@nestjs/testing";
23
import { Request, Response } from "express";
34
import { DatabaseService } from "../database/database.service";
@@ -38,6 +39,15 @@ describe("AuthController", () => {
3839
isUserSystemAdmin: jest.fn().mockResolvedValue(false),
3940
} as unknown as jest.Mocked<Pick<DatabaseService, "isUserSystemAdmin">>;
4041

42+
groupService = {
43+
getUserGroups: jest.fn().mockResolvedValue([]),
44+
getAllGroups: jest.fn().mockResolvedValue([]),
45+
} as unknown as jest.Mocked<GroupService>;
46+
47+
databaseService = {
48+
isUserSystemAdmin: jest.fn().mockResolvedValue(false),
49+
} as unknown as jest.Mocked<Pick<DatabaseService, "isUserSystemAdmin">>;
50+
4151
res = {
4252
redirect: jest.fn(),
4353
status: jest.fn().mockReturnThis(),
@@ -328,31 +338,36 @@ describe("AuthController", () => {
328338
});
329339

330340
describe("getMe", () => {
331-
it("should return user profile with groups from JWT payload", async () => {
341+
it("should return user profile with isAdmin false and groups including role", async () => {
332342
const user: User = {
333343
sub: "user-123",
334344
name: "Test User",
335345
preferred_username: "testuser",
336346
email: "test@example.com",
337-
roles: ["admin"],
338347
exp: Math.floor(Date.now() / 1000) + 3600,
339348
};
340349
req.user = user;
341-
const userGroups = [{ id: "group-1", name: "Group One" }];
350+
req.resolvedIdentity = { userId: user.sub };
351+
const userGroups = [
352+
{ id: "group-1", name: "Group One", role: GroupRole.MEMBER },
353+
];
342354
groupService.getUserGroups.mockResolvedValue(userGroups);
343355

344356
const result = await controller.getMe(req as Request);
345357

346358
expect(databaseService.isUserSystemAdmin).toHaveBeenCalledWith(
347359
"user-123",
348360
);
349-
expect(groupService.getUserGroups).toHaveBeenCalledWith("user-123");
361+
expect(groupService.getUserGroups).toHaveBeenCalledWith(
362+
"user-123",
363+
"user-123",
364+
);
350365
expect(result).toEqual({
351366
sub: "user-123",
352367
name: "Test User",
353368
preferred_username: "testuser",
354369
email: "test@example.com",
355-
roles: ["admin"],
370+
isAdmin: false,
356371
expires_in: expect.any(Number),
357372
groups: userGroups,
358373
});
@@ -363,10 +378,10 @@ describe("AuthController", () => {
363378
it("should return empty groups array for user with no memberships", async () => {
364379
const user: User = {
365380
sub: "user-456",
366-
roles: [],
367381
exp: Math.floor(Date.now() / 1000) + 100,
368382
};
369383
req.user = user;
384+
req.resolvedIdentity = { userId: user.sub };
370385
groupService.getUserGroups.mockResolvedValue([]);
371386

372387
const result = await controller.getMe(req as Request);
@@ -375,47 +390,51 @@ describe("AuthController", () => {
375390
expect(result.name).toBeUndefined();
376391
expect(result.preferred_username).toBeUndefined();
377392
expect(result.email).toBeUndefined();
378-
expect(result.roles).toEqual([]);
393+
expect(result.isAdmin).toBe(false);
379394
expect(result.groups).toEqual([]);
380395
});
381396

382-
it("should return all groups for a system-admin user", async () => {
397+
it("should return user groups with isAdmin true for a system-admin user", async () => {
383398
const user: User = {
384399
sub: "admin-user",
385400
name: "Admin User",
386-
roles: ["system-admin"],
387401
exp: Math.floor(Date.now() / 1000) + 3600,
388402
};
389403
req.user = user;
390-
const allGroups = [
391-
{ id: "group-1", name: "Group One" },
392-
{ id: "group-2", name: "Group Two" },
404+
req.resolvedIdentity = { userId: user.sub };
405+
const adminGroups = [
406+
{ id: "group-1", name: "Group One", role: GroupRole.ADMIN },
393407
];
394408
(databaseService.isUserSystemAdmin as jest.Mock).mockResolvedValue(true);
395-
groupService.getAllGroups.mockResolvedValue(allGroups);
409+
groupService.getUserGroups.mockResolvedValue(adminGroups);
396410

397411
const result = await controller.getMe(req as Request);
398412

399413
expect(databaseService.isUserSystemAdmin).toHaveBeenCalledWith(
400414
"admin-user",
401415
);
402-
expect(groupService.getAllGroups).toHaveBeenCalled();
403-
expect(groupService.getUserGroups).not.toHaveBeenCalled();
404-
expect(result.groups).toEqual(allGroups);
416+
expect(groupService.getUserGroups).toHaveBeenCalledWith(
417+
"admin-user",
418+
"admin-user",
419+
);
420+
expect(groupService.getAllGroups).not.toHaveBeenCalled();
421+
expect(result.isAdmin).toBe(true);
422+
expect(result.groups).toEqual(adminGroups);
405423
});
406424

407425
it("should return 0 expires_in if token is expired", async () => {
408426
const user: User = {
409427
sub: "user-789",
410-
roles: [],
411428
exp: Math.floor(Date.now() / 1000) - 100, // expired
412429
};
413430
req.user = user;
431+
req.resolvedIdentity = { userId: user.sub };
414432

415433
const result = await controller.getMe(req as Request);
416434

417435
expect(result.expires_in).toBe(0);
418436
expect(result.groups).toEqual([]);
437+
expect(result.groups).toEqual([]);
419438
});
420439
});
421440
});

apps/backend-services/src/auth/auth.controller.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -264,40 +264,39 @@ export class AuthController {
264264
/**
265265
* Returns the authenticated user's profile information, including group memberships.
266266
* Requires a valid access_token cookie or Bearer token.
267-
* The frontend uses this to display user info, schedule token refresh, and
268-
* determine available groups without making an additional API call.
267+
* The frontend uses this to display user info, schedule token refresh, determine
268+
* system-admin status, and access available groups with per-group roles.
269269
* System-admin users receive all groups in the system.
270270
*/
271271
@Get("me")
272272
@ApiOperation({
273273
summary: "Get current user profile from validated JWT",
274274
description:
275-
"Returns the user's profile, token expiry, and group memberships. System-admins receive all groups.",
275+
"Returns the user's profile, token expiry, system-admin status, and group memberships with per-group roles. System-admins receive all groups.",
276276
})
277277
@ApiOkResponse({
278278
type: MeResponseDto,
279-
description: "Returns current user profile, token expiry, and groups",
279+
description:
280+
"Returns current user profile, token expiry, admin status, and groups with roles",
280281
})
281282
@ApiUnauthorizedResponse({ description: "Not authenticated" })
282283
@ApiForbiddenResponse({ description: "Invalid token" })
283284
async getMe(@Req() req: Request): Promise<MeResponseDto> {
284285
const user = req.user as User;
285286
const now = Math.floor(Date.now() / 1000);
286287
const exp = (user.exp as number) || now;
287-
const userId = user.sub || "";
288+
const userId = req.resolvedIdentity?.userId ?? "";
288289

289290
const isAdmin = await this.databaseService.isUserSystemAdmin(userId);
290-
const groups = isAdmin
291-
? await this.groupService.getAllGroups()
292-
: await this.groupService.getUserGroups(userId);
291+
const groups = await this.groupService.getUserGroups(userId, userId);
293292

294293
return {
295294
sub: userId,
296295
name: (user.name as string) || (user.display_name as string),
297296
preferred_username:
298297
(user.preferred_username as string) || (user.idir_username as string),
299298
email: user.email,
300-
roles: user.roles || [],
299+
isAdmin,
301300
expires_in: Math.max(exp - now, 0),
302301
groups,
303302
};

apps/backend-services/src/auth/dto/me-response.dto.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { GroupRole } from "@generated/client";
12
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
23

34
/**
@@ -10,6 +11,12 @@ export class GroupSummaryDto {
1011

1112
@ApiProperty({ description: "Group display name" })
1213
name: string;
14+
15+
@ApiProperty({
16+
description: "The authenticated user's role in this group",
17+
enum: GroupRole,
18+
})
19+
role: GroupRole;
1320
}
1421

1522
/**
@@ -32,10 +39,9 @@ export class MeResponseDto {
3239
email?: string;
3340

3441
@ApiProperty({
35-
description: "Normalized roles from Keycloak JWT",
36-
type: [String],
42+
description: "Whether the user has system-admin status in the database",
3743
})
38-
roles: string[];
44+
isAdmin: boolean;
3945

4046
@ApiProperty({
4147
description: "Seconds until the current access token expires",

apps/backend-services/src/database/database.service.spec.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,21 +1334,20 @@ describe("DatabaseService", () => {
13341334

13351335
describe("isUserSystemAdmin", () => {
13361336
it("should return true when the user has the system-admin role", async () => {
1337-
mockPrisma.userRole = {
1338-
findFirst: jest
1339-
.fn()
1340-
.mockResolvedValueOnce({ user_id: "user-1", role_id: "role-1" }),
1337+
mockPrisma.user = {
1338+
findUnique: jest.fn().mockResolvedValueOnce({ is_system_admin: true }),
13411339
};
13421340
const result = await service.isUserSystemAdmin("user-1");
13431341
expect(result).toBe(true);
1344-
expect(mockPrisma.userRole.findFirst).toHaveBeenCalledWith({
1345-
where: { user_id: "user-1", role: { name: "system-admin" } },
1342+
expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({
1343+
where: { id: "user-1" },
1344+
select: { is_system_admin: true },
13461345
});
13471346
});
13481347

13491348
it("should return false when the user does not have the system-admin role", async () => {
1350-
mockPrisma.userRole = {
1351-
findFirst: jest.fn().mockResolvedValueOnce(null),
1349+
mockPrisma.user = {
1350+
findUnique: jest.fn().mockResolvedValueOnce({ is_system_admin: false }),
13521351
};
13531352
const result = await service.isUserSystemAdmin("user-1");
13541353
expect(result).toBe(false);

apps/backend-services/src/database/database.service.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -372,19 +372,16 @@ export class DatabaseService {
372372
}
373373

374374
/**
375-
* Checks whether a user holds the `system-admin` role.
375+
* Checks whether a user is a system admin.
376376
*
377377
* @param userId - The ID of the user to check.
378-
* @returns `true` when the user has a UserRole record linked to a Role with
379-
* name `"system-admin"`, `false` otherwise.
378+
* @returns `true` when the user has `is_system_admin` set to `true`, `false` otherwise.
380379
*/
381380
async isUserSystemAdmin(userId: string): Promise<boolean> {
382-
const entry = await this.prisma.userRole.findFirst({
383-
where: {
384-
user_id: userId,
385-
role: { name: "system-admin" },
386-
},
381+
const user = await this.prisma.user.findUnique({
382+
where: { id: userId },
383+
select: { is_system_admin: true },
387384
});
388-
return entry != null;
385+
return user?.is_system_admin ?? false;
389386
}
390387
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
2+
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
3+
4+
/**
5+
* DTO for creating a new group.
6+
* The calling user must be a system admin. Identity is derived from the JWT token.
7+
*/
8+
export class CreateGroupDto {
9+
@ApiProperty({ description: "The name of the group to create" })
10+
@IsString()
11+
@IsNotEmpty()
12+
name: string;
13+
14+
@ApiPropertyOptional({ description: "An optional description for the group" })
15+
@IsString()
16+
@IsOptional()
17+
description?: string;
18+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { ApiProperty } from "@nestjs/swagger";
2+
3+
/**
4+
* Represents a single member of a group, returned by the GET /api/groups/:groupId/members endpoint.
5+
*/
6+
export class GroupMemberDto {
7+
@ApiProperty({ description: "The user's unique identifier" })
8+
userId: string;
9+
10+
@ApiProperty({ description: "The user's email address" })
11+
email: string;
12+
13+
@ApiProperty({ description: "The date the user joined the group" })
14+
joinedAt: Date;
15+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
2+
3+
/**
4+
* Represents a single group membership request, returned by the GET /api/groups/:groupId/requests endpoint.
5+
*/
6+
export class GroupMembershipRequestDto {
7+
@ApiProperty({ description: "The unique identifier of the request" })
8+
id: string;
9+
10+
@ApiProperty({ description: "The ID of the user who made the request" })
11+
userId: string;
12+
13+
@ApiProperty({
14+
description: "The email address of the user who made the request",
15+
})
16+
email: string;
17+
18+
@ApiProperty({ description: "The ID of the group the request is for" })
19+
groupId: string;
20+
21+
@ApiProperty({
22+
description: "The current status of the request",
23+
enum: ["PENDING", "APPROVED", "DENIED", "CANCELLED"],
24+
})
25+
status: string;
26+
27+
@ApiPropertyOptional({
28+
description: "The ID of the admin who acted on the request",
29+
})
30+
actorId?: string;
31+
32+
@ApiPropertyOptional({
33+
description: "The reason provided when acting on the request",
34+
})
35+
reason?: string;
36+
37+
@ApiPropertyOptional({ description: "The date the request was resolved" })
38+
resolvedAt?: Date;
39+
40+
@ApiProperty({ description: "The date the request was created" })
41+
createdAt: Date;
42+
}

0 commit comments

Comments
 (0)