Feature/interview portal#25
Conversation
…rations feat: bootstrap interview portal with auth, schema, and app shell
📝 WalkthroughWalkthroughIntroduces an interview portal feature across backend and a new frontend app. The backend adds a Prisma schema migration with 8 new interview-domain models and enums, extends ChangesBackend: Interview Portal Data Model & Auth
Frontend: Interview Portal React App
Sequence DiagramsequenceDiagram
participant Browser
participant LoginPage
participant Backend
participant AuthCallbackPage
participant tokenStorage
participant api
Browser->>LoginPage: visit /login
LoginPage->>Browser: redirect to /auth/google
Browser->>Backend: GET /auth/google → Google OAuth
Backend-->>Browser: redirect /auth/callback?access=<body>&refresh=<body>
Note over Browser: ip_access_sig + ip_refresh_sig cookies set
Browser->>AuthCallbackPage: /auth/callback?access=...&refresh=...
AuthCallbackPage->>tokenStorage: set(accessBody, refreshBody)
AuthCallbackPage-->>Browser: navigate to role dashboard
Browser->>api: authenticated request
api->>Backend: with Authorization: Bearer <body.sig>
Backend-->>api: 401 (expired)
api->>Backend: POST /auth/refresh (Authorization: Bearer <refreshBody.sig>)
Backend-->>api: { accessBody } + new ip_access_sig cookie
api->>tokenStorage: setBody(accessBody)
api->>Backend: retry original request
Backend-->>Browser: 200 response
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 14
🧹 Nitpick comments (2)
apps/interview-portal-fe/src/components/icons/types.ts (1)
4-4: 💤 Low valueSimplify
aria-hiddentype toboolean.The union
boolean | 'true' | 'false'is redundant; React's aria-hidden prop acceptsbooleandirectly, and TypeScript'sbooleantype already coverstrueandfalseprimitive values. The string literals'true' | 'false'are unnecessary.♻️ Proposed simplification
- 'aria-hidden'?: boolean | 'true' | 'false' + 'aria-hidden'?: boolean🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/interview-portal-fe/src/components/icons/types.ts` at line 4, The aria-hidden property type in the types.ts file currently uses a redundant union type of boolean | 'true' | 'false'. Simplify this to just boolean since React's aria-hidden prop accepts boolean values directly, and the boolean type already encompasses the true and false primitive values, making the string literal types unnecessary. Replace the current aria-hidden type definition with a simple boolean type.apps/interview-portal-fe/src/pages/auth/AuthCallbackPage.tsx (1)
50-53: 💤 Low valueMinor:
tokenStorage.clearAll()is redundant here.Tokens haven't been stored yet at this point in the flow, so clearing them is a no-op. While defensive, this call can be removed for clarity.
♻️ Optional cleanup
if (!destination) { - tokenStorage.clearAll() void navigate(`${ROUTES.LOGIN}?error=not_registered`, { replace: true }) return }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/interview-portal-fe/src/pages/auth/AuthCallbackPage.tsx` around lines 50 - 53, The tokenStorage.clearAll() call in the destination check block within AuthCallbackPage.tsx is unnecessary because no tokens have been stored at this point in the authentication callback flow, making it a no-op. Remove the tokenStorage.clearAll() line from the if (!destination) block to improve code clarity while maintaining the same behavior of navigating to the login error page.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.claude/settings.json:
- Around line 9-10: The settings.json file contains hard-coded repo paths in the
git hook commands that only work for the specific absolute path
`/Users/gkmit/code/bb-sessioon/nest-assessment`. This breaks functionality in
other clones or environments. In both hook configurations (at lines 9-10 and
21-22), replace the hard-coded absolute repo path with a runtime-resolved repo
root path. Instead of using the fixed string path in the "command" field, use a
method or variable that dynamically determines the current repository root at
execution time so the hooks work correctly regardless of where the repository is
cloned.
In `@apps/backend/prisma/migrations/20260615040933_portal_phase1/migration.sql`:
- Around line 200-206: The migration creates a redundant index on
SessionLink.token field: a UNIQUE INDEX on line 203 provides index
functionality, making the additional regular INDEX on line 206 unnecessary and
wasteful. Remove the `@@index([token])` directive from the SessionLink model
definition in the Prisma schema file (typically schema.prisma) to eliminate this
redundancy, while keeping the unique constraint on the token field intact.
In `@apps/backend/src/modules/auth/auth.controller.ts`:
- Around line 83-94: The token splitting logic using parts[2] ?? '' masks
malformed JWT tokens by silently producing empty signatures instead of raising
errors. In apps/backend/src/modules/auth/auth.controller.ts at lines 83-94 in
the refresh() method, validate that accessToken.split('.') produces exactly 3
parts and throw an error if validation fails instead of using the empty string
fallback. Apply the same validation check for both access and refresh tokens in
apps/backend/src/modules/auth/auth.controller.ts at lines 186-191 in the
googleAuthCallback() method before extracting and using the signature parts.
In `@apps/backend/src/modules/auth/auth.service.ts`:
- Around line 245-259: The findUnique query in the user profile fetch does not
filter out soft-deleted users by checking for deletedAt, which allows
soft-deleted users with valid JWTs to access their profile. Add deletedAt: null
to the where clause of the findUnique query to ensure only non-deleted users can
retrieve their profile information, matching the pattern used in
refreshTokens().
In `@apps/interview-portal-fe/src/components/auth/PublicRoute.tsx`:
- Around line 10-11: The PublicRoute component creates an infinite redirect loop
by redirecting authenticated users with unmapped roles back to ROUTES.LOGIN.
Instead of using ROUTES.LOGIN as the default fallback destination in the
Navigate component on line 11, implement an alternative destination such as a
dedicated error or access-denied page, or clear the authentication tokens and
redirect to an auth-cleared state. Ensure the fallback destination properly
handles authenticated users with unknown or invalid roles without cycling them
back through the PublicRoute guard.
In `@apps/interview-portal-fe/src/components/icons/PortalLogoIcon.tsx`:
- Around line 5-12: The PortalLogoIcon component is missing the
aria-hidden="true" attribute on its SVG element, which creates an accessibility
inconsistency with other icon components in the library. Add aria-hidden="true"
to the SVG element in PortalLogoIcon to properly hide the decorative icon from
screen readers, ensuring it aligns with ARIA best practices and maintains
consistency with the rest of the icon library.
In `@apps/interview-portal-fe/src/components/ui/SkeletonRow.tsx`:
- Line 11: Replace the deprecated Tailwind CSS class `flex-shrink-0` with
`shrink-0` in the SkeletonRow.tsx Skeleton component. Additionally, search the
entire codebase for all occurrences of `flex-shrink-*` classes (including
flex-shrink-0 and flex-shrink variations) in the files mentioned: LoginPage.tsx,
AppLayout.tsx, PageHeader.tsx, Avatar.tsx, ExamsTable.tsx, and
ReadinessPanel.tsx, and replace each instance with the corresponding modern
Tailwind v4 equivalent (e.g., flex-shrink-0 becomes shrink-0, flex-shrink
becomes shrink). This aligns the codebase with Tailwind CSS v4 naming
conventions.
In `@apps/interview-portal-fe/src/hooks/useLogout.ts`:
- Around line 11-15: The useLogout hook currently only clears frontend state
(tokenStorage and queryClient) but does not invalidate server-set signature
cookies (ip_access_sig and ip_refresh_sig), which persist and allow session
continuation. Create a backend logout endpoint that expires these signature
cookies by setting maxAge: 0, then update the function returned by useLogout to
call this backend endpoint before executing tokenStorage.clearAll() and
queryClient.clear(). This ensures signature cookies are cleared server-side
during logout.
In `@apps/interview-portal-fe/src/layouts/InterviewerLayout.tsx`:
- Around line 17-22: The current check in InterviewerLayout.tsx uses a blocklist
approach by only blocking 'recruiter' and 'admin' roles, which allows any other
authenticated role to access the interviewer portal. Switch to an allowlist
approach by modifying the condition to explicitly verify that the payload role
is 'interviewer', and if it is not, redirect to an appropriate default route
(such as ROUTES.RECRUITER_DASHBOARD or a login route). This ensures only users
with the 'interviewer' role can render the AppLayout component in the
InterviewerLayout.
In `@apps/interview-portal-fe/src/layouts/RecruiterLayout.tsx`:
- Around line 19-24: The RecruiterLayout component uses a deny-list approach
that only blocks the 'interviewer' role, which allows unintended roles like
'candidate' and 'moderator' to access the recruiter portal. Replace the role
guard check with an allow-list pattern that explicitly permits only 'recruiter'
and 'admin' roles. The condition should check if the payload role exists and is
NOT included in the allowed roles list, redirecting to
ROUTES.INTERVIEWER_DASHBOARD if the check fails, matching the security pattern
already implemented in InterviewerLayout.
In `@apps/interview-portal-fe/src/lib/cookie.ts`:
- Around line 13-15: The get function in the cookie lookup constructs a RegExp
using the raw name parameter, which can fail if the cookie name contains regex
metacharacters. Replace the regex-based approach with a string split and
startsWith lookup instead: split document.cookie by semicolons and spaces, then
iterate through the resulting array to find a cookie entry that starts with the
desired name followed by an equals sign, extracting the value without relying on
regex construction.
In `@apps/interview-portal-fe/src/lib/token.ts`:
- Around line 14-20: The JwtPayload type definition includes email as a required
field, but the backend auth payload contract only guarantees sub and role (with
iat and exp). Make the email field optional in the JwtPayload type by changing
it to email?: string to align with the actual backend contract and prevent
undefined assumptions from leaking into role and layout flows.
- Around line 58-63: The decodeToken function uses atob() to decode the JWT
payload segment, but JWT payloads use base64url encoding (RFC 7518) which
replaces `+` with `-` and `/` with `_`, and omits padding. The atob() function
expects standard base64, so it fails on tokens with `-` or `_` characters. Fix
this by normalizing the base64url string before calling atob(): replace all `-`
characters with `+`, replace all `_` characters with `/`, add padding with `=`
characters as needed to make the length a multiple of 4, then pass the
normalized string to atob().
In `@apps/interview-portal-fe/vite.config.ts`:
- Around line 3-10: The `__dirname` variable used in the path.resolve call
within the alias configuration is undefined in ESM contexts, which will cause a
ReferenceError at runtime. To fix this, import `fileURLToPath` from the 'url'
module, then create an ESM-compatible `__dirname` replacement by combining
`fileURLToPath(import.meta.url)` with `path.dirname()`. Finally, replace the
existing `__dirname` reference in the alias configuration with this new
variable.
---
Nitpick comments:
In `@apps/interview-portal-fe/src/components/icons/types.ts`:
- Line 4: The aria-hidden property type in the types.ts file currently uses a
redundant union type of boolean | 'true' | 'false'. Simplify this to just
boolean since React's aria-hidden prop accepts boolean values directly, and the
boolean type already encompasses the true and false primitive values, making the
string literal types unnecessary. Replace the current aria-hidden type
definition with a simple boolean type.
In `@apps/interview-portal-fe/src/pages/auth/AuthCallbackPage.tsx`:
- Around line 50-53: The tokenStorage.clearAll() call in the destination check
block within AuthCallbackPage.tsx is unnecessary because no tokens have been
stored at this point in the authentication callback flow, making it a no-op.
Remove the tokenStorage.clearAll() line from the if (!destination) block to
improve code clarity while maintaining the same behavior of navigating to the
login error page.
🪄 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: 10e38df0-7e9c-4ef5-902c-a4ff3f4ae297
⛔ Files ignored due to path filters (2)
apps/interview-portal-fe/public/favicon.svgis excluded by!**/*.svgpnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (66)
.claude/settings.json.gitignoreapps/backend/package.jsonapps/backend/prisma/migrations/20260615040933_portal_phase1/migration.sqlapps/backend/prisma/schema.prismaapps/backend/src/modules/auth/auth.controller.tsapps/backend/src/modules/auth/auth.service.tsapps/backend/src/modules/auth/strategies/jwt.strategy.tsapps/interview-portal-fe/eslint.config.jsapps/interview-portal-fe/index.htmlapps/interview-portal-fe/package.jsonapps/interview-portal-fe/postcss.config.jsapps/interview-portal-fe/src/App.tsxapps/interview-portal-fe/src/components/auth/ProtectedRoute.tsxapps/interview-portal-fe/src/components/auth/PublicRoute.tsxapps/interview-portal-fe/src/components/icons/AlertCircleIcon.tsxapps/interview-portal-fe/src/components/icons/CalendarIcon.tsxapps/interview-portal-fe/src/components/icons/ClipboardListIcon.tsxapps/interview-portal-fe/src/components/icons/GoogleIcon.tsxapps/interview-portal-fe/src/components/icons/HomeIcon.tsxapps/interview-portal-fe/src/components/icons/LogOutIcon.tsxapps/interview-portal-fe/src/components/icons/MenuIcon.tsxapps/interview-portal-fe/src/components/icons/PortalLogoIcon.tsxapps/interview-portal-fe/src/components/icons/SparkleIcon.tsxapps/interview-portal-fe/src/components/icons/UsersIcon.tsxapps/interview-portal-fe/src/components/icons/XIcon.tsxapps/interview-portal-fe/src/components/icons/index.tsapps/interview-portal-fe/src/components/icons/types.tsapps/interview-portal-fe/src/components/ui/Avatar.tsxapps/interview-portal-fe/src/components/ui/Badge.tsxapps/interview-portal-fe/src/components/ui/EmptyState.tsxapps/interview-portal-fe/src/components/ui/LoadingSpinner.tsxapps/interview-portal-fe/src/components/ui/PageHeader.tsxapps/interview-portal-fe/src/components/ui/SkeletonRow.tsxapps/interview-portal-fe/src/components/ui/index.tsapps/interview-portal-fe/src/constants/env.tsapps/interview-portal-fe/src/constants/errors.tsapps/interview-portal-fe/src/constants/routes.tsapps/interview-portal-fe/src/hooks/useCurrentUser.tsapps/interview-portal-fe/src/hooks/useLogout.tsapps/interview-portal-fe/src/layouts/AppLayout.tsxapps/interview-portal-fe/src/layouts/InterviewerLayout.tsxapps/interview-portal-fe/src/layouts/RecruiterLayout.tsxapps/interview-portal-fe/src/lib/api.tsapps/interview-portal-fe/src/lib/cookie.tsapps/interview-portal-fe/src/lib/react-query.tsxapps/interview-portal-fe/src/lib/token.tsapps/interview-portal-fe/src/lib/utils.tsapps/interview-portal-fe/src/main.tsxapps/interview-portal-fe/src/pages/auth/AuthCallbackPage.tsxapps/interview-portal-fe/src/pages/auth/LoginPage.tsxapps/interview-portal-fe/src/pages/errors/NotFoundPage.tsxapps/interview-portal-fe/src/pages/interviewer/InterviewerDashboardPage.tsxapps/interview-portal-fe/src/pages/interviewer/InterviewerSessionsPage.tsxapps/interview-portal-fe/src/pages/recruiter/RecruiterCandidatesPage.tsxapps/interview-portal-fe/src/pages/recruiter/RecruiterDashboardPage.tsxapps/interview-portal-fe/src/pages/recruiter/RecruiterSchedulePage.tsxapps/interview-portal-fe/src/pages/recruiter/RecruiterTeamPage.tsxapps/interview-portal-fe/src/providers/AppProviders.tsxapps/interview-portal-fe/src/routes/index.tsxapps/interview-portal-fe/src/styles/globals.cssapps/interview-portal-fe/src/vite-env.d.tsapps/interview-portal-fe/tailwind.config.tsapps/interview-portal-fe/tsconfig.app.jsonapps/interview-portal-fe/tsconfig.jsonapps/interview-portal-fe/vite.config.ts
| "command": "cat >/dev/null || true; git rev-parse --git-dir >/dev/null 2>&1 && code-review-graph update --skip-flows --repo \"/Users/gkmit/code/bb-sessioon/nest-assessment\" || true", | ||
| "timeout": 30 |
There was a problem hiding this comment.
Remove the hard-coded repo path from both hooks.
These commands only work for /Users/gkmit/code/bb-sessioon/nest-assessment; in any other clone they’ll target the wrong repo or no-op. Resolve the repo root at runtime instead.
Suggested fix
- "command": "cat >/dev/null || true; git rev-parse --git-dir >/dev/null 2>&1 && code-review-graph update --skip-flows --repo \"/Users/gkmit/code/bb-sessioon/nest-assessment\" || true",
+ "command": "repo_root=\"$(git rev-parse --show-toplevel)\" && code-review-graph update --skip-flows --repo \"$repo_root\" || true",
...
- "command": "cat >/dev/null || true; git rev-parse --git-dir >/dev/null 2>&1 && code-review-graph status --repo \"/Users/gkmit/code/bb-sessioon/nest-assessment\" || echo 'Not a git repo, skipping'",
+ "command": "repo_root=\"$(git rev-parse --show-toplevel)\" && code-review-graph status --repo \"$repo_root\" || echo 'Not a git repo, skipping'",Also applies to: 21-22
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.claude/settings.json around lines 9 - 10, The settings.json file contains
hard-coded repo paths in the git hook commands that only work for the specific
absolute path `/Users/gkmit/code/bb-sessioon/nest-assessment`. This breaks
functionality in other clones or environments. In both hook configurations (at
lines 9-10 and 21-22), replace the hard-coded absolute repo path with a
runtime-resolved repo root path. Instead of using the fixed string path in the
"command" field, use a method or variable that dynamically determines the
current repository root at execution time so the hooks work correctly regardless
of where the repository is cloned.
| CREATE UNIQUE INDEX "SessionLink_sessionId_key" ON "SessionLink"("sessionId"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE UNIQUE INDEX "SessionLink_token_key" ON "SessionLink"("token"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE INDEX "SessionLink_token_idx" ON "SessionLink"("token"); |
There was a problem hiding this comment.
Redundant index on SessionLink.token.
Line 203 creates a unique index on token, which already provides index functionality. The additional index on line 206 is redundant and wastes storage space.
This should be removed from the schema's @@index([token]) directive on SessionLink.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/backend/prisma/migrations/20260615040933_portal_phase1/migration.sql`
around lines 200 - 206, The migration creates a redundant index on
SessionLink.token field: a UNIQUE INDEX on line 203 provides index
functionality, making the additional regular INDEX on line 206 unnecessary and
wasteful. Remove the `@@index([token])` directive from the SessionLink model
definition in the Prisma schema file (typically schema.prisma) to eliminate this
redundancy, while keeping the unique constraint on the token field intact.
| const parts = accessToken.split('.'); | ||
| const accessBody = `${parts[0]}.${parts[1]}`; | ||
| const accessSig = parts[2] ?? ''; | ||
|
|
||
| res.cookie('ip_access_sig', accessSig, { | ||
| httpOnly: false, | ||
| secure: process.env.NODE_ENV === 'production', | ||
| sameSite: 'lax', | ||
| maxAge: 30 * 60 * 1000, | ||
| }); | ||
|
|
||
| return { accessBody }; |
There was a problem hiding this comment.
Empty signature fallback in token splitting may mask invalid tokens. Both refresh() and googleAuthCallback() use parts[2] ?? '' when splitting JWTs, which produces empty signatures if tokens are malformed. This leads to invalid cookies and silent auth failures rather than explicit errors.
apps/backend/src/modules/auth/auth.controller.ts#L83-L94: Validate thataccessToken.split('.')produces exactly 3 parts before using them inrefresh().apps/backend/src/modules/auth/auth.controller.ts#L186-L191: Apply the same validation for both access and refresh tokens ingoogleAuthCallback().
📍 Affects 1 file
apps/backend/src/modules/auth/auth.controller.ts#L83-L94(this comment)apps/backend/src/modules/auth/auth.controller.ts#L186-L191
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/backend/src/modules/auth/auth.controller.ts` around lines 83 - 94, The
token splitting logic using parts[2] ?? '' masks malformed JWT tokens by
silently producing empty signatures instead of raising errors. In
apps/backend/src/modules/auth/auth.controller.ts at lines 83-94 in the refresh()
method, validate that accessToken.split('.') produces exactly 3 parts and throw
an error if validation fails instead of using the empty string fallback. Apply
the same validation check for both access and refresh tokens in
apps/backend/src/modules/auth/auth.controller.ts at lines 186-191 in the
googleAuthCallback() method before extracting and using the signature parts.
| const user = await this.prisma.user.findUnique({ | ||
| where: { id: userId }, | ||
| select: { | ||
| id: true, | ||
| email: true, | ||
| firstName: true, | ||
| lastName: true, | ||
| role: true, | ||
| isActive: true, | ||
| }, | ||
| }); | ||
| if (!user || !user.isActive) { | ||
| throw new UnauthorizedException('User not found or inactive'); | ||
| } | ||
| return user; |
There was a problem hiding this comment.
Missing deletedAt check allows soft-deleted users to access profile.
The query doesn't filter out soft-deleted users, unlike refreshTokens() which includes deletedAt: null. A soft-deleted user with a valid JWT (issued before deletion) could still access their profile.
🛠️ Suggested fix
const user = await this.prisma.user.findUnique({
- where: { id: userId },
+ where: { id: userId, deletedAt: null },
select: {
id: true,
email: true,📝 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.
| const user = await this.prisma.user.findUnique({ | |
| where: { id: userId }, | |
| select: { | |
| id: true, | |
| email: true, | |
| firstName: true, | |
| lastName: true, | |
| role: true, | |
| isActive: true, | |
| }, | |
| }); | |
| if (!user || !user.isActive) { | |
| throw new UnauthorizedException('User not found or inactive'); | |
| } | |
| return user; | |
| const user = await this.prisma.user.findUnique({ | |
| where: { id: userId, deletedAt: null }, | |
| select: { | |
| id: true, | |
| email: true, | |
| firstName: true, | |
| lastName: true, | |
| role: true, | |
| isActive: true, | |
| }, | |
| }); | |
| if (!user || !user.isActive) { | |
| throw new UnauthorizedException('User not found or inactive'); | |
| } | |
| return user; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/backend/src/modules/auth/auth.service.ts` around lines 245 - 259, The
findUnique query in the user profile fetch does not filter out soft-deleted
users by checking for deletedAt, which allows soft-deleted users with valid JWTs
to access their profile. Add deletedAt: null to the where clause of the
findUnique query to ensure only non-deleted users can retrieve their profile
information, matching the pattern used in refreshTokens().
| const dest = payload?.role ? (ROLE_REDIRECT[payload.role] ?? null) : null | ||
| return <Navigate to={dest ?? ROUTES.LOGIN} replace /> |
There was a problem hiding this comment.
Redirect loop: authenticated users with unknown roles will cycle back to LOGIN.
When an authenticated user has a role not in ROLE_REDIRECT (or a null/malformed token), Line 11 redirects to ROUTES.LOGIN. Since PublicRoute guards the login page, this creates an infinite redirect loop: LOGIN → PublicRoute detects auth → redirects to LOGIN again.
Consider redirecting to a dedicated error page, a default dashboard, or clearing tokens and showing an access-denied message.
🔄 Proposed fix to prevent redirect loop
- const dest = payload?.role ? (ROLE_REDIRECT[payload.role] ?? null) : null
- return <Navigate to={dest ?? ROUTES.LOGIN} replace />
+ const dest = payload?.role ? ROLE_REDIRECT[payload.role] : null
+ if (!dest) {
+ // Clear invalid tokens and redirect to login with error
+ accessTokenStore.clear()
+ return <Navigate to={`${ROUTES.LOGIN}?error=not_registered`} replace />
+ }
+ return <Navigate to={dest} replace />🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/interview-portal-fe/src/components/auth/PublicRoute.tsx` around lines 10
- 11, The PublicRoute component creates an infinite redirect loop by redirecting
authenticated users with unmapped roles back to ROUTES.LOGIN. Instead of using
ROUTES.LOGIN as the default fallback destination in the Navigate component on
line 11, implement an alternative destination such as a dedicated error or
access-denied page, or clear the authentication tokens and redirect to an
auth-cleared state. Ensure the fallback destination properly handles
authenticated users with unknown or invalid roles without cycling them back
through the PublicRoute guard.
| // Interviewer who manually navigates here → send to their portal | ||
| if (payload?.role === 'interviewer') { | ||
| return <Navigate to={ROUTES.INTERVIEWER_DASHBOARD} replace /> | ||
| } | ||
|
|
||
| return <AppLayout navItems={NAV_ITEMS} portalTitle="Recruitment" /> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Backend role enum =="
rg -n -C4 "enum UserRole|recruiter|interviewer|admin" apps/backend/prisma/schema.prisma
echo
echo "== Frontend role guards =="
sed -n '1,220p' apps/interview-portal-fe/src/layouts/RecruiterLayout.tsx
sed -n '1,220p' apps/interview-portal-fe/src/layouts/InterviewerLayout.tsx
sed -n '1,260p' apps/interview-portal-fe/src/components/auth/ProtectedRoute.tsxRepository: shivams10/nest-assessment
Length of output: 4606
Change RecruiterLayout guard to allow-list for consistency and security.
Line 20 uses a deny-list approach (blocks only interviewer), allowing candidate, moderator, and other unintended roles to access the recruiter portal. Update to match the allow-list pattern used in InterviewerLayout:
Suggested fix
if (payload?.role && !['recruiter', 'admin'].includes(payload.role)) {
return <Navigate to={ROUTES.INTERVIEWER_DASHBOARD} replace />
}
InterviewerLayout already implements this correctly with explicit allow-list logic for its intended roles.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/interview-portal-fe/src/layouts/RecruiterLayout.tsx` around lines 19 -
24, The RecruiterLayout component uses a deny-list approach that only blocks the
'interviewer' role, which allows unintended roles like 'candidate' and
'moderator' to access the recruiter portal. Replace the role guard check with an
allow-list pattern that explicitly permits only 'recruiter' and 'admin' roles.
The condition should check if the payload role exists and is NOT included in the
allowed roles list, redirecting to ROUTES.INTERVIEWER_DASHBOARD if the check
fails, matching the security pattern already implemented in InterviewerLayout.
| get: (name: string): string | null => { | ||
| const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)) | ||
| if (!match || !match[1]) return null |
There was a problem hiding this comment.
Avoid regex construction from raw cookie name.
Line 14 interpolates name directly into new RegExp(...); cookie names with regex metacharacters can cause incorrect matches or parse failures. Prefer a split/startsWith lookup.
Suggested deterministic cookie lookup
get: (name: string): string | null => {
- const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
- if (!match || !match[1]) return null
- return decodeURIComponent(match[1])
+ const prefix = `${name}=`
+ const found = document.cookie
+ .split('; ')
+ .find((entry) => entry.startsWith(prefix))
+ if (!found) return null
+ return decodeURIComponent(found.slice(prefix.length))
},📝 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.
| get: (name: string): string | null => { | |
| const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)) | |
| if (!match || !match[1]) return null | |
| get: (name: string): string | null => { | |
| const prefix = `${name}=` | |
| const found = document.cookie | |
| .split('; ') | |
| .find((entry) => entry.startsWith(prefix)) | |
| if (!found) return null | |
| return decodeURIComponent(found.slice(prefix.length)) | |
| }, |
🧰 Tools
🪛 ast-grep (0.43.0)
[warning] 13-13: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp((?:^|; )${name}=([^;]*))
Note: [CWE-1333] Inefficient Regular Expression Complexity
(regexp-from-variable)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/interview-portal-fe/src/lib/cookie.ts` around lines 13 - 15, The get
function in the cookie lookup constructs a RegExp using the raw name parameter,
which can fail if the cookie name contains regex metacharacters. Replace the
regex-based approach with a string split and startsWith lookup instead: split
document.cookie by semicolons and spaces, then iterate through the resulting
array to find a cookie entry that starts with the desired name followed by an
equals sign, extracting the value without relying on regex construction.
Source: Linters/SAST tools
| export type JwtPayload = { | ||
| sub: string | ||
| email: string | ||
| role: string | ||
| iat: number | ||
| exp: number | ||
| } |
There was a problem hiding this comment.
Align JwtPayload with the backend claim contract.
Line 16 makes email required, but the backend auth payload contract is sub + role (with standard iat/exp). This creates a false type guarantee that can leak undefined assumptions into role/layout flows.
Suggested contract-safe type update
export type JwtPayload = {
sub: string
- email: string
role: string
iat: number
exp: number
+ email?: string
}📝 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.
| export type JwtPayload = { | |
| sub: string | |
| email: string | |
| role: string | |
| iat: number | |
| exp: number | |
| } | |
| export type JwtPayload = { | |
| sub: string | |
| role: string | |
| iat: number | |
| exp: number | |
| email?: string | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/interview-portal-fe/src/lib/token.ts` around lines 14 - 20, The
JwtPayload type definition includes email as a required field, but the backend
auth payload contract only guarantees sub and role (with iat and exp). Make the
email field optional in the JwtPayload type by changing it to email?: string to
align with the actual backend contract and prevent undefined assumptions from
leaking into role and layout flows.
| export const decodeToken = (token: string): JwtPayload | null => { | ||
| try { | ||
| const part = token.split('.')[1] | ||
| if (!part) return null | ||
| return JSON.parse(atob(part)) as JwtPayload | ||
| } catch { |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
python - <<'PY'
import base64, json
payload = {"sub":"1","role":"recruiter","iat":1700000000,"exp":1800000000}
part = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
print("JWT payload segment (base64url, unpadded):", part)
try:
base64.b64decode(part, validate=True)
print("raw base64 decode: unexpectedly succeeded")
except Exception as e:
print("raw base64 decode fails as expected:", e)
padded = part + "=" * ((4 - len(part) % 4) % 4)
print("base64url normalized decode:", base64.urlsafe_b64decode(padded).decode())
PYRepository: shivams10/nest-assessment
Length of output: 364
🏁 Script executed:
cat -n apps/interview-portal-fe/src/lib/token.ts | head -70Repository: shivams10/nest-assessment
Length of output: 2992
🌐 Web query:
JavaScript atob() base64url unpadded JWT token decoding behavior
💡 Result:
The JavaScript atob function is designed to decode standard Base64, not the Base64URL encoding used in JWT tokens [1][2][3]. If you pass a raw JWT segment to atob, it will typically throw an "InvalidCharacterError" because Base64URL uses characters ('-' and '') that are not part of the standard Base64 alphabet ('+' and '/') [1][4][5]. Additionally, JWT segments often omit the '=' padding characters required by standard Base64 [4][2][3]. To correctly decode a Base64URL-encoded JWT segment in a browser environment using atob, you must perform the following transformations [1][2][5]: 1. Character Replacement: Replace '-' with '+' and '' with '/' [1][4][3]. 2. Padding Restoration: Add '=' characters to the end of the string until its length is a multiple of 4 [4][2][6]. Example Implementation: function decodeBase64Url(segment) { let base64 = segment.replace(/-/g, '+').replace(/_/g, '/'); // Add padding if missing const pad = base64.length % 4; if (pad === 2) base64 += '=='; else if (pad === 3) base64 += '='; // Decode using atob and convert from binary string to UTF-8 const binary = atob(base64); const bytes = Uint8Array.from(binary, c => c.charCodeAt(0)); return new TextDecoder.decode(bytes); } In modern JavaScript environments, including Node.js and newer browser versions, you have better alternatives: - Node.js: Use Buffer.from(segment, 'base64url').toString('utf8') [2][6][3]. - Modern Browsers: The newer Uint8Array.fromBase64(segment, { alphabet: 'base64url' }) API natively handles Base64URL without manual replacement or padding [5][7]. Important: Decoding a JWT only reveals its contents; it does not verify the token's signature [2][8]. Always use a verified JWT library for production authentication workflows [2][3].
Citations:
- 1: https://jsonic.io/guides/jwt-decode-without-library
- 2: https://dev.to/tooldeck/base64-decode-in-javascript-atob-buffer-and-common-pitfalls-4k3d
- 3: https://bytejson.com/tutorials/base64-decode-errors/
- 4: https://dev.to/tommy_worklab/i-built-a-jwt-decoder-and-lost-half-a-day-to-atob-2cok
- 5: https://developertools.app/articles/how-to-decode-base64-javascript.html
- 6: https://encodingbase64.com/blog/base64-in-javascript/
- 7: https://developer.mozilla.org/docs/Web/API/atob
- 8: https://tooldeck.top/en/encoding/base64-decode/javascript/
🏁 Script executed:
# Search for backend OAuth/JWT token generation code
find . -type f \( -name "*.ts" -o -name "*.js" \) -path "*/src/*" | xargs grep -l "OAuth\|JWT\|jwt\|token" | head -20Repository: shivams10/nest-assessment
Length of output: 1198
🏁 Script executed:
# Look for test files that might show token format
find . -type f \( -name "*.test.ts" -o -name "*.spec.ts" \) | xargs grep -l "decodeToken\|base64" 2>/dev/nullRepository: shivams10/nest-assessment
Length of output: 53
🏁 Script executed:
# Search for any usages of the decodeToken function
rg "decodeToken" --type ts --type js -A 2 -B 2Repository: shivams10/nest-assessment
Length of output: 4884
🏁 Script executed:
# Check backend JWT/auth strategy to see how tokens are generated
cat -n apps/backend/src/modules/auth/strategies/jwt.strategy.tsRepository: shivams10/nest-assessment
Length of output: 1100
🏁 Script executed:
# Look for JWT library/passport configuration
rg "JwtModule\|sign\|secret" apps/backend/src/modules/auth/ --type ts -A 2 -B 2Repository: shivams10/nest-assessment
Length of output: 51
🏁 Script executed:
# Check if there are any actual token examples in tests or config
find . -type f \( -name "*.ts" -o -name "*.json" \) | xargs grep -l "eyJ" 2>/dev/null | head -10Repository: shivams10/nest-assessment
Length of output: 51
🏁 Script executed:
# Search for how tokens are signed on the backend (likely jsonwebtoken or similar)
rg "sign|jsonwebtoken|jwt" apps/backend/src/modules/auth/ --type ts | head -20Repository: shivams10/nest-assessment
Length of output: 2226
🏁 Script executed:
# Check auth controller to see how tokens are generated
cat -n apps/backend/src/modules/auth/auth.controller.ts | head -100Repository: shivams10/nest-assessment
Length of output: 3964
🏁 Script executed:
# Let's verify the actual JWT structure generated by NestJS JWT
# by checking if there's any test or documentation about token format
cat -n apps/backend/src/modules/auth/auth.service.ts | head -120Repository: shivams10/nest-assessment
Length of output: 4152
🏁 Script executed:
# Check if there are any unit tests that show actual JWT tokens or their structure
find . -type f -name "*.spec.ts" | xargs grep -l "accessToken\|refreshToken\|jwt" 2>/dev/null | head -5Repository: shivams10/nest-assessment
Length of output: 51
Fix JWT decoding to handle base64url encoding.
Line 62 uses atob(part) directly on JWT payload segments, which are base64url-encoded per RFC 7518. Since atob() only handles standard base64 (with +// and padding), it will fail on unpadded base64url tokens containing - or _ characters, causing authentication and expiry checks to break intermittently.
Apply the suggested fix to normalize base64url to standard base64:
Base64url normalization
export const decodeToken = (token: string): JwtPayload | null => {
try {
const part = token.split('.')[1]
if (!part) return null
- return JSON.parse(atob(part)) as JwtPayload
+ const base64 = part.replace(/-/g, '+').replace(/_/g, '/')
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4)
+ return JSON.parse(atob(padded)) as JwtPayload
} catch {
return null
}
}📝 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.
| export const decodeToken = (token: string): JwtPayload | null => { | |
| try { | |
| const part = token.split('.')[1] | |
| if (!part) return null | |
| return JSON.parse(atob(part)) as JwtPayload | |
| } catch { | |
| export const decodeToken = (token: string): JwtPayload | null => { | |
| try { | |
| const part = token.split('.')[1] | |
| if (!part) return null | |
| const base64 = part.replace(/-/g, '+').replace(/_/g, '/') | |
| const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4) | |
| return JSON.parse(atob(padded)) as JwtPayload | |
| } catch { | |
| return null | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/interview-portal-fe/src/lib/token.ts` around lines 58 - 63, The
decodeToken function uses atob() to decode the JWT payload segment, but JWT
payloads use base64url encoding (RFC 7518) which replaces `+` with `-` and `/`
with `_`, and omits padding. The atob() function expects standard base64, so it
fails on tokens with `-` or `_` characters. Fix this by normalizing the
base64url string before calling atob(): replace all `-` characters with `+`,
replace all `_` characters with `/`, add padding with `=` characters as needed
to make the length a multiple of 4, then pass the normalized string to atob().
| import path from 'path' | ||
|
|
||
| export default defineConfig({ | ||
| plugins: [react()], | ||
| resolve: { | ||
| alias: { | ||
| '@': path.resolve(__dirname, './src'), | ||
| }, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Verify module mode and current alias expression
cat apps/interview-portal-fe/package.json | jq -r '.type // "type field not set"'
rg -nP '\b__dirname\b|fileURLToPath|import\.meta\.url' apps/interview-portal-fe/vite.config.tsRepository: shivams10/nest-assessment
Length of output: 123
Replace __dirname with ESM-compatible import.meta.url pattern in Vite alias config.
Line 9 uses __dirname, which is undefined in ESM contexts. Since the project uses "type": "module", this will throw a ReferenceError at runtime, breaking app startup and all @/... imports.
Suggested fix
-import path from 'path'
+import { fileURLToPath, URL } from 'node:url'
@@
resolve: {
alias: {
- '@': path.resolve(__dirname, './src'),
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})📝 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.
| import path from 'path' | |
| export default defineConfig({ | |
| plugins: [react()], | |
| resolve: { | |
| alias: { | |
| '@': path.resolve(__dirname, './src'), | |
| }, | |
| import { fileURLToPath, URL } from 'node:url' | |
| export default defineConfig({ | |
| plugins: [react()], | |
| resolve: { | |
| alias: { | |
| '@': fileURLToPath(new URL('./src', import.meta.url)), | |
| }, |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/interview-portal-fe/vite.config.ts` around lines 3 - 10, The `__dirname`
variable used in the path.resolve call within the alias configuration is
undefined in ESM contexts, which will cause a ReferenceError at runtime. To fix
this, import `fileURLToPath` from the 'url' module, then create an
ESM-compatible `__dirname` replacement by combining
`fileURLToPath(import.meta.url)` with `path.dirname()`. Finally, replace the
existing `__dirname` reference in the alias configuration with this new
variable.
Description:
feat: bootstrap interview portal with auth, schema, and app shell
Phases 1–2: extends Prisma schema with 8 new interview portal tables and
recruiter/interviewer roles; hardens auth with split JWT storage across
localStorage and server-set cookies; adds role-based layouts with sidebar
navigation, shared UI components, and a fully wired route structure.
Summary by CodeRabbit
New Features
Chores