Skip to content

Issue/1-6#19

Open
mirek1112 wants to merge 6 commits intomainfrom
issue/6
Open

Issue/1-6#19
mirek1112 wants to merge 6 commits intomainfrom
issue/6

Conversation

@mirek1112
Copy link
Copy Markdown
Collaborator

@mirek1112 mirek1112 commented Apr 13, 2026

Summary by CodeRabbit

Release Notes

  • New Features

    • User roles system with admin and regular user access levels
    • Admin-only dashboard with access protection
    • Login redirect to originally requested page after authentication
    • Role-based navigation menu items
    • Admin capabilities to manage user roles
  • Bug Fixes

    • Enforced corporate email domain validation for authentication
  • Chores

    • Database schema updates and migrations
    • Global request validation enabled

await onUserUpdate(user);
unsubscribe = auth.onAuthStateChanged(async firebaseUser => {
if (firebaseUser) {
await onUserUpdate(firebaseUser as User);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type casting like that can cause a lot of troubles. It hides type drift — prefer explicit mappers or using zod across the stack.

user: result.user,
};
createUserMutation.mutate(data);
const firebaseUser = result.user as User;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

Comment thread frontend/src/routes/Login.tsx Outdated
const navigate = useNavigate();
const location = useLocation();

const from = (location.state as { from?: string } | null)?.from ?? '/';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line mixes three concerns (cast, optional chaining, fallback) and duplicates the state shape. The as cast is also unchecked — malformed state would slip through.

Could we extract a small typed helper so the contract lives in one place?

Comment thread backend/src/user/user.controller.ts Outdated

class UpdateRoleRequest {
@ApiProperty({ enum: UserRole })
role: UserRole;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

role is not validated at runtime. body.role is typed as UserRole, but TypeScript types are erased at runtime — nothing here stops a client from sending { "role": "superadmin" }. Because role is stored as a plain varchar (SQLite has no native enum), that string gets persisted as-is. Every subsequent RolesGuard comparison then fails closed (which happens to be safe today — 403), but the DB row is now in an invalid state and any future hierarchy / admin-implies-user logic will misbehave.

Two small changes fix it:

  1. Add class-validator on the DTO field.
  2. Move UpdateRoleRequest out of the controller into dto/update_role.ts — every other DTO in this project lives under dto/ (see create_user.ts).
Suggested change
role: UserRole;
class UpdateRoleRequest {
@ApiProperty({ enum: UserRole, enumName: 'UserRole' })
@IsEnum(UserRole)
role: UserRole;
}

(You'll also need import { IsEnum } from 'class-validator'; at the top, and to confirm the global ValidationPipe is registered in main.ts — otherwise the decorator is decorative only.)

Follow-up in a separate commit: delete this inline class and replace with import { UpdateRoleRequest } from './dto/update_role';.

Copy link
Copy Markdown
Collaborator

@bohdanKulcyckyj bohdanKulcyckyj left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blockers before merge

Following up on my earlier review (the inline suggestion on UpdateRoleRequest validation is also still open). These are the remaining items that have to be fixed in this PR — deferring them is not safe:

  1. Migration — see inline on the migration file. synchronize: true has been carrying the schema; the new migration recreates all 12 tables and will fail on any existing DB.
  2. DTO boundary on /auth/* — see inline on auth.controller.ts. Both endpoints leak employee_id; once a client ships we can't remove it.
  3. Frontend zombie session — see inline on AuthProvider.tsx. 404 from /auth/me is silently swallowed → user appears logged in but has no role.
  4. First-admin bootstrap — a fresh DB has no admin, and the only promotion endpoint (PATCH /users/:id/role) is itself admin-guarded. That's a chicken-and-egg: the deployed app cannot be administered until somebody edits the DB by hand. Please add either a one-shot seed script or an FIRST_ADMIN_EMAIL env-driven bootstrap inside upsertByGoogleWorkspaceToken. This has to be in this PR because #2 in this series is what introduced the role column.
  5. PR description is empty. For a 30-file / +1129 change, please fill it in before merge: what's in scope, what's out of scope, testing notes, screenshots for the login page changes, and Closes #1 #2 #3 #4 #5 #6 so the issues auto-close. This isn't follow-up-able — the PR description is the artifact that ships.

Everything else I noticed (guard DB round-trip, duplicated RolesGuard provider registration, circular forwardRef, profiq-ui adoption, token-expiration handling, generated OpenAPI types, magic-string 'admin', missing AdminRoute / upsertByGoogleWorkspaceToken tests) I'm fine with tracking as separate follow-up issues — they don't block merge.

Process note for next time: please stop bundling 6 issues into one PR. Your per-commit discipline is good; apply the same discipline at the PR boundary (stacked PRs, or at minimum split migration / backend guard / UI / auth API). I flagged this in the earlier review too.


public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar NOT NULL, "employee_id" varchar NOT NULL, "role" varchar NOT NULL DEFAULT ('user'))`
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This migration recreates the entire schema from scratch — 12 tables, plus TypeORM's SQLite temporary_* rewrite dance later in the file. None of that is about adding role. It looks like typeorm migration:generate was run against an empty DB and the output was committed wholesale.

The project has been running on synchronize: true since inception, so on any existing database (staging, anyone's local, CI if it persists state) this CREATE TABLE "user" fails immediately — the table is already there.

This file has to be replaced with a one-liner incremental migration:

await queryRunner.query(
  `ALTER TABLE "user" ADD COLUMN "role" varchar NOT NULL DEFAULT 'user'`
);

Baselining the rest of the schema into migrations is a valuable thing to do, but it's a separate, well-scoped PR — not a side effect of adding one column.

Related: datasource.ts still runs migrationsRun only when NODE_ENV === 'production' and synchronize is still on in dev, so the e2e suite never exercises the migration. That's how this slipped through. Please flip that as part of the fix so CI actually runs migrations.

req.firebaseUser
);
if (user === null) {
throw new NotFoundException('User not found in employee directory');
Copy link
Copy Markdown
Collaborator

@bohdanKulcyckyj bohdanKulcyckyj Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two problems on this endpoint that have to be fixed before we ship a client:

1. employee_id should never cross the wire. The response type is User (the TypeORM entity), which means employee_id is serialized into every /auth/login and /auth/me response. Today employee_id is just an opaque directory key, but it's the exact kind of identifier that gets joined to HR data (payroll, performance, contracts) the first time Ops asks us for a "simple" report. Once it's on the browser it's in logs, in analytics payloads, in error-reporting tools like Sentry — and you can't reel it back in. The fix has to happen before this endpoint has real clients.

Please introduce a UserResponseDto (class-transformer @Expose on only the fields the frontend actually needs — id, name, role) and return that from both login here and getMe below. The frontend already hand-duplicates this shape as DbUser in services/auth/auth.ts, which is a symptom — the wire contract should be a deliberate DTO, not the entity leaking out whatever columns happen to exist.

2. Misleading 404 message. upsertByGoogleWorkspaceToken returns null for two distinct cases: (a) the Firebase token has no google.com identity, and (b) the user isn't in the employee directory. Both get mapped to 'User not found in employee directory' here, which is wrong for case (a) — the client can't tell "your SSO isn't Google" from "you're not an employee", which is a terrible debugging experience for whoever hits it.

Please either return a discriminated result from the service (e.g. { ok: false, reason: 'no-google-identity' | 'not-in-directory' }) or throw typed exceptions from the service and catch them here with appropriate messages.

Why both are merge-blockers: the DTO leak is hard to remove once clients depend on it (and actively dangerous as soon as HR data is in the picture), and the 404 message gets screenshotted into Slack during the first incident and then nobody trusts it.

Comment thread backend/src/auth/auth.controller.ts Outdated
req.firebaseUser
);
if (!user) {
throw new NotFoundException('User not found');
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/auth/me doesn't upsert — it only reads. That creates a race: right after a successful /auth/login on a fresh account, the next /auth/me call (on another tab, after a reload, after a token refresh) can 404 if anything went sideways with the upsert.

More importantly, the frontend currently swallows this 404 silently (see the AuthProvider.tsx comment) and the app enters a zombie state: user is set, role is null, nothing surfaces to the user.

Pick one of:

  • Make /auth/me upsert-if-missing (same code path as /auth/login), or
  • Have the frontend call loginToBackend on 404 from /auth/me and retry.

I have a slight preference for the first — /auth/me being idempotent and self-healing means the frontend doesn't need to know about this edge case at all. But either is fine, as long as the contract is explicit and the zombie state is impossible.

(Same DTO-leak issue from line 36 applies here too.)

return;
}
// User not in DB yet — will be created on next login
setRole(null);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "User not in DB yet — will be created on next login" but nothing schedules a next login. After onAuthStateChanged fires on app boot for a freshly-signed-up-but-never-login'd user, we end up with user set, role = null, no error surfaced, and no retry — a zombie session where the UI thinks you're logged in but you can't do anything.

The same pattern appears on line 99-100 (the login() catch): loginToBackend fails → setRole(null)Login.tsx sees no thrown error → navigates into the app. Broken backend = silent zombie.

Two fixes needed here:

  1. On 404 from /auth/me: call loginToBackend(firebaseUser) to force the upsert, then re-read. Or (preferred) make /auth/me upsert itself on the backend (see my comment on auth.controller.ts line 55) and then this whole branch is just catch (err) { throw err }.
  2. On any other error from getMe / loginToBackend: surface it. Either return it from login() so Login.tsx can display it, or call auth.signOut() so we don't sit in a half-authenticated state. The ForbiddenError → signOut path above (line 47-50) is the right pattern; generalize it.

Why this is a merge-blocker: it's a user-facing correctness bug, not polish. A user who hits this path is stuck on a blank admin-less app with no way to recover except clearing storage.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a comprehensive authentication and role-based access control (RBAC) system. The backend adds Firebase-authenticated endpoints with bearer token verification, user role management, and admin-restricted routes protected by guards and decorators. The frontend integrates role-based UI rendering and admin route protection. Database migrations add the role column to users.

Changes

Cohort / File(s) Summary
Environment & Configuration
.env.example
Added FIRST_ADMIN_EMAIL placeholder for first admin user identification.
Backend Authentication Core
backend/src/auth/auth.controller.ts, backend/src/auth/auth.module.ts, backend/src/auth/auth.guard.ts
Introduced AuthController with login, logout, and getMe endpoints; added AuthModule with controller registration and UserModule import via forwardRef; updated AuthGuard to attach firebaseUser to requests after email domain validation.
Backend Authorization & Roles
backend/src/auth/roles.decorator.ts, backend/src/auth/roles.guard.ts
Added Roles decorator factory and ROLES_KEY metadata constant; implemented RolesGuard to enforce role-based access by checking firebaseUser presence, user lookup, and role matching.
Backend User Management
backend/src/user/user.entity.ts, backend/src/user/user.service.ts, backend/src/user/user.controller.ts
Added UserRole enum to entity; changed id from manually assigned to auto-generated; added role column with default 'user'; extended service with upsertByGoogleWorkspaceToken, getUserByGoogleWorkspaceUid, and updateUserRole methods; added PATCH :id/role controller endpoint with admin-only access.
Backend User DTOs
backend/src/user/dto/user_response.dto.ts, backend/src/user/dto/update_role.ts
Added UserResponseDto with id, name, role fields; added UpdateRoleRequest DTO with validated role enum property.
Backend Admin Access
backend/src/admin/admin.controller.ts, backend/src/admin/admin.module.ts
Added @ApiBearerAuth() and @UseGuards(AuthGuard, RolesGuard) with @Roles(UserRole.Admin) to controller; updated module to import AuthModule and UserModule, and register RolesGuard in providers.
Backend Infrastructure
backend/src/main.ts, backend/src/datasource.ts, backend/src/employee/employee.module.ts, backend/src/user/user.module.ts
Added global ValidationPipe; disabled synchronize and enabled migrationsRun in datasource; wrapped AuthModule and UserModule imports in forwardRef across modules.
Backend Database
backend/src/db/migrations/1776082215808-InitialSchema.ts
Added migration to add non-nullable role column with default 'user' to user table.
Backend Authentication Tests
backend/src/auth/auth.controller.spec.ts, backend/src/auth/roles.guard.spec.ts, backend/test/auth.e2e-spec.ts
Added controller unit tests validating login, logout, getMe endpoints with error mapping; added guard unit tests verifying role enforcement logic; added e2e tests covering auth endpoints with valid/invalid tokens and role-based access.
Backend User Tests
backend/src/user/user.service.spec.ts, backend/test/user.e2e-spec.ts
Added service unit tests for upsert and role update operations; updated e2e tests to expect role in user payloads.
Backend Loan Fixture Updates
backend/src/loans/loans.controller.spec.ts, backend/src/loans/loans.service.spec.ts
Updated mock user objects to include role: UserRole.User field.
Frontend Authentication Context
frontend/src/lib/contexts.tsx, frontend/src/lib/providers/auth/AuthProvider.tsx, frontend/src/lib/providers/auth/domain.ts
Added UserRole type definition; extended AuthContextType with role field across all states; refactored AuthProvider to handle roles, call backend auth services (loginToBackend, getMe), and extract domain validation to separate module; changed login return type to Promise<string | undefined> for error handling.
Frontend Authorization & Routes
frontend/src/components/AdminRoute.tsx, frontend/src/components/ProtectedRoute.tsx, frontend/src/components/ProtectedRoute.component-spec.tsx
Added AdminRoute component gating access to /admin by checking role === 'admin'; updated ProtectedRoute to use useLocation and pass current pathname in redirect state; updated test mock for useLocation.
Frontend Login & Navigation
frontend/src/routes/Login.tsx, frontend/src/routes/Login.component-spec.tsx, frontend/src/components/navigation-menu-reference.tsx, frontend/src/App.tsx
Refactored login flow to handle redirect state and role-based error handling; added Spinner component for loading states; updated navigation menu to conditionally render admin link when role === 'admin'; wrapped /admin route with AdminRoute in App.
Frontend Auth Service
frontend/src/services/auth/auth.ts
Added loginToBackend and getMe functions calling /auth/login and /auth/me endpoints; defined DbUser type matching backend response schema.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Frontend Client
    participant AuthCtrl as AuthController
    participant AuthGuard
    participant UserSvc as UserService
    participant DB as Database
    participant EmpSvc as EmployeeService

    Client->>AuthCtrl: POST /auth/login<br/>(Bearer token)
    AuthCtrl->>AuthGuard: Verify token
    AuthGuard->>AuthGuard: Validate email domain
    AuthGuard->>AuthCtrl: Attach firebaseUser
    AuthCtrl->>UserSvc: upsertByGoogleWorkspaceToken(token)
    alt Google identity present
        UserSvc->>DB: Check user by employee_id
        alt User exists
            UserSvc->>UserSvc: Return existing user
        else User missing
            UserSvc->>EmpSvc: getEmployee(googleUid)
            alt Employee found
                EmpSvc->>UserSvc: Return employee
                UserSvc->>DB: Create new User
                UserSvc->>DB: Save user
                UserSvc->>UserSvc: Return new user
            else Employee not in directory
                UserSvc->>UserSvc: Return not-in-directory error
            end
        end
    else No Google identity
        UserSvc->>UserSvc: Return no-google-identity error
    end
    UserSvc->>AuthCtrl: Return UpsertResult
    AuthCtrl->>Client: HTTP 200 + UserResponseDto
Loading
sequenceDiagram
    participant Client as Frontend Client
    participant RolesGuard
    participant UserCtrl as UserController
    participant UserSvc as UserService
    participant DB as Database

    Client->>RolesGuard: PATCH /users/:id/role<br/>(Bearer token)
    RolesGuard->>RolesGuard: Extract required roles<br/>from `@Roles` metadata
    RolesGuard->>RolesGuard: Verify firebaseUser<br/>from request
    RolesGuard->>UserSvc: getUserByGoogleWorkspaceUid(token)
    UserSvc->>DB: Find user by token
    alt User found & has required role
        RolesGuard->>UserCtrl: Allow access
        UserCtrl->>UserSvc: updateUserRole(id, role)
        UserSvc->>DB: Update user.role
        UserSvc->>UserCtrl: Return updated user
        UserCtrl->>Client: HTTP 200 + User
    else User missing or role insufficient
        RolesGuard->>Client: HTTP 403 Forbidden
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 Hops of joy for auth so fine,
Roles and guards in perfect line,
Admin gates now seal the way,
Users upser-ted every day!
From frontend to database deep,
Security guards the treasure keep. 🔐

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title "Issue/1-6" is vague and does not clearly convey what changes are implemented; it appears to be a branch name rather than a descriptive summary of the actual changes. Replace with a clear, descriptive title that summarizes the main change, such as 'Add role-based access control and admin authentication' or 'Implement admin role management and authorization guards'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue/6

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (16)
frontend/src/lib/providers/auth/domain.ts (1)

3-8: Domain string duplicated with backend; consider config-driven value.

'profiq.com' is also hardcoded in backend/src/auth/auth.guard.ts (Line 12: endsWith('@profiq.com')). Any future change (e.g., staging/alternate tenant) requires edits in both places and risks FE/BE drift. Consider sourcing this from env (e.g., VITE_ALLOWED_EMAIL_DOMAIN) on the frontend and a matching backend config key.

Also note: email?.split('@')[1] picks the second segment rather than the last, so a pathological address like a@b@profiq.com would resolve to 'b' on the frontend but still pass the backend's endsWith('@profiq.com') check — a minor FE/BE inconsistency. Using email.slice(email.lastIndexOf('@') + 1) would match backend semantics more closely.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/lib/providers/auth/domain.ts` around lines 3 - 8, DOMAIN is
hardcoded and parsing uses split('@')[1], causing FE/BE drift and incorrect
parsing for addresses with multiple '@'; change DOMAIN to read from the
environment (e.g., import.meta.env.VITE_ALLOWED_EMAIL_DOMAIN with a sensible
fallback) and update checkDomain to compute the domain using
email.slice(email.lastIndexOf('@') + 1) (handle undefined/empty email) and
compare lowercased/trimmed values against DOMAIN so frontend matches backend
semantics; update references to DOMAIN and the checkDomain(UserInfo) function
accordingly.
backend/src/auth/auth.guard.ts (1)

12-16: Extract domain to shared configuration to avoid FE/BE drift.

The @profiq.com domain is hardcoded here and in backend/src/employee/employee.service.ts, while the frontend defines it as a constant in frontend/src/lib/providers/auth/domain.ts. Moving this to a centralized backend configuration (e.g., backend/src/config/configuration.ts) or a shared constant would prevent duplication and make staging/multi-tenant deployments easier.

Also consider having AuthService.verifyToken() throw an exception on missing or invalid tokens instead of returning { email: undefined }. Currently, the guard returns false (which NestJS treats as 403 Forbidden), but 401 Unauthorized would be semantically more accurate for authentication failures.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/auth/auth.guard.ts` around lines 12 - 16, Hardcoded domain
"@profiq.com" in the auth guard causes FE/BE drift and auth failures; extract
this domain into a centralized backend config (e.g., add a DOMAIN or
ALLOWED_EMAIL_DOMAIN constant in backend/src/config/configuration.ts) and import
that constant into auth.guard.ts and employee.service.ts (referencing the token
check in AuthGuard and any domain logic in EmployeeService) so both use the same
source of truth. Also update AuthService.verifyToken() to throw an error for
missing/invalid tokens instead of returning { email: undefined }, and change the
guard (AuthGuard) to catch that error and rethrow a NestJS UnauthorizedException
(401) rather than returning false, ensuring authentication failures produce 401s
not 403s.
backend/src/main.ts (1)

23-23: Strengthen ValidationPipe configuration with security-focused options.

new ValidationPipe() with defaults allows unknown properties to pass through and does not transform payloads into DTO instances. For the PATCH :id/role endpoint with UpdateRoleRequest, enabling whitelist, forbidNonWhitelisted, and transform is a recommended security hardening practice — it prevents clients from submitting unexpected fields, provides explicit feedback on invalid input, and ensures @IsEnum(UserRole) validates against properly coerced values.

♻️ Suggested options
-  app.useGlobalPipes(new ValidationPipe());
+  app.useGlobalPipes(
+    new ValidationPipe({
+      whitelist: true,
+      forbidNonWhitelisted: true,
+      transform: true,
+    })
+  );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/main.ts` at line 23, The global ValidationPipe is instantiated
with defaults which allow unknown properties and do not transform payloads;
update the instantiation where app.useGlobalPipes(new ValidationPipe()) is
called to enable security-focused options: set whitelist: true,
forbidNonWhitelisted: true, and transform: true so DTOs (e.g.,
UpdateRoleRequest) are transformed and extraneous fields are stripped/forbidden
and validation like `@IsEnum`(UserRole) operates on coerced values; keep the same
ValidationPipe usage but pass the options object to enforce these rules
globally.
frontend/src/components/navigation-menu-reference.tsx (1)

49-49: Optional: avoid hard-coded role string.

Consider comparing against the UserRole type/constant from @/lib/contexts (or a shared constant) to avoid drift if the role string ever changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/navigation-menu-reference.tsx` at line 49, Replace
the hard-coded role string check (role === 'admin') with a comparison against
the shared UserRole constant/enum from your auth context; import UserRole from
"@/lib/contexts" and use the appropriate member (e.g., UserRole.Admin or
UserRole.ADMIN) in the conditional where the variable role is checked to prevent
drift if the role string changes.
backend/src/admin/admin.module.ts (1)

11-11: Optional: RolesGuard registration in providers is redundant.

RolesGuard is applied via @UseGuards(AuthGuard, RolesGuard) at the controller level, which instantiates the class directly using the module's injector — it does not need to be listed in providers. It's also already registered as a provider in UserModule. You can drop it here without affecting behavior.

♻️ Proposed tweak
-  providers: [AdminService, RolesGuard],
+  providers: [AdminService],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/admin/admin.module.ts` at line 11, Remove the redundant
RolesGuard entry from the AdminModule providers array: in admin.module.ts,
delete RolesGuard from the providers list (leaving AdminService) since
RolesGuard is already provided in UserModule and applied via
`@UseGuards`(AuthGuard, RolesGuard) at controller level; this keeps DI behavior
unchanged while avoiding duplicate registration.
frontend/src/components/ProtectedRoute.tsx (1)

20-24: Consider preserving search/hash alongside pathname.

state={{ from: location.pathname }} drops location.search and location.hash, so post-login redirect won't restore query params (filters, tabs, etc.). Minor; passing location.pathname + location.search + location.hash (or the whole location object) would be more robust.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/ProtectedRoute.tsx` around lines 20 - 24, The current
redirect drops query and hash because ProtectedRoute sets state={{ from:
location.pathname }}; update the Navigate state to include the full location
(e.g., state={{ from: location.pathname + location.search + location.hash }} or
simply state={{ from: location }}) so post-login redirects restore query params
and fragment; change the state passed to Navigate in ProtectedRoute.tsx
accordingly.
frontend/src/components/AdminRoute.tsx (1)

18-27: Access Denied block is a dead-end UX.

No link back to a safe route (e.g., home) and no semantic role/landmark. Consider adding a "Go home" link/button and wrapping in <main role="main"> or similar to help screen-reader users and accidental visitors recover.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/AdminRoute.tsx` around lines 18 - 27, The Access
Denied block in the AdminRoute component (the conditional that checks role !==
'admin') should provide a recovery path and semantic landmark: wrap the returned
JSX in a <main role="main"> landmark and add a visible "Go home" link or button
that navigates to the safe route (e.g., "/") — ensure the control uses a
semantic element (anchor or button) with an accessible label/aria-label and
keyboard-focusable styling; update AdminRoute's return for non-admins to include
these changes so screen-reader users and accidental visitors can recover.
frontend/src/services/auth/auth.ts (1)

16-38: Optional: extract a small helper to remove duplication and guard against missing error.message.

loginToBackend and getMe are structurally identical except for method/path. Additionally, result.error.message assumes the backend always returns { message: ... }; if not, the thrown error will surface an undefined message. A small helper removes the copy/paste and makes the fallback explicit.

♻️ Proposed refactor
+async function callAuth(
+  user: User,
+  method: HttpMethod,
+  path: string
+): Promise<DbUser> {
+  const client = new APIClient(user);
+  const result: APIResponse<DbUser> = await client.fetch(method, path);
+  if (!result.success) {
+    throw createError(
+      result.status_code,
+      result.error?.message ?? 'Request failed'
+    );
+  }
+  return result.data;
+}
+
-export async function loginToBackend(user: User): Promise<DbUser> {
-  const client = new APIClient(user);
-  const result: APIResponse<DbUser> = await client.fetch(
-    HttpMethod.Post,
-    '/auth/login'
-  );
-  if (!result.success) {
-    throw createError(result.status_code, result.error.message);
-  }
-  return result.data;
-}
-
-export async function getMe(user: User): Promise<DbUser> {
-  const client = new APIClient(user);
-  const result: APIResponse<DbUser> = await client.fetch(
-    HttpMethod.Get,
-    '/auth/me'
-  );
-  if (!result.success) {
-    throw createError(result.status_code, result.error.message);
-  }
-  return result.data;
-}
+export const loginToBackend = (user: User) =>
+  callAuth(user, HttpMethod.Post, '/auth/login');
+
+export const getMe = (user: User) =>
+  callAuth(user, HttpMethod.Get, '/auth/me');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/services/auth/auth.ts` around lines 16 - 38, Extract a small
helper (e.g., handleApiResponse or unwrapApiResponse) that accepts an
APIResponse<DbUser> returned from APIClient.fetch and centralizes the success
check: if result.success return result.data, otherwise throw
createError(result.status_code, result.error?.message ?? 'Unknown error'); then
update loginToBackend and getMe to call this helper after their client.fetch
calls instead of duplicating the success/error logic so you remove copy/paste
and guard against missing error.message from APIResponse.
backend/src/user/user.service.spec.ts (1)

29-105: Recommend adding tests for upsertByGoogleWorkspaceToken — especially the FIRST_ADMIN_EMAIL branch.

upsertByGoogleWorkspaceToken decides who is provisioned as UserRole.Admin based on a process.env.FIRST_ADMIN_EMAIL match (see backend/src/user/user.service.ts:69-97). That's a security-sensitive branch — please add tests that cover:

  • no-google-identity → returns { error: 'no-google-identity' }.
  • Existing user → returned as-is with their stored role (not re-assigned).
  • Not in directory → { error: 'not-in-directory' }.
  • New user with email matching FIRST_ADMIN_EMAIL → saved with role: UserRole.Admin.
  • New user with non-matching email → saved with role: UserRole.User.
  • FIRST_ADMIN_EMAIL unset → never escalates to Admin.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/user/user.service.spec.ts` around lines 29 - 105, Add
comprehensive unit tests for UserService.upsertByGoogleWorkspaceToken covering
the FIRST_ADMIN_EMAIL branch: write tests for (1) token missing google.com
identity returns { error: 'no-google-identity' }, (2) existing user in
repository is returned unchanged (preserves stored role), (3) when
employeeService cannot find user in directory return { error: 'not-in-directory'
}, (4) new user whose email matches process.env.FIRST_ADMIN_EMAIL is saved with
role UserRole.Admin, (5) new user whose email does not match is saved with role
UserRole.User, and (6) when process.env.FIRST_ADMIN_EMAIL is unset no escalation
to Admin occurs; use the existing mocks (mockRepository, mockEmployeeService)
and methods (mockRepository.findOne, mockRepository.save,
mockEmployeeService.getByWorkspaceId) and assert returned values and that save
is called with expected role.
backend/src/db/migrations/1776082215808-InitialSchema.ts (1)

1-16: Approved — thanks for addressing the earlier feedback.

The migration is now a minimal incremental change instead of recreating the whole schema, which matches the intent of this PR and won't conflict with existing databases.

One small nit: the filename is still 1776082215808-InitialSchema.ts while the class is AddRoleToUser1776082215808. Consider renaming the file to 1776082215808-AddRoleToUser.ts to match and avoid confusion for future maintainers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/db/migrations/1776082215808-InitialSchema.ts` around lines 1 -
16, Rename the migration file to match the class name to avoid confusion: change
the filename from 1776082215808-InitialSchema.ts to
1776082215808-AddRoleToUser.ts and ensure the exported class name
AddRoleToUser1776082215808 remains unchanged so tooling and humans can match
file ↔ class (no code changes needed inside the file).
frontend/src/lib/contexts.tsx (1)

9-35: LGTM — discriminated union variants consistently carry role.

Nit: UserRole duplicates the string values of the backend UserRole enum (backend/src/user/user.entity.ts). If the enum is ever extended (e.g., a manager role), both sides need manual updates. Consider colocating a single source of truth (e.g., a generated types package or a shared constants file) if the project grows more roles.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/lib/contexts.tsx` around lines 9 - 35, The UserRole string union
duplicates the backend UserRole enum which will drift if roles change; replace
the local UserRole declaration by importing the shared/generated role type or
constants and update usages (e.g., the UserRole type and any references inside
AuthContextType) to use that single source of truth (or wire up a codegen step
that emits a frontend type from the backend enum) so frontend and backend stay
in sync.
frontend/src/routes/Login.tsx (1)

16-21: Optional: constrain from to internal paths.

getRedirectFrom accepts any string for from. Today the only producer is ProtectedRoute passing location.pathname, so this is safe. If a future caller ever forwards a user-controlled value (e.g. a ?redirect= query param) into this state, a value like //evil.com/x would be treated as an absolute URL by navigate. A one-line guard keeps the contract robust against that drift.

🛡️ Proposed hardening
 function getRedirectFrom(state: unknown): string {
   const parsed = loginStateSchema.safeParse(state);
-  return parsed.success ? (parsed.data?.from ?? '/') : '/';
+  const from = parsed.success ? parsed.data?.from : undefined;
+  return from && from.startsWith('/') && !from.startsWith('//') ? from : '/';
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/routes/Login.tsx` around lines 16 - 21, getRedirectFrom
currently accepts any string for the "from" field (loginStateSchema) which could
allow absolute URLs like "//evil.com" to be treated as safe redirects; tighten
the schema or add a one-line guard in getRedirectFrom to only accept internal
paths (e.g., require strings that start with a single "/" and do not start with
"//" and do not contain "://"); update loginStateSchema (or validate inside
getRedirectFrom) to enforce that constraint and fall back to "/" when the value
fails validation so navigate never receives an external URL.
backend/src/auth/auth.controller.ts (2)

22-32: Optional: lift the UserRole import to the top.

Inline import('@/user/user.entity').UserRole works but is harder to scan; a regular import type { UserRole } from '@/user/user.entity' at the top keeps types consistent with the rest of the file. Even simpler: type the parameter as Pick<User, 'id' | 'name' | 'role'>.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/auth/auth.controller.ts` around lines 22 - 32, The inline type
import for UserRole in toUserResponseDto makes the signature harder to read;
replace the inline import by adding a top-level type import (e.g. import type {
UserRole, User } from '@/user/user.entity') and then change the parameter type
to either Pick<User, 'id' | 'name' | 'role'> or use UserRole explicitly, keeping
the function name toUserResponseDto and return type UserResponseDto unchanged;
update the file-level imports and the function signature accordingly so types
are consistent and easier to scan.

41-81: Nice — previous review concerns are all addressed (DTO, discriminated errors, /auth/me upsert).

Small follow-up: login and getMe are byte-identical apart from the decorator. Extracting the upsert + error mapping keeps the two endpoints from drifting when the error matrix grows.

♻️ Suggested dedup
+  private async upsertAndMap(req: FirebaseRequest): Promise<UserResponseDto> {
+    const result = await this.userService.upsertByGoogleWorkspaceToken(
+      req.firebaseUser
+    );
+    if ('error' in result) {
+      if (result.error === 'no-google-identity') {
+        throw new BadRequestException(
+          'Token does not contain a Google identity'
+        );
+      }
+      throw new NotFoundException('User not found in employee directory');
+    }
+    return toUserResponseDto(result.user);
+  }
+
   `@Post`('login')
   `@HttpCode`(HttpStatus.OK)
   `@ApiOkResponse`({ type: UserResponseDto })
-  async login(`@Req`() req: FirebaseRequest): Promise<UserResponseDto> {
-    const result = await this.userService.upsertByGoogleWorkspaceToken(
-      req.firebaseUser
-    );
-    if ('error' in result) {
-      if (result.error === 'no-google-identity') {
-        throw new BadRequestException(
-          'Token does not contain a Google identity'
-        );
-      }
-      throw new NotFoundException('User not found in employee directory');
-    }
-    return toUserResponseDto(result.user);
-  }
+  login(`@Req`() req: FirebaseRequest): Promise<UserResponseDto> {
+    return this.upsertAndMap(req);
+  }
@@
   `@Get`('me')
   `@ApiOkResponse`({ type: UserResponseDto })
-  async getMe(`@Req`() req: FirebaseRequest): Promise<UserResponseDto> {
-    const result = await this.userService.upsertByGoogleWorkspaceToken(
-      req.firebaseUser
-    );
-    if ('error' in result) {
-      if (result.error === 'no-google-identity') {
-        throw new BadRequestException(
-          'Token does not contain a Google identity'
-        );
-      }
-      throw new NotFoundException('User not found in employee directory');
-    }
-    return toUserResponseDto(result.user);
-  }
+  getMe(`@Req`() req: FirebaseRequest): Promise<UserResponseDto> {
+    return this.upsertAndMap(req);
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/auth/auth.controller.ts` around lines 41 - 81, login and getMe
each call userService.upsertByGoogleWorkspaceToken(req.firebaseUser) and
duplicate the same error mapping and return logic; extract that shared logic
into a single private helper (e.g., resolveUserFromFirebase or
handleUpsertAndMapErrors) that calls userService.upsertByGoogleWorkspaceToken,
maps the discriminated errors ('no-google-identity' -> BadRequestException,
other -> NotFoundException) and returns toUserResponseDto(result.user); then
have both login and getMe simply call the new helper with req.firebaseUser to
avoid drift and centralize error handling.
frontend/src/lib/providers/auth/AuthProvider.tsx (1)

24-26: toUser is an identity function — consider dropping it or making it a real mapper.

Today toUser(fbUser) just returns fbUser with a narrowed type, which means User and FirebaseUser have to stay structurally identical for this to keep compiling. Either:

  • remove the helper and assign FirebaseUser directly (the call sites already rely on structural compatibility), or
  • make User a genuinely narrower shape ({ uid, email, displayName, ... }) and have toUser actually project to it — which gives you a real boundary against Firebase SDK drift.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/lib/providers/auth/AuthProvider.tsx` around lines 24 - 26,
toUser currently just returns its FirebaseUser input, providing no real mapping
between FirebaseUser and your app-level User; either remove toUser and use
FirebaseUser directly everywhere, or make User a narrower type (e.g., only uid,
email, displayName, photoURL) and implement toUser to project fbUser -> { uid:
fbUser.uid, email: fbUser.email, displayName: fbUser.displayName, ... } so call
sites receive the concrete User shape; update any usages that assume structural
identity to use the new User properties or the original FirebaseUser as
appropriate and adjust types for toUser, User, and FirebaseUser accordingly.
backend/src/user/user.service.ts (1)

56-97: Recommend extracting the Google UID extraction.

getUserByGoogleWorkspaceUid and upsertByGoogleWorkspaceToken repeat the same identity-array cast and ?.[0] lookup. A private helper keeps the shape assumptions (and any future email/iss-based fallbacks) in one place.

♻️ Suggested helper
+  private extractGoogleUid(token: {
+    firebase?: { identities?: Record<string, unknown> };
+  }): string | null {
+    const uid = (
+      token.firebase?.identities?.['google.com'] as string[] | undefined
+    )?.[0];
+    return uid ?? null;
+  }
+
   async getUserByGoogleWorkspaceUid(token: {
     uid: string;
     firebase?: { identities?: Record<string, unknown> };
   }): Promise<User | null> {
-    const googleUid = (
-      token.firebase?.identities?.['google.com'] as string[] | undefined
-    )?.[0];
+    const googleUid = this.extractGoogleUid(token);
     if (!googleUid) {
       return null;
     }
     return this.getUserByEmployeeId(googleUid);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/user/user.service.ts` around lines 56 - 97, Create a private
helper (e.g., private getGoogleUidFromToken(token: { uid: string; firebase?: {
identities?: Record<string, unknown> } }): string | undefined) that encapsulates
the cast and first-element lookup currently duplicated in
getUserByGoogleWorkspaceUid and upsertByGoogleWorkspaceToken (i.e., return
(token.firebase?.identities?.['google.com'] as string[] | undefined)?.[0]).
Replace the duplicated logic in getUserByGoogleWorkspaceUid to call this helper
and return null when it yields undefined, and in upsertByGoogleWorkspaceToken
call the helper and return { error: 'no-google-identity' } when it yields
undefined; preserve all other behavior (use existing
employeeService.getEmployee, role assignment, and repository.save).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.env.example:
- Line 15: The FIRST_ADMIN_EMAIL check currently compares employee.email ===
process.env.FIRST_ADMIN_EMAIL case-sensitively which can silently fail; update
the comparison in backend/src/user/user.service.ts (the block that promotes the
first admin around the employee.email check) to normalize both sides with
.trim().toLowerCase() before comparing (e.g., normalize employee.email and
process.env.FIRST_ADMIN_EMAIL) so emails match regardless of case/whitespace,
and add a short comment in .env.example next to FIRST_ADMIN_EMAIL noting it must
match the first-provisioned admin email (case/whitespace are ignored) and that
it only applies during initial provisioning.

In `@backend/src/datasource.ts`:
- Around line 22-24: Add a new baseline migration file named with an earlier
timestamp (e.g., 1776082215807-InitialSchema.ts) that runs before the existing
1776082215808-InitialSchema.ts and creates the missing tables (at minimum the
"user" table) referenced by your entities; implement proper up() to CREATE TABLE
"user" (and any other entity tables used by later migrations) and down() to DROP
them, so that with the current TypeORM config (migrationsRun: true and the
migrations array in datasource.ts) the ALTER in 1776082215808-InitialSchema.ts
will succeed on clean DBs. Ensure the migration class name and filename are
unique and the SQL/schema matches your entity definitions.

In `@backend/src/user/user.controller.ts`:
- Around line 68-81: The update allows an admin to demote themselves or remove
the last admin; modify the updateUserRole flow to prevent that: change the
updateUserRole signature (and update the controller call in updateRole) to
accept the acting user id (e.g., currentUserId from request) so the service can
detect self-demotion, then inside userService.updateUserRole check (1) if id ===
currentUserId and the new role is not UserRole.Admin then reject with a
Forbidden/BadRequest, and (2) before applying a demotion of any admin,
query/count current admins (e.g., via userRepository.count({ role:
UserRole.Admin })) and reject any change that would result in zero admins; keep
throwing UnknownUserException when the target user is missing and use
appropriate HTTP error for forbidden actions.

In `@backend/src/user/user.service.ts`:
- Around line 69-97: upsertByGoogleWorkspaceToken currently does a
check-then-act (calls getUserByEmployeeId then userRepository.save) which races
and can throw a unique-constraint QueryFailedError; change it to either (A)
perform an atomic upsert via TypeORM's upsert([], { conflictPaths:
['employee_id'] }) on the User entity instead of save, or (B) wrap the save in a
try/catch that detects the DB unique-violation (e.g., Postgres 23505 /
QueryFailedError) and on conflict re-read the existing row with
getUserByEmployeeId and return that user; also extract the googleUid extraction
logic used in upsertByGoogleWorkspaceToken into a small shared helper and reuse
it from getUserByGoogleWorkspaceUid to avoid duplication.

In `@backend/test/auth.e2e-spec.ts`:
- Around line 62-64: The afterEach hook currently calls await app.close()
unguarded; if buildApp()/beforeAll failed and app is undefined this masks the
original error. Modify the afterEach to first check that the test app is
initialized and has a close method (e.g. if (app && typeof app.close ===
'function') await app.close()) so it only calls close when safe; reference the
afterEach hook and the app variable created by buildApp()/beforeAll to locate
where to apply this guard.

In `@frontend/src/components/AdminRoute.tsx`:
- Around line 15-17: AdminRoute currently returns <Navigate to='/login' />
without using replace or passing the original location, so unauthenticated users
create an extra history entry and Login.tsx cannot redirect back; update the
AdminRoute component to read the current location via useLocation() and return
<Navigate to='/login' replace state={{ from: location.pathname }} /> (mirror the
behavior used by ProtectedRoute) so the login flow can redirect back to the
attempted /admin route.

---

Nitpick comments:
In `@backend/src/admin/admin.module.ts`:
- Line 11: Remove the redundant RolesGuard entry from the AdminModule providers
array: in admin.module.ts, delete RolesGuard from the providers list (leaving
AdminService) since RolesGuard is already provided in UserModule and applied via
`@UseGuards`(AuthGuard, RolesGuard) at controller level; this keeps DI behavior
unchanged while avoiding duplicate registration.

In `@backend/src/auth/auth.controller.ts`:
- Around line 22-32: The inline type import for UserRole in toUserResponseDto
makes the signature harder to read; replace the inline import by adding a
top-level type import (e.g. import type { UserRole, User } from
'@/user/user.entity') and then change the parameter type to either Pick<User,
'id' | 'name' | 'role'> or use UserRole explicitly, keeping the function name
toUserResponseDto and return type UserResponseDto unchanged; update the
file-level imports and the function signature accordingly so types are
consistent and easier to scan.
- Around line 41-81: login and getMe each call
userService.upsertByGoogleWorkspaceToken(req.firebaseUser) and duplicate the
same error mapping and return logic; extract that shared logic into a single
private helper (e.g., resolveUserFromFirebase or handleUpsertAndMapErrors) that
calls userService.upsertByGoogleWorkspaceToken, maps the discriminated errors
('no-google-identity' -> BadRequestException, other -> NotFoundException) and
returns toUserResponseDto(result.user); then have both login and getMe simply
call the new helper with req.firebaseUser to avoid drift and centralize error
handling.

In `@backend/src/auth/auth.guard.ts`:
- Around line 12-16: Hardcoded domain "@profiq.com" in the auth guard causes
FE/BE drift and auth failures; extract this domain into a centralized backend
config (e.g., add a DOMAIN or ALLOWED_EMAIL_DOMAIN constant in
backend/src/config/configuration.ts) and import that constant into auth.guard.ts
and employee.service.ts (referencing the token check in AuthGuard and any domain
logic in EmployeeService) so both use the same source of truth. Also update
AuthService.verifyToken() to throw an error for missing/invalid tokens instead
of returning { email: undefined }, and change the guard (AuthGuard) to catch
that error and rethrow a NestJS UnauthorizedException (401) rather than
returning false, ensuring authentication failures produce 401s not 403s.

In `@backend/src/db/migrations/1776082215808-InitialSchema.ts`:
- Around line 1-16: Rename the migration file to match the class name to avoid
confusion: change the filename from 1776082215808-InitialSchema.ts to
1776082215808-AddRoleToUser.ts and ensure the exported class name
AddRoleToUser1776082215808 remains unchanged so tooling and humans can match
file ↔ class (no code changes needed inside the file).

In `@backend/src/main.ts`:
- Line 23: The global ValidationPipe is instantiated with defaults which allow
unknown properties and do not transform payloads; update the instantiation where
app.useGlobalPipes(new ValidationPipe()) is called to enable security-focused
options: set whitelist: true, forbidNonWhitelisted: true, and transform: true so
DTOs (e.g., UpdateRoleRequest) are transformed and extraneous fields are
stripped/forbidden and validation like `@IsEnum`(UserRole) operates on coerced
values; keep the same ValidationPipe usage but pass the options object to
enforce these rules globally.

In `@backend/src/user/user.service.spec.ts`:
- Around line 29-105: Add comprehensive unit tests for
UserService.upsertByGoogleWorkspaceToken covering the FIRST_ADMIN_EMAIL branch:
write tests for (1) token missing google.com identity returns { error:
'no-google-identity' }, (2) existing user in repository is returned unchanged
(preserves stored role), (3) when employeeService cannot find user in directory
return { error: 'not-in-directory' }, (4) new user whose email matches
process.env.FIRST_ADMIN_EMAIL is saved with role UserRole.Admin, (5) new user
whose email does not match is saved with role UserRole.User, and (6) when
process.env.FIRST_ADMIN_EMAIL is unset no escalation to Admin occurs; use the
existing mocks (mockRepository, mockEmployeeService) and methods
(mockRepository.findOne, mockRepository.save,
mockEmployeeService.getByWorkspaceId) and assert returned values and that save
is called with expected role.

In `@backend/src/user/user.service.ts`:
- Around line 56-97: Create a private helper (e.g., private
getGoogleUidFromToken(token: { uid: string; firebase?: { identities?:
Record<string, unknown> } }): string | undefined) that encapsulates the cast and
first-element lookup currently duplicated in getUserByGoogleWorkspaceUid and
upsertByGoogleWorkspaceToken (i.e., return
(token.firebase?.identities?.['google.com'] as string[] | undefined)?.[0]).
Replace the duplicated logic in getUserByGoogleWorkspaceUid to call this helper
and return null when it yields undefined, and in upsertByGoogleWorkspaceToken
call the helper and return { error: 'no-google-identity' } when it yields
undefined; preserve all other behavior (use existing
employeeService.getEmployee, role assignment, and repository.save).

In `@frontend/src/components/AdminRoute.tsx`:
- Around line 18-27: The Access Denied block in the AdminRoute component (the
conditional that checks role !== 'admin') should provide a recovery path and
semantic landmark: wrap the returned JSX in a <main role="main"> landmark and
add a visible "Go home" link or button that navigates to the safe route (e.g.,
"/") — ensure the control uses a semantic element (anchor or button) with an
accessible label/aria-label and keyboard-focusable styling; update AdminRoute's
return for non-admins to include these changes so screen-reader users and
accidental visitors can recover.

In `@frontend/src/components/navigation-menu-reference.tsx`:
- Line 49: Replace the hard-coded role string check (role === 'admin') with a
comparison against the shared UserRole constant/enum from your auth context;
import UserRole from "@/lib/contexts" and use the appropriate member (e.g.,
UserRole.Admin or UserRole.ADMIN) in the conditional where the variable role is
checked to prevent drift if the role string changes.

In `@frontend/src/components/ProtectedRoute.tsx`:
- Around line 20-24: The current redirect drops query and hash because
ProtectedRoute sets state={{ from: location.pathname }}; update the Navigate
state to include the full location (e.g., state={{ from: location.pathname +
location.search + location.hash }} or simply state={{ from: location }}) so
post-login redirects restore query params and fragment; change the state passed
to Navigate in ProtectedRoute.tsx accordingly.

In `@frontend/src/lib/contexts.tsx`:
- Around line 9-35: The UserRole string union duplicates the backend UserRole
enum which will drift if roles change; replace the local UserRole declaration by
importing the shared/generated role type or constants and update usages (e.g.,
the UserRole type and any references inside AuthContextType) to use that single
source of truth (or wire up a codegen step that emits a frontend type from the
backend enum) so frontend and backend stay in sync.

In `@frontend/src/lib/providers/auth/AuthProvider.tsx`:
- Around line 24-26: toUser currently just returns its FirebaseUser input,
providing no real mapping between FirebaseUser and your app-level User; either
remove toUser and use FirebaseUser directly everywhere, or make User a narrower
type (e.g., only uid, email, displayName, photoURL) and implement toUser to
project fbUser -> { uid: fbUser.uid, email: fbUser.email, displayName:
fbUser.displayName, ... } so call sites receive the concrete User shape; update
any usages that assume structural identity to use the new User properties or the
original FirebaseUser as appropriate and adjust types for toUser, User, and
FirebaseUser accordingly.

In `@frontend/src/lib/providers/auth/domain.ts`:
- Around line 3-8: DOMAIN is hardcoded and parsing uses split('@')[1], causing
FE/BE drift and incorrect parsing for addresses with multiple '@'; change DOMAIN
to read from the environment (e.g., import.meta.env.VITE_ALLOWED_EMAIL_DOMAIN
with a sensible fallback) and update checkDomain to compute the domain using
email.slice(email.lastIndexOf('@') + 1) (handle undefined/empty email) and
compare lowercased/trimmed values against DOMAIN so frontend matches backend
semantics; update references to DOMAIN and the checkDomain(UserInfo) function
accordingly.

In `@frontend/src/routes/Login.tsx`:
- Around line 16-21: getRedirectFrom currently accepts any string for the "from"
field (loginStateSchema) which could allow absolute URLs like "//evil.com" to be
treated as safe redirects; tighten the schema or add a one-line guard in
getRedirectFrom to only accept internal paths (e.g., require strings that start
with a single "/" and do not start with "//" and do not contain "://"); update
loginStateSchema (or validate inside getRedirectFrom) to enforce that constraint
and fall back to "/" when the value fails validation so navigate never receives
an external URL.

In `@frontend/src/services/auth/auth.ts`:
- Around line 16-38: Extract a small helper (e.g., handleApiResponse or
unwrapApiResponse) that accepts an APIResponse<DbUser> returned from
APIClient.fetch and centralizes the success check: if result.success return
result.data, otherwise throw createError(result.status_code,
result.error?.message ?? 'Unknown error'); then update loginToBackend and getMe
to call this helper after their client.fetch calls instead of duplicating the
success/error logic so you remove copy/paste and guard against missing
error.message from APIResponse.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: db01a925-ff29-4e99-929a-6dfce0f5d800

📥 Commits

Reviewing files that changed from the base of the PR and between e8b0bb7 and 470ae61.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (37)
  • .env.example
  • backend/src/admin/admin.controller.ts
  • backend/src/admin/admin.module.ts
  • backend/src/auth/auth.controller.spec.ts
  • backend/src/auth/auth.controller.ts
  • backend/src/auth/auth.guard.ts
  • backend/src/auth/auth.module.ts
  • backend/src/auth/roles.decorator.ts
  • backend/src/auth/roles.guard.spec.ts
  • backend/src/auth/roles.guard.ts
  • backend/src/datasource.ts
  • backend/src/db/migrations/1776082215808-InitialSchema.ts
  • backend/src/employee/employee.module.ts
  • backend/src/loans/loans.controller.spec.ts
  • backend/src/loans/loans.service.spec.ts
  • backend/src/main.ts
  • backend/src/user/dto/update_role.ts
  • backend/src/user/dto/user_response.dto.ts
  • backend/src/user/user.controller.ts
  • backend/src/user/user.entity.ts
  • backend/src/user/user.module.ts
  • backend/src/user/user.service.spec.ts
  • backend/src/user/user.service.ts
  • backend/test/auth.e2e-spec.ts
  • backend/test/user.e2e-spec.ts
  • frontend/src/App.tsx
  • frontend/src/components/AdminRoute.tsx
  • frontend/src/components/ProtectedRoute.component-spec.tsx
  • frontend/src/components/ProtectedRoute.tsx
  • frontend/src/components/navigation-menu-reference.tsx
  • frontend/src/lib/contexts.tsx
  • frontend/src/lib/providers/auth/AuthProvider.tsx
  • frontend/src/lib/providers/auth/AuthProvider.unit-spec.tsx
  • frontend/src/lib/providers/auth/domain.ts
  • frontend/src/routes/Login.component-spec.tsx
  • frontend/src/routes/Login.tsx
  • frontend/src/services/auth/auth.ts

Comment thread .env.example
GOOGLE_CLIENT_EMAIL=
GOOGLE_PRIVATE_KEY=
VITE_API_URL=http://localhost:3000
FIRST_ADMIN_EMAIL=
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Admin bootstrap compared case-sensitively — risk of silent misconfiguration.

The consumer in backend/src/user/user.service.ts (lines 69‑97) does employee.email === process.env.FIRST_ADMIN_EMAIL with no trim or case-folding. Email local-parts are case-insensitive for all practical providers (and Google normalizes), so a value like Admin@profiq.com in .env will never promote admin@profiq.com to Admin, and the misconfiguration is silent (user is created as a regular user and the env value is ignored on subsequent runs because the user already exists).

Recommend normalizing both sides at the comparison site (toLowerCase().trim()) and/or documenting the expected format here in .env.example. Also consider that once the first admin is created, this env var becomes dead weight — worth a short comment near the variable documenting that it only applies to the first-ever provisioning of that user.

🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 15-15: [UnorderedKey] The FIRST_ADMIN_EMAIL key should go before the GOOGLE_CLIENT_EMAIL key

(UnorderedKey)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.env.example at line 15, The FIRST_ADMIN_EMAIL check currently compares
employee.email === process.env.FIRST_ADMIN_EMAIL case-sensitively which can
silently fail; update the comparison in backend/src/user/user.service.ts (the
block that promotes the first admin around the employee.email check) to
normalize both sides with .trim().toLowerCase() before comparing (e.g.,
normalize employee.email and process.env.FIRST_ADMIN_EMAIL) so emails match
regardless of case/whitespace, and add a short comment in .env.example next to
FIRST_ADMIN_EMAIL noting it must match the first-provisioned admin email
(case/whitespace are ignored) and that it only applies during initial
provisioning.

Comment thread backend/src/datasource.ts
Comment on lines +22 to +24
synchronize: false,
migrations: [__dirname + '/db/migrations/**/*{.js,.ts}'],
migrationsRun: true,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -t f . backend/src/db/migrations
echo '---'
rg -n "CREATE TABLE|createTable" backend/src/db/migrations

Repository: profiq/items-management-app

Length of output: 131


🏁 Script executed:

cat -n backend/src/db/migrations/1776082215808-InitialSchema.ts

Repository: profiq/items-management-app

Length of output: 750


🏁 Script executed:

cat -n backend/src/datasource.ts

Repository: profiq/items-management-app

Length of output: 1141


🏁 Script executed:

find backend/src -name "*migration*" -o -name "*schema*" | head -20

Repository: profiq/items-management-app

Length of output: 96


🏁 Script executed:

rg -l "CREATE TABLE" backend/src --type ts

Repository: profiq/items-management-app

Length of output: 53


Add baseline schema migration that creates the user table before AddRoleToUser runs.

With synchronize: false + migrationsRun: true, the single migration 1776082215808-InitialSchema.ts attempts to alter the user table on fresh databases, but that table is never created. The migration only executes ALTER TABLE "user" ADD COLUMN "role" — it will fail immediately on clean SQLite DBs (dev, CI, e2e) because the user table does not exist.

Create a baseline schema migration (e.g., 1776082215807-InitialSchema.ts) that runs before the current one and creates all required tables (at minimum user and any others referenced by entities).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/datasource.ts` around lines 22 - 24, Add a new baseline migration
file named with an earlier timestamp (e.g., 1776082215807-InitialSchema.ts) that
runs before the existing 1776082215808-InitialSchema.ts and creates the missing
tables (at minimum the "user" table) referenced by your entities; implement
proper up() to CREATE TABLE "user" (and any other entity tables used by later
migrations) and down() to DROP them, so that with the current TypeORM config
(migrationsRun: true and the migrations array in datasource.ts) the ALTER in
1776082215808-InitialSchema.ts will succeed on clean DBs. Ensure the migration
class name and filename are unique and the SQL/schema matches your entity
definitions.

Comment on lines +68 to +81
@Patch(':id/role')
@UseGuards(RolesGuard)
@Roles(UserRole.Admin)
@ApiOkResponse({ type: User })
async updateRole(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdateRoleRequest
): Promise<User> {
const user = await this.userService.updateUserRole(id, body.role);
if (!user) {
throw new UnknownUserException();
}
return user;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP 'useGlobalPipes|ValidationPipe' backend/src/main.ts
rg -nP '@IsEnum|UpdateRoleRequest' backend/src/user/dto/update_role.ts

Repository: profiq/items-management-app

Length of output: 225


🏁 Script executed:

rg -A 20 'updateUserRole' backend/src/user/user.service.ts

Repository: profiq/items-management-app

Length of output: 555


Implement safeguards against admin self-demotion and last-admin lockout.

The updateUserRole method has no checks to prevent an admin from demoting themselves or removing the last admin from the system. Add validation in the service to reject self-demotion and reject any role change that would leave zero admins.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/user/user.controller.ts` around lines 68 - 81, The update allows
an admin to demote themselves or remove the last admin; modify the
updateUserRole flow to prevent that: change the updateUserRole signature (and
update the controller call in updateRole) to accept the acting user id (e.g.,
currentUserId from request) so the service can detect self-demotion, then inside
userService.updateUserRole check (1) if id === currentUserId and the new role is
not UserRole.Admin then reject with a Forbidden/BadRequest, and (2) before
applying a demotion of any admin, query/count current admins (e.g., via
userRepository.count({ role: UserRole.Admin })) and reject any change that would
result in zero admins; keep throwing UnknownUserException when the target user
is missing and use appropriate HTTP error for forbidden actions.

Comment on lines +69 to +97
async upsertByGoogleWorkspaceToken(token: {
uid: string;
firebase?: { identities?: Record<string, unknown> };
}): Promise<UpsertResult> {
const googleUid = (
token.firebase?.identities?.['google.com'] as string[] | undefined
)?.[0];
if (!googleUid) {
return { error: 'no-google-identity' };
}
const existing = await this.getUserByEmployeeId(googleUid);
if (existing) {
return { user: existing };
}
const employee = await this.employeeService.getEmployee(googleUid);
if (!employee) {
return { error: 'not-in-directory' };
}
const user = new User();
user.employee_id = googleUid;
user.name = employee.name;
user.role =
process.env.FIRST_ADMIN_EMAIL &&
employee.email === process.env.FIRST_ADMIN_EMAIL
? UserRole.Admin
: UserRole.User;
await this.userRepository.save(user);
return { user };
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

TypeORM 0.3.28 Repository.upsert conflictPaths return type

💡 Result:

In TypeORM 0.3.28, the Repository.upsert method, including when using the conflictPaths option, returns Promise<InsertResult>.

Citations:


🏁 Script executed:

# Find the user entity file
fd -e ts "user.entity" backend/

Repository: profiq/items-management-app

Length of output: 102


🏁 Script executed:

# Check the package.json for TypeORM version
cat backend/package.json | grep -i typeorm -A 2 -B 2

Repository: profiq/items-management-app

Length of output: 1046


🏁 Script executed:

# Look at the actual user.service.ts code around the target lines
cat -n backend/src/user/user.service.ts | sed -n '60,110p'

Repository: profiq/items-management-app

Length of output: 1878


🏁 Script executed:

# Search for unique constraints in user entity
rg "unique|constraint" backend/src/user/ -t ts -A 2 -B 2

Repository: profiq/items-management-app

Length of output: 310


🏁 Script executed:

# Check TypeORM 0.3.28 Repository for upsert method signature and conflictPaths support
cat backend/node_modules/typeorm/repository/Repository.d.ts 2>/dev/null | grep -A 20 "upsert(" || echo "Node modules not available, will use web search"

Repository: profiq/items-management-app

Length of output: 118


🌐 Web query:

TypeORM 0.3.28 Repository upsert conflictPaths method signature documentation

💡 Result:

In TypeORM 0.3.28, the conflictPaths property is part of the UpsertOptions interface used in the Repository.upsert method. Method usage example from official docs: await repository.upsert( [{ externalId: "abc123", firstName: "Rizzrak" }], { conflictPaths: ["externalId"] } ); Type definition from typeorm@0.3.28 UpsertOptions.d.ts: export interface UpsertOptions extends InsertOrUpdateOptions { conflictPaths: string[] | { [P in keyof Entity]?: true }; // ... other properties } conflictPaths specifies the columns/fields for conflict detection in the upsert operation (e.g., ON CONFLICT clause in PostgreSQL). It supports either an array of property path strings (e.g., ["externalId"] or ["user.id"] for relations) or a typed object selecting entity keys (e.g., { externalId: true }), added in earlier versions via PR #9365 and included in 0.3.28 (released Dec 2025). This signature has remained stable since its introduction, with no breaking changes noted in the 0.3.28 changelog.

Citations:


🏁 Script executed:

# Verify QueryFailedError or similar is what would be thrown on unique violation
rg "QueryFailedError|UniqueConstraintFailed" backend/ -t ts -A 2 -B 2 || echo "No existing error handling found"

Repository: profiq/items-management-app

Length of output: 103


🏁 Script executed:

# Check for database migrations that might define the unique constraint
find backend/src/db/migrations -name "*.ts" -type f | head -5 && \
cat backend/src/db/migrations/*.ts 2>/dev/null | grep -i "unique\|employee_id" -B 3 -A 3 | head -40

Repository: profiq/items-management-app

Length of output: 127


🏁 Script executed:

# Verify the actual entity decorator usage
cat -n backend/src/user/user.entity.ts | head -50

Repository: profiq/items-management-app

Length of output: 810


🌐 Web query:

TypeORM sqlite3 unique constraint violation error handling save() method

💡 Result:

To handle unique constraint violation errors from TypeORM's save method with SQLite3, catch the QueryFailedError and check the error code. The save method throws QueryFailedError when a database constraint is violated during insert or update operations. For SQLite unique constraint violations, the error has code: 'SQLITE_CONSTRAINT' and errno: 19. Example handling code: typescript import { QueryFailedError } from 'typeorm'; try { await repository.save(entity); } catch (error) { if (error instanceof QueryFailedError) { if (error.code === 'SQLITE_CONSTRAINT') { // Handle unique constraint violation console.log('Unique constraint violation:', error.message); // Optionally check driverError for more details throw new Error('Duplicate entry - unique constraint violated'); } } throw error; // Re-throw if not the expected error } Key points: - QueryFailedError wraps the driver-specific error (driverError property contains SQLite error details). - SQLite returns 'SQLITE_CONSTRAINT' for UNIQUE, PRIMARY KEY, CHECK, and FOREIGN KEY violations. - Distinguish unique violations by inspecting error.message (contains "UNIQUE constraint failed") or driverError details. - Common causes: Attempting to insert duplicate unique/primary key values, or save bugs with composite uniques/relations where it tries INSERT instead of UPDATE. For prevention: - Use findOne before save to check existence manually if save behavior is unreliable with composites. - Define @Unique decorators properly on entities. - Consider QueryBuilder with onConflict for upsert (PostgreSQL-specific; SQLite limited support). This approach works across TypeORM versions as shown in multiple GitHub issues and Stack Overflow examples.

Citations:


Race on concurrent first logins can 500 on a unique-constraint violation.

getUserByEmployeeIduserRepository.save(new User) is a check-then-act without a transaction or unique-violation handler. Two near-simultaneous first logins for the same employee (e.g. multiple tabs opening during onboarding, or a retry after a slow popup) can both observe existing === undefined and both call save; the second hits the unique index on employee_id and surfaces as a QueryFailedError (500) to the client instead of the intended { user } response.

Two reasonable fixes:

  • Catch the unique-violation error after save and re-read the existing row:
🛡️ Proposed fix
     const employee = await this.employeeService.getEmployee(googleUid);
     if (!employee) {
       return { error: 'not-in-directory' };
     }
     const user = new User();
     user.employee_id = googleUid;
     user.name = employee.name;
     user.role =
       process.env.FIRST_ADMIN_EMAIL &&
       employee.email === process.env.FIRST_ADMIN_EMAIL
         ? UserRole.Admin
         : UserRole.User;
-    await this.userRepository.save(user);
-    return { user };
+    try {
+      await this.userRepository.save(user);
+      return { user };
+    } catch (err) {
+      // Concurrent first login may have inserted the row first.
+      const raced = await this.getUserByEmployeeId(googleUid);
+      if (raced) {
+        return { user: raced };
+      }
+      throw err;
+    }
  • Or use TypeORM 0.3.28's upsert([], { conflictPaths: ['employee_id'] }) for atomic conflict resolution.

Minor: googleUid extraction (lines 73–75) duplicates the logic from getUserByGoogleWorkspaceUid (lines 60–66). Consider a shared helper.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/user/user.service.ts` around lines 69 - 97,
upsertByGoogleWorkspaceToken currently does a check-then-act (calls
getUserByEmployeeId then userRepository.save) which races and can throw a
unique-constraint QueryFailedError; change it to either (A) perform an atomic
upsert via TypeORM's upsert([], { conflictPaths: ['employee_id'] }) on the User
entity instead of save, or (B) wrap the save in a try/catch that detects the DB
unique-violation (e.g., Postgres 23505 / QueryFailedError) and on conflict
re-read the existing row with getUserByEmployeeId and return that user; also
extract the googleUid extraction logic used in upsertByGoogleWorkspaceToken into
a small shared helper and reuse it from getUserByGoogleWorkspaceUid to avoid
duplication.

Comment on lines +62 to +64
afterEach(async () => {
await app.close();
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor: guard afterEach against an un-initialised app.

If a beforeAll/buildApp call ever fails, app will be undefined and afterEach throws Cannot read properties of undefined (reading 'close'), masking the real failure.

🛡️ Suggested guard
 afterEach(async () => {
-  await app.close();
+  if (app) {
+    await app.close();
+  }
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
afterEach(async () => {
await app.close();
});
afterEach(async () => {
if (app) {
await app.close();
}
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/test/auth.e2e-spec.ts` around lines 62 - 64, The afterEach hook
currently calls await app.close() unguarded; if buildApp()/beforeAll failed and
app is undefined this masks the original error. Modify the afterEach to first
check that the test app is initialized and has a close method (e.g. if (app &&
typeof app.close === 'function') await app.close()) so it only calls close when
safe; reference the afterEach hook and the app variable created by
buildApp()/beforeAll to locate where to apply this guard.

Comment on lines +15 to +17
if (!user) {
return <Navigate to='/login' />;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent redirect — missing replace and from state.

Unlike ProtectedRoute, this <Navigate> doesn't pass replace nor state={{ from: location.pathname }}. As a result, an unauthenticated user hitting /admin gets an extra history entry and — per Login.tsx which reads location.state.from — won't be routed back to /admin after signing in.

🛠️ Suggested fix
-import { Navigate, Outlet } from 'react-router';
+import { Navigate, Outlet, useLocation } from 'react-router';
@@
-  const { user, role, loading } = useAuth();
+  const { user, role, loading } = useAuth();
+  const location = useLocation();
@@
-  if (!user) {
-    return <Navigate to='/login' />;
-  }
+  if (!user) {
+    return (
+      <Navigate to='/login' state={{ from: location.pathname }} replace />
+    );
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/AdminRoute.tsx` around lines 15 - 17, AdminRoute
currently returns <Navigate to='/login' /> without using replace or passing the
original location, so unauthenticated users create an extra history entry and
Login.tsx cannot redirect back; update the AdminRoute component to read the
current location via useLocation() and return <Navigate to='/login' replace
state={{ from: location.pathname }} /> (mirror the behavior used by
ProtectedRoute) so the login flow can redirect back to the attempted /admin
route.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants