Skip to content

Commit abcbfb2

Browse files
authored
AI-1099 Update Frontend to Utilize Group-specific Behaviour (#50)
1 parent 1696f06 commit abcbfb2

88 files changed

Lines changed: 6877 additions & 276 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/copilot-instructions.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
- Do not include any document-specific implementation, the system is generic and must support arbitrary workloads
88
- Changes to files must pass any linting and formatting checks. If there are any errors, fix them before submitting the code for review.
99

10+
## Frontend Implementation Guidelines
11+
- Use React functional components and hooks for state management and side effects.
12+
- Use Mantine components for UI elements and styling consistency.
13+
- Use Tanstack React Query for data fetching and mutations, with proper error handling and loading states.
14+
- Ensure all API interactions are typed correctly using TypeScript interfaces and types.
15+
- Write unit tests for components and hooks using React Testing Library and Vitest, covering various states and edge cases.
16+
- Follow accessibility best practices, including proper use of ARIA attributes and keyboard navigation.
1017

1118
## Backend Implementation Guidelines
1219
- Functions in js/ts and jsx/tsx files should be documented with JSDoc comments, including parameter and return types, and a description of the function's purpose.

.github/workflows/frontend-qa.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,6 @@ jobs:
3131
- name: Run Linter
3232
working-directory: apps/frontend
3333
run: npm run lint
34+
- name: Run Unit Tests
35+
working-directory: apps/frontend
36+
run: npm run test

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

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ describe("ApiKeyController", () => {
1616
generateApiKey: jest.fn(),
1717
deleteApiKey: jest.fn(),
1818
regenerateApiKey: jest.fn(),
19+
getApiKeyGroupId: jest.fn(),
1920
};
2021

2122
const mockRequest = {
@@ -144,34 +145,42 @@ describe("ApiKeyController", () => {
144145

145146
it("should not throw when user has no email for regenerate", async () => {
146147
mockApiKeyService.regenerateApiKey.mockResolvedValue({});
148+
mockApiKeyService.getApiKeyGroupId.mockResolvedValue("group123");
147149
await expect(
148150
controller.regenerateApiKey(
149151
{
150152
user: { sub: "testuser" },
151153
resolvedIdentity: { userId: "testuser" },
152154
} as any,
153-
{ groupId: "group123" },
155+
{ id: "key123" },
154156
),
155157
).resolves.toBeDefined();
156158
});
157159
});
158160

159161
describe("deleteApiKey", () => {
160-
it("should delete group api key when user is a member", async () => {
162+
it("should delete api key by id when user is a member", async () => {
161163
mockApiKeyService.deleteApiKey.mockResolvedValue(undefined);
164+
mockApiKeyService.getApiKeyGroupId.mockResolvedValue("group123");
162165

163-
await controller.deleteApiKey(mockRequest as any, {
164-
groupId: "group123",
165-
});
166+
await controller.deleteApiKey(mockRequest as any, "key123");
166167

167-
expect(apiKeyService.deleteApiKey).toHaveBeenCalledWith("group123");
168+
expect(apiKeyService.deleteApiKey).toHaveBeenCalledWith("key123");
169+
});
170+
171+
it("should throw BadRequestException when id is missing", async () => {
172+
await expect(
173+
controller.deleteApiKey(mockRequest as any, ""),
174+
).rejects.toThrow(BadRequestException);
175+
expect(apiKeyService.deleteApiKey).not.toHaveBeenCalled();
168176
});
169177

170178
it("should throw ForbiddenException when user is not a group member", async () => {
171179
databaseService.isUserInGroup.mockResolvedValue(false);
180+
mockApiKeyService.getApiKeyGroupId.mockResolvedValue("group123");
172181

173182
await expect(
174-
controller.deleteApiKey(mockRequest as any, { groupId: "group123" }),
183+
controller.deleteApiKey(mockRequest as any, "key123"),
175184
).rejects.toThrow(ForbiddenException);
176185
expect(apiKeyService.deleteApiKey).not.toHaveBeenCalled();
177186
});
@@ -188,24 +197,26 @@ describe("ApiKeyController", () => {
188197
lastUsed: null,
189198
};
190199
mockApiKeyService.regenerateApiKey.mockResolvedValue(mockRegeneratedKey);
200+
mockApiKeyService.getApiKeyGroupId.mockResolvedValue("group123");
191201

192202
const result = await controller.regenerateApiKey(mockRequest as any, {
193-
groupId: "group123",
203+
id: "key123",
194204
});
195205

196206
expect(result).toEqual({ apiKey: mockRegeneratedKey });
197207
expect(apiKeyService.regenerateApiKey).toHaveBeenCalledWith(
198208
"testuser",
199-
"group123",
209+
"key123",
200210
);
201211
});
202212

203213
it("should throw ForbiddenException when user is not a group member", async () => {
204214
databaseService.isUserInGroup.mockResolvedValue(false);
215+
mockApiKeyService.getApiKeyGroupId.mockResolvedValue("group123");
205216

206217
await expect(
207218
controller.regenerateApiKey(mockRequest as any, {
208-
groupId: "group123",
219+
id: "key123",
209220
}),
210221
).rejects.toThrow(ForbiddenException);
211222
expect(apiKeyService.regenerateApiKey).not.toHaveBeenCalled();
@@ -222,6 +233,7 @@ describe("ApiKeyController", () => {
222233
lastUsed: null,
223234
};
224235
mockApiKeyService.regenerateApiKey.mockResolvedValue(mockUpdatedKey);
236+
mockApiKeyService.getApiKeyGroupId.mockResolvedValue("group123");
225237

226238
const reqWithDifferentUser = {
227239
user: { sub: newUserId },
@@ -230,13 +242,13 @@ describe("ApiKeyController", () => {
230242

231243
const result = await controller.regenerateApiKey(
232244
reqWithDifferentUser as any,
233-
{ groupId: "group123" },
245+
{ id: "key123" },
234246
);
235247

236248
expect(result).toEqual({ apiKey: mockUpdatedKey });
237249
expect(apiKeyService.regenerateApiKey).toHaveBeenCalledWith(
238250
newUserId,
239-
"group123",
251+
"key123",
240252
);
241253
});
242254
});

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

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from "@nestjs/swagger";
2424
import { Request } from "express";
2525
import {
26+
ApiKeyByIdRequestDto,
2627
ApiKeyInfoDto,
2728
ApiKeyInfoWrapperDto,
2829
GenerateApiKeyRequestDto,
@@ -108,27 +109,34 @@ export class ApiKeyController {
108109
@Delete()
109110
@HttpCode(HttpStatus.NO_CONTENT)
110111
@KeycloakSSOAuth()
111-
@ApiOperation({ summary: "Delete the API key for a group" })
112-
@ApiBody({ type: GenerateApiKeyRequestDto })
112+
@ApiOperation({ summary: "Delete the API key by its ID" })
113+
@ApiQuery({
114+
name: "id",
115+
description: "The ID of the API key to delete",
116+
})
113117
@ApiNoContentResponse({ description: "API key deleted successfully" })
114118
@ApiForbiddenResponse({ description: "Access denied: not a group member" })
115119
@ApiUnauthorizedResponse({ description: "User is not authenticated" })
116120
async deleteApiKey(
117121
@Req() req: Request,
118-
@Body() body: GenerateApiKeyRequestDto,
122+
@Query("id") id: string,
119123
): Promise<void> {
124+
if (!id) {
125+
throw new BadRequestException("id query parameter is required");
126+
}
127+
const groupId = await this.apiKeyService.getApiKeyGroupId(id);
120128
await identityCanAccessGroup(
121129
req.resolvedIdentity,
122-
body.groupId,
130+
groupId,
123131
this.databaseService,
124132
);
125-
await this.apiKeyService.deleteApiKey(body.groupId);
133+
await this.apiKeyService.deleteApiKey(id);
126134
}
127135

128136
@Post("regenerate")
129137
@KeycloakSSOAuth()
130-
@ApiOperation({ summary: "Regenerate the API key for a group" })
131-
@ApiBody({ type: GenerateApiKeyRequestDto })
138+
@ApiOperation({ summary: "Regenerate the API key by its ID" })
139+
@ApiBody({ type: ApiKeyByIdRequestDto })
132140
@ApiOkResponse({
133141
description: "Returns the newly generated API key",
134142
type: GeneratedApiKeyWrapperDto,
@@ -137,7 +145,7 @@ export class ApiKeyController {
137145
@ApiUnauthorizedResponse({ description: "User is not authenticated" })
138146
async regenerateApiKey(
139147
@Req() req: Request,
140-
@Body() body: GenerateApiKeyRequestDto,
148+
@Body() body: ApiKeyByIdRequestDto,
141149
): Promise<{ apiKey: GeneratedApiKeyDto }> {
142150
const user = req.user;
143151
const userId = user?.sub as string;
@@ -146,15 +154,13 @@ export class ApiKeyController {
146154
"User ID is required to regenerate an API key",
147155
);
148156
}
157+
const groupId = await this.apiKeyService.getApiKeyGroupId(body.id);
149158
await identityCanAccessGroup(
150159
req.resolvedIdentity,
151-
body.groupId,
160+
groupId,
152161
this.databaseService,
153162
);
154-
const apiKey = await this.apiKeyService.regenerateApiKey(
155-
userId,
156-
body.groupId,
157-
);
163+
const apiKey = await this.apiKeyService.regenerateApiKey(userId, body.id);
158164
return { apiKey };
159165
}
160166
}

apps/backend-services/src/api-key/api-key.service.spec.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import { ApiKeyService } from "./api-key.service";
77
// Mock Prisma
88
const mockPrismaApiKey = {
99
findFirst: jest.fn(),
10+
findUnique: jest.fn(),
1011
findMany: jest.fn(),
1112
create: jest.fn(),
1213
deleteMany: jest.fn(),
14+
delete: jest.fn(),
1315
update: jest.fn(),
1416
};
1517
const mockPrismaService = {
@@ -127,19 +129,41 @@ describe("ApiKeyService", () => {
127129
});
128130
});
129131

130-
describe("deleteApiKey", () => {
131-
it("should throw NotFoundException if no key exists", async () => {
132-
mockPrismaApiKey.deleteMany.mockResolvedValue({ count: 0 });
133-
await expect(service.deleteApiKey("group123")).rejects.toThrow(
132+
describe("getApiKeyGroupId", () => {
133+
it("should return the group ID for a valid key", async () => {
134+
mockPrismaApiKey.findUnique.mockResolvedValue({
135+
id: "key123",
136+
group_id: "group123",
137+
});
138+
139+
const result = await service.getApiKeyGroupId("key123");
140+
141+
expect(result).toBe("group123");
142+
expect(mockPrismaApiKey.findUnique).toHaveBeenCalledWith({
143+
where: { id: "key123" },
144+
});
145+
});
146+
147+
it("should throw NotFoundException when key does not exist", async () => {
148+
mockPrismaApiKey.findUnique.mockResolvedValue(null);
149+
150+
await expect(service.getApiKeyGroupId("missing")).rejects.toThrow(
134151
NotFoundException,
135152
);
136153
});
154+
});
137155

138-
it("should delete existing key", async () => {
139-
mockPrismaApiKey.deleteMany.mockResolvedValue({ count: 1 });
140-
await service.deleteApiKey("group123");
141-
expect(mockPrismaApiKey.deleteMany).toHaveBeenCalledWith({
142-
where: { group_id: "group123" },
156+
describe("deleteApiKey", () => {
157+
it("should throw NotFoundException if no key exists", async () => {
158+
mockPrismaApiKey.delete.mockRejectedValue({ code: "P2025" });
159+
await expect(service.deleteApiKey("key123")).rejects.toBeDefined();
160+
});
161+
162+
it("should delete a key by its ID", async () => {
163+
mockPrismaApiKey.delete.mockResolvedValue({ id: "key123" });
164+
await service.deleteApiKey("key123");
165+
expect(mockPrismaApiKey.delete).toHaveBeenCalledWith({
166+
where: { id: "key123" },
143167
});
144168
});
145169
});

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

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,23 @@ export class ApiKeyService {
3535
};
3636
}
3737

38+
/**
39+
* Looks up an API key record by its ID and returns the associated group ID.
40+
* Throws NotFoundException when no matching key exists.
41+
*
42+
* @param keyId - The UUID of the API key record.
43+
* @returns The group ID the key belongs to.
44+
*/
45+
async getApiKeyGroupId(keyId: string): Promise<string> {
46+
const apiKey = await this.prisma.apiKey.findUnique({
47+
where: { id: keyId },
48+
});
49+
if (!apiKey) {
50+
throw new NotFoundException("No API key found with this ID");
51+
}
52+
return apiKey.group_id;
53+
}
54+
3855
async generateApiKey(
3956
userId: string,
4057
groupId: string,
@@ -70,20 +87,21 @@ export class ApiKeyService {
7087
};
7188
}
7289

73-
async deleteApiKey(groupId: string): Promise<void> {
74-
const deleted = await this.prisma.apiKey.deleteMany({
75-
where: { group_id: groupId },
90+
async deleteApiKey(keyId: string): Promise<void> {
91+
const deleted = await this.prisma.apiKey.delete({
92+
where: { id: keyId },
7693
});
77-
if (deleted.count === 0) {
78-
throw new NotFoundException("No API key found for this group");
94+
if (!deleted) {
95+
throw new NotFoundException("No API key found with this ID");
7996
}
80-
this.logger.log(`API key(s) deleted for group ${groupId}`);
97+
this.logger.log(`API key ${keyId} deleted`);
8198
}
8299

83100
async regenerateApiKey(
84101
userId: string,
85-
groupId: string,
102+
keyId: string,
86103
): Promise<GeneratedApiKeyDto> {
104+
const groupId = await this.getApiKeyGroupId(keyId);
87105
return this.generateApiKey(userId, groupId);
88106
}
89107

apps/backend-services/src/api-key/dto/api-key-info.dto.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ export class GenerateApiKeyRequestDto {
1010
groupId: string;
1111
}
1212

13+
export class ApiKeyByIdRequestDto {
14+
@ApiProperty({
15+
description: "The ID of the API key to operate on",
16+
})
17+
@IsString()
18+
@IsNotEmpty()
19+
id: string;
20+
}
21+
1322
export class ApiKeyInfoDto {
1423
@ApiProperty()
1524
id: string;

0 commit comments

Comments
 (0)