feat: implement applicant portal schema and authentication utilities#139
feat: implement applicant portal schema and authentication utilities#139
Conversation
- Added schema for applicant portal including tables for tokens, accounts, and sessions. - Implemented functions for generating and validating portal tokens. - Created utilities for managing applicant sessions and accounts via Google OAuth. - Developed dashboard functionality to fetch application data for candidates. - Included helpers for formatting job types and interview details.
📝 WalkthroughWalkthroughThis pull request introduces a complete applicant portal system enabling job candidates to authenticate via Google OAuth, access their submitted applications, view interview schedules, track pipeline progress, and monitor activity timelines through both authenticated dashboard and token-based sharing links. Changes
Sequence Diagram(s)sequenceDiagram
participant Candidate as Candidate<br/>(Browser)
participant Portal as Portal API<br/>(Node.js)
participant Google as Google OAuth<br/>Service
participant DB as Database
participant Email as Account DB
Candidate->>Portal: GET /api/portal/auth/google?returnTo=...
Portal->>Portal: Encode state (returnTo, portalToken)
Portal-->>Candidate: Redirect to Google OAuth URL
Candidate->>Google: Authenticate & authorize
Google-->>Candidate: Redirect to callback with code
Candidate->>Portal: GET /api/portal/auth/google/callback?code=...&state=...
Portal->>Portal: Decode state from base64url
Portal->>Google: POST token exchange (code)
Google-->>Portal: Return access token
Portal->>Google: GET userinfo with access token
Google-->>Portal: Return profile (email, name, picture)
Portal->>Email: Query/create applicant account by googleId
Email-->>Portal: Return applicant account
Portal->>DB: Insert applicant session with 72h expiry
DB-->>Portal: Return session token
Portal->>Portal: Set secure httpOnly cookie (portal_session)
Portal-->>Candidate: Redirect to returnTo or /portal
Candidate->>Candidate: Portal accessible
sequenceDiagram
participant Candidate as Candidate<br/>(Browser)
participant Portal as Portal API<br/>(Node.js)
participant DB as Database
participant Cache as Cache Layer
Candidate->>Portal: GET /api/portal/dashboard
Portal->>Portal: Read portal_session cookie
alt Session Missing
Portal-->>Candidate: 401 Sign in required
else Session Exists
Portal->>DB: Validate applicant session token
alt Session Expired
Portal->>Portal: Delete stale cookie
Portal-->>Candidate: 401 Session expired
else Session Valid
Portal->>Cache: Query cached dashboard (key: email)
Cache-->>Portal: Return applicant + applications
Portal->>DB: Fetch interview counts per application
Portal->>DB: Fetch pipeline stages per application
Portal-->>Candidate: Return dashboard JSON
Candidate->>Candidate: Render applications grid
end
end
sequenceDiagram
participant Public as Anonymous User<br/>(Browser)
participant Portal as Portal API<br/>(Node.js)
participant RateLimit as Rate Limiter<br/>(IP-based)
participant DB as Database
participant Cache as Cache
Public->>RateLimit: GET /api/portal/token/[token]
RateLimit->>RateLimit: Check quota (30 req/min)
alt Rate Limited
RateLimit-->>Public: 429 Too Many Requests
else Within Limit
RateLimit->>Portal: Request allowed
Portal->>Portal: Validate token format (64-char hex)
alt Invalid Format
Portal-->>Public: 400 Invalid token format
else Valid Format
Portal->>DB: Lookup & validate portal token
alt Token Expired/Missing
Portal-->>Public: 404 Invalid or expired
else Token Valid
Portal->>DB: Fetch application dashboard<br/>(application, interviews, docs, timeline)
DB-->>Portal: Return dashboard data
Portal->>DB: Update last_accessed_at (async)
Portal-->>Public: Return dashboard JSON
Public->>Public: Render application detail
end
end
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (1 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 |
|
🚅 Deployed to the reqcore-pr-139 environment in applirank
|
| try { | ||
| const state = JSON.parse(Buffer.from(stateParam, 'base64url').toString()) | ||
| returnTo = state.returnTo || '' | ||
| portalToken = state.portalToken || '' |
| @@ -0,0 +1,47 @@ | |||
| import { eq, and } from 'drizzle-orm' | |||
| @@ -0,0 +1,47 @@ | |||
| import { eq, and } from 'drizzle-orm' | |||
| import { candidate } from '../../../../database/schema' | |||
| @@ -0,0 +1,33 @@ | |||
| import { eq } from 'drizzle-orm' | |||
| import { | ||
| application, | ||
| job, | ||
| candidate, | ||
| interview, | ||
| organization, | ||
| document, | ||
| activityLog, | ||
| applicationStatusEnum, | ||
| } from '../database/schema' |
There was a problem hiding this comment.
Actionable comments posted: 12
🧹 Nitpick comments (11)
app/components/portal/StatusTimeline.vue (1)
26-43: Consolidate title matching to avoid brittle icon/color drift.Line 28 through Line 41 duplicate case-sensitive title checks in two functions. A single normalized classifier would keep icon and color behavior consistent if labels change.
Refactor sketch
+function getEntryKind(entry: TimelineEntry) { + if (entry.type === 'interview') return 'interview' + const title = entry.title.toLowerCase() + if (title.includes('hired')) return 'hired' + if (title.includes('not moving')) return 'rejected' + if (title.includes('offer')) return 'offer' + if (title.includes('viewed')) return 'viewed' + if (title.includes('submitted')) return 'submitted' + return 'default' +} + function getIcon(entry: TimelineEntry) { - if (entry.type === 'interview') return Calendar - if (entry.title.includes('Hired')) return Star - if (entry.title.includes('Not Moving')) return XCircle - if (entry.title.includes('Offer')) return UserCheck - if (entry.title.includes('Viewed')) return Eye - if (entry.title.includes('Submitted')) return FileText - return ArrowRight + switch (getEntryKind(entry)) { + case 'interview': return Calendar + case 'hired': return Star + case 'rejected': return XCircle + case 'offer': return UserCheck + case 'viewed': return Eye + case 'submitted': return FileText + default: return ArrowRight + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/components/portal/StatusTimeline.vue` around lines 26 - 43, Consolidate the duplicated, case-sensitive title/type checks by creating a single classifier (e.g., classifyTimelineEntry or getTimelineEntryMeta) that accepts a TimelineEntry, normalizes title/type (toLowerCase and trim), and returns a small descriptor (enum or object) indicating the matched kind (e.g., "interview", "hired", "notMoving", "offer", "viewed", "submitted", "default"); then have getIcon(entry) and getIconColor(entry) call that classifier and map the descriptor to the correct icon (Calendar, Star, XCircle, UserCheck, Eye, FileText, ArrowRight) and color strings, ensuring all checks live in one place so label changes stay consistent across both functions.app/components/portal/StatusBadge.vue (1)
2-39: Derive the badge keys from the shared application-status source.The same six statuses already exist in
shared/status-transitions.ts:15-21, so keeping another untypedRecord<string, ...>here makes badge rendering easy to forget when a status is added or renamed. Please type the prop/config from the shared status union instead of maintaining a second enum in this component.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/components/portal/StatusBadge.vue` around lines 2 - 39, Replace the ad-hoc untyped status usage in StatusBadge.vue by importing the shared status union type from the shared status-transitions module and use that type in defineProps (e.g. props.status: SharedStatusUnion) and for the statusConfig map keys so the map is typed to accept only known statuses; update the computed statusConfig signature to use Record<SharedStatusUnion, {label:string;color:string;bg:string}> (with a fallback typed to SharedStatusUnion) and remove the loose Record<string,...> so adding/renaming statuses in the shared source will be type-checked here.app/pages/jobs/[slug]/confirmation.vue (2)
26-26: Remove unusedconfigvariable.
useRuntimeConfig()is called but the result is never used.🧹 Proposed fix
// Build portal URL -const config = useRuntimeConfig() const portalUrl = computed(() => {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/jobs/`[slug]/confirmation.vue at line 26, The variable config is unused: remove the unused call to useRuntimeConfig() and the const config declaration in the confirmation.vue script to eliminate dead code; locate the line declaring "const config = useRuntimeConfig()" and delete it (or replace it with a used call if runtime config is actually needed elsewhere in the component).
92-98: Use relative path forNuxtLinkto enable client-side navigation.The
portalUrlcontains the full origin on client-side, which will causeNuxtLinkto perform a full page navigation instead of a client-side route change. Consider using the relative path for the link while keeping the full URL for the copy functionality.♻️ Proposed fix
<NuxtLink - :to="portalUrl" + :to="`/portal/t/${portalToken}`" class="inline-flex items-center gap-1.5 text-sm font-medium text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300 transition-colors" >🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/jobs/`[slug]/confirmation.vue around lines 92 - 98, The NuxtLink is using the full-origin portalUrl which forces a full page load; change the template to pass a relative path to NuxtLink (e.g., use :to="relativePortalPath") while keeping portalUrl for copy/download actions. Add a computed property or helper (e.g., relativePortalPath) that derives the path+search+hash from portalUrl (using new URL(portalUrl).pathname + search + hash or trimming the origin) and use that in the NuxtLink; leave the existing portalUrl value untouched for any copy functionality.server/database/migrations/0021_blushing_thing.sql (2)
38-38: Redundant index ongoogle_id.The
UNIQUE("google_id")constraint on line 9 already creates an index. The explicit unique index on line 38 is redundant.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/database/migrations/0021_blushing_thing.sql` at line 38, The CREATE UNIQUE INDEX statement for "applicant_account_google_id_idx" is redundant because the applicant_account table already defines a UNIQUE constraint on the google_id column; remove the explicit index line (the CREATE UNIQUE INDEX ... ON "applicant_account" ... ("google_id")) from the migration so the UNIQUE("google_id") constraint alone provides the index and avoids duplication.
42-44: Redundant indexes on unique columns.Lines 42 and 44 create indexes on
tokencolumns that already haveUNIQUEconstraints (lines 21 and 30 respectively). PostgreSQL automatically creates indexes for unique constraints, making these explicit indexes redundant and consuming extra storage.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/database/migrations/0021_blushing_thing.sql` around lines 42 - 44, The migration creates redundant btree indexes "portal_token_token_idx" and "applicant_session_token_idx" on applicant_portal_token.token and applicant_session.token even though UNIQUE constraints already create indexes (see the UNIQUE constraints defined earlier around lines 21 and 30); remove these explicit CREATE INDEX statements (or omit them from the migration) so you only rely on the unique-constraint-backed indexes and avoid duplicate storage and overhead.server/api/portal/applications/[id].get.ts (1)
1-2: Remove unusedandimport.The
andfunction is imported but not used in this file.🧹 Proposed fix
-import { eq, and } from 'drizzle-orm' +import { eq } from 'drizzle-orm'🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/api/portal/applications/`[id].get.ts around lines 1 - 2, The file imports `and` from 'drizzle-orm' but never uses it; remove the unused `and` identifier from the import line (leaving `eq` only) to eliminate the unused import warning—update the import statement that currently reads something like "import { eq, and } from 'drizzle-orm'" to drop `and` while keeping `eq` and the existing `application` import usage.app/pages/portal/applications/[id].vue (2)
21-30: Consider using route middleware for authentication.The current pattern fetches the session, then navigates away if unauthenticated, but the component continues execution and triggers the application fetch regardless. This can cause unnecessary API calls and potential race conditions.
Consider using Nuxt route middleware for the auth check, or restructure to guard the data fetch:
♻️ Option 1: Guard the data fetch
// Check session const { data: sessionData } = await useFetch('/api/portal/auth/session') if (!sessionData.value?.authenticated) { await navigateTo('/portal/auth/sign-in') } -const { data, error, status } = await useFetch(`/api/portal/applications/${applicationId.value}`, { +const { data, error, status } = await useFetch(() => `/api/portal/applications/${applicationId.value}`, { key: `portal-app-${applicationId.value}`, headers: useRequestHeaders(['cookie']), + immediate: sessionData.value?.authenticated, })♻️ Option 2: Use route middleware (preferred)
Create a middleware file (e.g.,
middleware/portal-auth.ts) and reference it indefinePageMeta:// middleware/portal-auth.ts export default defineNuxtRouteMiddleware(async () => { const { data } = await useFetch('/api/portal/auth/session') if (!data.value?.authenticated) { return navigateTo('/portal/auth/sign-in') } })Then in the page:
definePageMeta({ layout: 'portal', + middleware: 'portal-auth', }) - -// Check session -const { data: sessionData } = await useFetch('/api/portal/auth/session') -if (!sessionData.value?.authenticated) { - await navigateTo('/portal/auth/sign-in') -}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/portal/applications/`[id].vue around lines 21 - 30, The current on-page auth check uses sessionData from useFetch then still proceeds to call useFetch for `/api/portal/applications/${applicationId.value}`, causing unnecessary requests; move the auth guard into Nuxt route middleware (create middleware e.g., portal-auth that uses useFetch to check session and calls navigateTo('/portal/auth/sign-in') when unauthenticated) and reference it from the page via definePageMeta so the page never runs the application fetch when unauthenticated, or alternatively wrap the application fetch in an explicit guard that checks sessionData.value?.authenticated before invoking useFetch with key `portal-app-${applicationId.value}` and headers useRequestHeaders(['cookie']).
47-58: Consider extracting date formatters to a composable.The
formatDateandgetRelativeTimefunctions are likely duplicated across portal pages. Extracting them to a shared composable (e.g.,composables/useDateFormat.ts) would improve maintainability.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/portal/applications/`[id].vue around lines 47 - 58, The getRelativeTime (and related formatDate) logic is duplicated and should be extracted into a shared composable to improve reuse and maintenance: create a new composable (e.g., useDateFormat.ts) that exports getRelativeTime and formatDate functions, move the current getRelativeTime implementation into it, update this component to import getRelativeTime from the composable, and replace other pages' local implementations to import from useDateFormat instead; ensure the exported functions are typed and tested where used.server/api/portal/auth/google/index.get.ts (1)
1-2: Remove unused imports.The imports
eq,andfromdrizzle-ormandcandidatefrom the schema are not used in this file.🧹 Proposed fix
-import { eq, and } from 'drizzle-orm' -import { candidate } from '../../../../database/schema' -🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/api/portal/auth/google/index.get.ts` around lines 1 - 2, The top-level imports eq, and (from drizzle-orm) and candidate (from schema) are not used; remove these unused imports by deleting eq and and from the drizzle-orm import and removing candidate from the schema import (or collapse the import to only keep actually used symbols), and run a quick search in this file (index.get.ts) to ensure no remaining references to eq, and, or candidate before committing.server/api/portal/token/[token].get.ts (1)
1-2: Remove unused imports.The imports from
drizzle-ormand the schema are not used in this handler—the token validation and dashboard fetching are delegated to utility functions.🧹 Proposed fix
-import { eq, and } from 'drizzle-orm' -import { application, job, candidate, interview, organization, applicantPortalToken } from '../../../database/schema' -🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/api/portal/token/`[token].get.ts around lines 1 - 2, The import statements bring in unused symbols (eq, and, application, job, candidate, interview, organization, applicantPortalToken) that are not referenced in this handler; remove these unused imports from the top of the file so only the utilities actually used by the token validation and dashboard fetching remain—specifically delete the "import { eq, and } from 'drizzle-orm'" line and the schema import line that lists application, job, candidate, interview, organization, applicantPortalToken.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/components/portal/InterviewCard.vue`:
- Around line 20-26: The getTypeIcon function misses matching for the
transformed in-person type so it falls back to Calendar; update getTypeIcon to
explicitly check for 'In-Person' (in addition to existing checks like
type.includes('Panel')) and return the Users icon (or another chosen icon) for
that case so in-person interviews render correctly; locate function getTypeIcon
and add the 'In-Person' conditional alongside the existing checks returning
Users, Video, Phone, Code, FileText, or Calendar.
In `@app/components/portal/PipelineProgress.vue`:
- Line 104: The gridTemplateColumns expression can produce repeat(-1, ...) when
stages.length === 0; update the inline style on the div (the :style that uses
`gridTemplateColumns: \`repeat(${stages.length * 2 - 1}, ...)\``) to guard
against empty stages by using a safe value or conditional: either set the
columns count with Math.max(1, stages.length * 2 - 1) or only apply the
gridTemplateColumns style when stages.length > 0 (e.g., compute columns =
stages.length > 0 ? stages.length * 2 - 1 : 1 and use that), so repeat() never
receives a negative value.
In `@app/layouts/portal.vue`:
- Line 41: Footer external anchor in app/layouts/portal.vue currently can leak
portal tokens via Referer when used on /portal/t/[token]; update the anchor (the
<a> for "Reqcore" in the layout) to prevent referrer leakage by adding either
rel="noreferrer noopener" or referrerpolicy="no-referrer" (or both) so the
tokenized URL is not sent to the external marketingUrl; ensure the change
targets the anchor element rendering the marketing link.
In `@app/pages/portal/index.vue`:
- Around line 17-22: The current redirect treats any missing sessionData or
fetch error the same as unauthenticated; change the guard to only redirect when
the API explicitly returns authenticated === false (i.e. use if
(sessionData.value?.authenticated === false) { await
navigateTo('/portal/auth/sign-in') }), and do not redirect when
sessionError.value exists or sessionData is undefined/empty so transient 5xx or
network failures don't bounce users—optionally add a separate branch to surface
or retry on sessionError (check sessionError and handle accordingly) instead of
redirecting.
In `@app/pages/portal/t/`[token].vue:
- Around line 103-117: The template currently shows the "Access Link Invalid" UI
whenever the reactive error flag (v-if="error") is truthy; change the
fetch/error handling so you distinguish permanent token errors (400/404) from
transient/ratelimit/server errors (429, 5xx) by inspecting response.status in
the function that fetches the token (the code that sets the error flag for this
page). Introduce two states (e.g., invalidToken and retryableError) and set
invalidToken = true only for 400/404 responses, set retryableError = true for
429 or >=500 responses (and include any retry meta), then update the template to
render the current message: keep the existing "Access Link Invalid" block for
invalidToken, and add a separate retryable block (with a retry button/clear
instructions) for retryableError so users aren’t misled when the issue is
transient.
In `@server/api/applications/`[id].get.ts:
- Around line 52-57: The fire-and-forget update to record first view
(db.update(application).set(...) where application.viewedAt and viewedBy are set
using session.user.id and id) should be made durable: await the Promise instead
of discarding it and handle errors instead of swallowing them; update the code
in the block that checks if (!result.viewedAt) to await
db.update(application).set({ viewedAt: new Date(), viewedBy: session.user.id
}).where(and(eq(application.id, id), isNull(application.viewedAt))) and
propagate or log any errors (e.g., try/catch with process/logger.error or
rethrow) so failures to write viewedAt/viewedBy are not silently lost.
In `@server/api/portal/applications/`[id].get.ts:
- Around line 43-45: The code accesses app.candidate.email without verifying
that app.candidate exists, which can throw if the relation is missing; update
the guard in the handler that uses app (check the result from the query where
app is returned) to also verify app.candidate is present before comparing emails
(e.g. ensure app && app.candidate && app.candidate.email or use optional
chaining) and if candidate is missing throw the same createError({ statusCode:
404, statusMessage: 'Application not found' }) so you don't attempt to read
account.email against an undefined candidate.
In `@server/api/portal/auth/google/callback.get.ts`:
- Around line 102-104: Validate the decoded state's returnTo before calling
sendRedirect: treat returnTo as untrusted input and only allow safe internal
redirects (e.g., paths beginning with '/' and not containing '//' or a scheme)
or check against a whitelist of allowed origins/paths; if validation fails, fall
back to '/portal'. Update the callback handler to parse/inspect the returnTo
value from the decoded state, perform the check, and then pass the validated (or
fallback) value to sendRedirect.
- Around line 33-34: The callback handler currently uses
env.AUTH_GOOGLE_CLIENT_ID and env.AUTH_GOOGLE_CLIENT_SECRET with non-null
assertions (clientId, clientSecret) without validation; add runtime checks at
the start of the handler to verify both environment variables are defined, and
if not, return an appropriate error response (or throw) and log the missing
variable(s) instead of allowing a runtime crash—use the same validation pattern
as the initiation endpoint (check env values, log which key is missing, and
return HTTP 500/400) to locate and update the clientId/clientSecret usage in
this file.
In `@server/api/portal/auth/sign-out.post.ts`:
- Around line 12-18: The current sign-out swallows DB delete errors and still
removes the cookie; update the logic around
db.delete(applicantSession).where(eq(applicantSession.token, sessionToken)) so
any thrown error is not ignored: catch the error, log it (e.g., via your server
logger or console.error) with context including sessionToken and
applicantSession, and then propagate failure to the caller (throw or return an
HTTP error) instead of deleting the portal_session cookie; only
deleteCookie(event, 'portal_session', { path: '/' }) after successful revocation
to ensure the bearer session is actually invalidated.
In `@server/utils/portal-auth.ts`:
- Around line 30-35: The code stores plaintext tokens via
db.insert(applicantPortalToken).values({... token ...}) (and the analogous
session insert around lines 78–81); change this to store a one-way digest
instead: compute a secure hash (e.g., HMAC-SHA256 with a per-app secret or
bcrypt/argon2) of the token before calling db.insert for applicantPortalToken
and the session token insert, persist only the digest and any salt/nonce
metadata, and update the token validation paths to hash the presented token and
compare digests (rather than comparing plaintext). Ensure you use a
constant-time comparison and keep the hashing secret/config in a secure config
value used by the verification logic.
In `@server/utils/portal-dashboard.ts`:
- Around line 98-110: The query in portal-dashboard.ts is missing an application
scope check: when selecting document fields in the
db.select(...).from(document).where(...) call you must include
eq(document.applicationId, applicationId) alongside eq(document.candidateId,
candidateId) and eq(document.organizationId, organizationId) so the results are
limited to the current application; update the where(...) clause in that query
(the db.select(...) / .from(document) block) to add this condition and
reorder/add to the and(...) so only documents for the specified applicationId
are returned.
---
Nitpick comments:
In `@app/components/portal/StatusBadge.vue`:
- Around line 2-39: Replace the ad-hoc untyped status usage in StatusBadge.vue
by importing the shared status union type from the shared status-transitions
module and use that type in defineProps (e.g. props.status: SharedStatusUnion)
and for the statusConfig map keys so the map is typed to accept only known
statuses; update the computed statusConfig signature to use
Record<SharedStatusUnion, {label:string;color:string;bg:string}> (with a
fallback typed to SharedStatusUnion) and remove the loose Record<string,...> so
adding/renaming statuses in the shared source will be type-checked here.
In `@app/components/portal/StatusTimeline.vue`:
- Around line 26-43: Consolidate the duplicated, case-sensitive title/type
checks by creating a single classifier (e.g., classifyTimelineEntry or
getTimelineEntryMeta) that accepts a TimelineEntry, normalizes title/type
(toLowerCase and trim), and returns a small descriptor (enum or object)
indicating the matched kind (e.g., "interview", "hired", "notMoving", "offer",
"viewed", "submitted", "default"); then have getIcon(entry) and
getIconColor(entry) call that classifier and map the descriptor to the correct
icon (Calendar, Star, XCircle, UserCheck, Eye, FileText, ArrowRight) and color
strings, ensuring all checks live in one place so label changes stay consistent
across both functions.
In `@app/pages/jobs/`[slug]/confirmation.vue:
- Line 26: The variable config is unused: remove the unused call to
useRuntimeConfig() and the const config declaration in the confirmation.vue
script to eliminate dead code; locate the line declaring "const config =
useRuntimeConfig()" and delete it (or replace it with a used call if runtime
config is actually needed elsewhere in the component).
- Around line 92-98: The NuxtLink is using the full-origin portalUrl which
forces a full page load; change the template to pass a relative path to NuxtLink
(e.g., use :to="relativePortalPath") while keeping portalUrl for copy/download
actions. Add a computed property or helper (e.g., relativePortalPath) that
derives the path+search+hash from portalUrl (using new URL(portalUrl).pathname +
search + hash or trimming the origin) and use that in the NuxtLink; leave the
existing portalUrl value untouched for any copy functionality.
In `@app/pages/portal/applications/`[id].vue:
- Around line 21-30: The current on-page auth check uses sessionData from
useFetch then still proceeds to call useFetch for
`/api/portal/applications/${applicationId.value}`, causing unnecessary requests;
move the auth guard into Nuxt route middleware (create middleware e.g.,
portal-auth that uses useFetch to check session and calls
navigateTo('/portal/auth/sign-in') when unauthenticated) and reference it from
the page via definePageMeta so the page never runs the application fetch when
unauthenticated, or alternatively wrap the application fetch in an explicit
guard that checks sessionData.value?.authenticated before invoking useFetch with
key `portal-app-${applicationId.value}` and headers
useRequestHeaders(['cookie']).
- Around line 47-58: The getRelativeTime (and related formatDate) logic is
duplicated and should be extracted into a shared composable to improve reuse and
maintenance: create a new composable (e.g., useDateFormat.ts) that exports
getRelativeTime and formatDate functions, move the current getRelativeTime
implementation into it, update this component to import getRelativeTime from the
composable, and replace other pages' local implementations to import from
useDateFormat instead; ensure the exported functions are typed and tested where
used.
In `@server/api/portal/applications/`[id].get.ts:
- Around line 1-2: The file imports `and` from 'drizzle-orm' but never uses it;
remove the unused `and` identifier from the import line (leaving `eq` only) to
eliminate the unused import warning—update the import statement that currently
reads something like "import { eq, and } from 'drizzle-orm'" to drop `and` while
keeping `eq` and the existing `application` import usage.
In `@server/api/portal/auth/google/index.get.ts`:
- Around line 1-2: The top-level imports eq, and (from drizzle-orm) and
candidate (from schema) are not used; remove these unused imports by deleting eq
and and from the drizzle-orm import and removing candidate from the schema
import (or collapse the import to only keep actually used symbols), and run a
quick search in this file (index.get.ts) to ensure no remaining references to
eq, and, or candidate before committing.
In `@server/api/portal/token/`[token].get.ts:
- Around line 1-2: The import statements bring in unused symbols (eq, and,
application, job, candidate, interview, organization, applicantPortalToken) that
are not referenced in this handler; remove these unused imports from the top of
the file so only the utilities actually used by the token validation and
dashboard fetching remain—specifically delete the "import { eq, and } from
'drizzle-orm'" line and the schema import line that lists application, job,
candidate, interview, organization, applicantPortalToken.
In `@server/database/migrations/0021_blushing_thing.sql`:
- Line 38: The CREATE UNIQUE INDEX statement for
"applicant_account_google_id_idx" is redundant because the applicant_account
table already defines a UNIQUE constraint on the google_id column; remove the
explicit index line (the CREATE UNIQUE INDEX ... ON "applicant_account" ...
("google_id")) from the migration so the UNIQUE("google_id") constraint alone
provides the index and avoids duplication.
- Around line 42-44: The migration creates redundant btree indexes
"portal_token_token_idx" and "applicant_session_token_idx" on
applicant_portal_token.token and applicant_session.token even though UNIQUE
constraints already create indexes (see the UNIQUE constraints defined earlier
around lines 21 and 30); remove these explicit CREATE INDEX statements (or omit
them from the migration) so you only rely on the unique-constraint-backed
indexes and avoid duplicate storage and overhead.
🪄 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: a282cae6-9d28-4cf3-a9ee-90f324c3640b
📒 Files selected for processing (31)
.env.exampleapp/components/portal/InterviewCard.vueapp/components/portal/PipelineProgress.vueapp/components/portal/StatusBadge.vueapp/components/portal/StatusTimeline.vueapp/layouts/portal.vueapp/pages/jobs/[slug]/apply.vueapp/pages/jobs/[slug]/confirmation.vueapp/pages/portal/applications/[id].vueapp/pages/portal/auth/sign-in.vueapp/pages/portal/index.vueapp/pages/portal/t/[token].vueserver/api/applications/[id].get.tsserver/api/portal/applications/[id].get.tsserver/api/portal/auth/google/callback.get.tsserver/api/portal/auth/google/index.get.tsserver/api/portal/auth/session.get.tsserver/api/portal/auth/sign-out.post.tsserver/api/portal/dashboard.get.tsserver/api/portal/token/[token].get.tsserver/api/public/jobs/[slug]/apply.post.tsserver/database/migrations/0021_blushing_thing.sqlserver/database/migrations/0022_mute_green_goblin.sqlserver/database/migrations/meta/0021_snapshot.jsonserver/database/migrations/meta/0022_snapshot.jsonserver/database/migrations/meta/_journal.jsonserver/database/schema/app.tsserver/database/schema/index.tsserver/database/schema/portal.tsserver/utils/portal-auth.tsserver/utils/portal-dashboard.ts
| function getTypeIcon(type: string) { | ||
| if (type.includes('Video')) return Video | ||
| if (type.includes('Phone')) return Phone | ||
| if (type.includes('Panel')) return Users | ||
| if (type.includes('Technical')) return Code | ||
| if (type.includes('Take-Home')) return FileText | ||
| return Calendar |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 1) Locate where portal interview payloads are built
rg -n -C3 --type=ts --type=vue "upcomingInterviews|pastInterviews|PortalInterviewCard|interview\\.type|formatInterviewType|type:" app server
# 2) Confirm canonical enum values in schema
rg -n -C2 --type=ts "interviewTypeEnum|take_home|in_person|video|phone|panel|technical" server/database/schema/app.tsRepository: reqcore-inc/reqcore
Length of output: 90
🏁 Script executed:
# Search for PortalInterviewCard usage and interview type handling
rg -n -C3 "PortalInterviewCard|upcomingInterviews|pastInterviews" app --type=ts --type=vue --type=js --type=jsx 2>/dev/null || rg -n -C3 "PortalInterviewCard|upcomingInterviews|pastInterviews" appRepository: reqcore-inc/reqcore
Length of output: 5001
🏁 Script executed:
# Look for enum definitions and type mapping in schema
cat server/database/schema/app.ts | head -300 | tail -100Repository: reqcore-inc/reqcore
Length of output: 4986
🏁 Script executed:
# Search for interview type enum definitions
rg -n "interview.*type|enum.*Interview" server/database/schema/app.tsRepository: reqcore-inc/reqcore
Length of output: 253
🏁 Script executed:
# Find the InterviewCard component itself
find app -name "InterviewCard.vue" -o -name "InterviewCard.ts" -o -name "InterviewCard.tsx" | head -5Repository: reqcore-inc/reqcore
Length of output: 102
🏁 Script executed:
# Read the full InterviewCard.vue component
cat app/components/portal/InterviewCard.vueRepository: reqcore-inc/reqcore
Length of output: 3485
🏁 Script executed:
# Check data fetching in the portal pages
grep -n "upcomingInterviews\|pastInterviews" app/pages/portal/t/[token].vue | head -20Repository: reqcore-inc/reqcore
Length of output: 372
🏁 Script executed:
# Look at the page component to see how data is fetched
head -100 "app/pages/portal/t/[token].vue" | tail -50Repository: reqcore-inc/reqcore
Length of output: 1294
🏁 Script executed:
# Find the complete data fetching logic in the portal page
rg -n "const data|data\.value|upcomingInterviews.*=" "app/pages/portal/t/[token].vue" -A 5Repository: reqcore-inc/reqcore
Length of output: 494
🏁 Script executed:
# Search for where interview data is fetched/transformed in the server
rg -n "upcomingInterviews|interview.*type|formatInterviewType" server --type=ts --type=jsRepository: reqcore-inc/reqcore
Length of output: 2304
🏁 Script executed:
# Check if there's any type transformation in composables or utilities
fd -type f "\.ts$\|\.js$" app/composables app/utils | xargs rg -l "interview" | head -5Repository: reqcore-inc/reqcore
Length of output: 404
🏁 Script executed:
# Read the formatInterviewType function
sed -n '340,380p' server/utils/portal-dashboard.tsRepository: reqcore-inc/reqcore
Length of output: 950
🏁 Script executed:
# Read the formatInterview function to see full processing
sed -n '365,385p' server/utils/portal-dashboard.tsRepository: reqcore-inc/reqcore
Length of output: 663
🏁 Script executed:
# Get more context around formatInterviewType
sed -n '340,390p' server/utils/portal-dashboard.tsRepository: reqcore-inc/reqcore
Length of output: 1402
in_person interview type will display Calendar icon instead of a proper icon.
The server correctly transforms lowercase enum values via formatInterviewType() in server/utils/portal-dashboard.ts (e.g., video → 'Video Call', phone → 'Phone Screen'). However, getTypeIcon() checks for partial string matches that don't account for the in_person → 'In-Person' transformation. The check type.includes('Panel') will not match 'In-Person', causing it to default to the Calendar icon.
Add a check for 'In-Person' to the getTypeIcon() function. Consider using Users (panel icon) or another appropriate icon for in-person interviews.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/components/portal/InterviewCard.vue` around lines 20 - 26, The
getTypeIcon function misses matching for the transformed in-person type so it
falls back to Calendar; update getTypeIcon to explicitly check for 'In-Person'
(in addition to existing checks like type.includes('Panel')) and return the
Users icon (or another chosen icon) for that case so in-person interviews render
correctly; locate function getTypeIcon and add the 'In-Person' conditional
alongside the existing checks returning Users, Video, Phone, Code, FileText, or
Calendar.
| </div> | ||
|
|
||
| <!-- Pipeline steps — full-width, responsive grid --> | ||
| <div class="grid w-full" :style="{ gridTemplateColumns: `repeat(${stages.length * 2 - 1}, minmax(0, 1fr))` }"> |
There was a problem hiding this comment.
Guard empty-stage layout to prevent invalid grid CSS.
Line 104 can produce repeat(-1, ...) when stages.length === 0, which breaks the progress layout.
Minimal fix
-<div class="grid w-full" :style="{ gridTemplateColumns: `repeat(${stages.length * 2 - 1}, minmax(0, 1fr))` }">
+<div class="grid w-full" :style="{ gridTemplateColumns: `repeat(${Math.max(stages.length * 2 - 1, 1)}, minmax(0, 1fr))` }">📝 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.
| <div class="grid w-full" :style="{ gridTemplateColumns: `repeat(${stages.length * 2 - 1}, minmax(0, 1fr))` }"> | |
| <div class="grid w-full" :style="{ gridTemplateColumns: `repeat(${Math.max(stages.length * 2 - 1, 1)}, minmax(0, 1fr))` }"> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/components/portal/PipelineProgress.vue` at line 104, The
gridTemplateColumns expression can produce repeat(-1, ...) when stages.length
=== 0; update the inline style on the div (the :style that uses
`gridTemplateColumns: \`repeat(${stages.length * 2 - 1}, ...)\``) to guard
against empty stages by using a safe value or conditional: either set the
columns count with Math.max(1, stages.length * 2 - 1) or only apply the
gridTemplateColumns style when stages.length > 0 (e.g., compute columns =
stages.length > 0 ? stages.length * 2 - 1 : 1 and use that), so repeat() never
receives a negative value.
| © {{ new Date().getFullYear() }} Applicant Portal | ||
| </p> | ||
| <p class="text-xs text-surface-400"> | ||
| Powered by <a :href="config.public.marketingUrl ?? 'https://reqcore.com'" class="underline hover:text-surface-600 dark:hover:text-surface-300" target="_blank" rel="noopener">Reqcore</a> |
There was a problem hiding this comment.
Prevent the footer link from leaking portal tokens.
On Line 41, this layout is used by /portal/t/[token], so opening the external marketing link can send the full tokenized URL in the Referer header. Please add noreferrer or referrerpolicy="no-referrer" here so the portal token never leaves the app origin.
Suggested fix
- Powered by <a :href="config.public.marketingUrl ?? 'https://reqcore.com'" class="underline hover:text-surface-600 dark:hover:text-surface-300" target="_blank" rel="noopener">Reqcore</a>
+ Powered by <a :href="config.public.marketingUrl ?? 'https://reqcore.com'" class="underline hover:text-surface-600 dark:hover:text-surface-300" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">Reqcore</a>📝 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.
| Powered by <a :href="config.public.marketingUrl ?? 'https://reqcore.com'" class="underline hover:text-surface-600 dark:hover:text-surface-300" target="_blank" rel="noopener">Reqcore</a> | |
| Powered by <a :href="config.public.marketingUrl ?? 'https://reqcore.com'" class="underline hover:text-surface-600 dark:hover:text-surface-300" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">Reqcore</a> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/layouts/portal.vue` at line 41, Footer external anchor in
app/layouts/portal.vue currently can leak portal tokens via Referer when used on
/portal/t/[token]; update the anchor (the <a> for "Reqcore" in the layout) to
prevent referrer leakage by adding either rel="noreferrer noopener" or
referrerpolicy="no-referrer" (or both) so the tokenized URL is not sent to the
external marketingUrl; ensure the change targets the anchor element rendering
the marketing link.
| const { data: sessionData, error: sessionError } = await useFetch('/api/portal/auth/session') | ||
|
|
||
| // Redirect to sign-in if not authenticated | ||
| if (!sessionData.value?.authenticated) { | ||
| await navigateTo('/portal/auth/sign-in') | ||
| } |
There was a problem hiding this comment.
Only redirect on an explicit unauthenticated result.
Right now a failed session probe is handled the same as “not signed in”. If /api/portal/auth/session returns a transient 5xx, authenticated users get bounced to the sign-in page and never see the real failure.
Suggested guard
const { data: sessionData, error: sessionError } = await useFetch('/api/portal/auth/session')
-// Redirect to sign-in if not authenticated
-if (!sessionData.value?.authenticated) {
+if (sessionError.value && sessionError.value.statusCode !== 401) {
+ throw sessionError.value
+}
+
+if (!sessionData.value?.authenticated) {
await navigateTo('/portal/auth/sign-in')
}📝 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 { data: sessionData, error: sessionError } = await useFetch('/api/portal/auth/session') | |
| // Redirect to sign-in if not authenticated | |
| if (!sessionData.value?.authenticated) { | |
| await navigateTo('/portal/auth/sign-in') | |
| } | |
| const { data: sessionData, error: sessionError } = await useFetch('/api/portal/auth/session') | |
| if (sessionError.value && sessionError.value.statusCode !== 401) { | |
| throw sessionError.value | |
| } | |
| if (!sessionData.value?.authenticated) { | |
| await navigateTo('/portal/auth/sign-in') | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/pages/portal/index.vue` around lines 17 - 22, The current redirect treats
any missing sessionData or fetch error the same as unauthenticated; change the
guard to only redirect when the API explicitly returns authenticated === false
(i.e. use if (sessionData.value?.authenticated === false) { await
navigateTo('/portal/auth/sign-in') }), and do not redirect when
sessionError.value exists or sessionData is undefined/empty so transient 5xx or
network failures don't bounce users—optionally add a separate branch to surface
or retry on sessionError (check sessionError and handle accordingly) instead of
redirecting.
| <div v-if="error" class="flex flex-col items-center justify-center py-20 text-center"> | ||
| <div class="size-16 rounded-full bg-danger-50 dark:bg-danger-900/30 flex items-center justify-center mb-4"> | ||
| <Shield class="size-8 text-danger-500" /> | ||
| </div> | ||
| <h1 class="text-xl font-bold text-surface-900 dark:text-surface-100">Access Link Invalid</h1> | ||
| <p class="mt-2 text-sm text-surface-500 dark:text-surface-400 max-w-md"> | ||
| This link may have expired or is no longer valid. If you signed in with Google, you can access your applications from the portal dashboard. | ||
| </p> | ||
| <NuxtLink | ||
| to="/portal/auth/sign-in" | ||
| class="mt-6 inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-brand-700 transition-colors" | ||
| > | ||
| <LogIn class="size-4" /> | ||
| Sign in to view your applications | ||
| </NuxtLink> |
There was a problem hiding this comment.
Differentiate invalid links from transient fetch failures.
This branch treats every error as an expired/invalid token. A 429 from the rate limiter or a temporary 5xx will currently tell applicants their link is bad, which is misleading and removes the obvious retry path. Gate this copy on the response code and keep a generic retryable state for non-404/400 failures.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/pages/portal/t/`[token].vue around lines 103 - 117, The template
currently shows the "Access Link Invalid" UI whenever the reactive error flag
(v-if="error") is truthy; change the fetch/error handling so you distinguish
permanent token errors (400/404) from transient/ratelimit/server errors (429,
5xx) by inspecting response.status in the function that fetches the token (the
code that sets the error flag for this page). Introduce two states (e.g.,
invalidToken and retryableError) and set invalidToken = true only for 400/404
responses, set retryableError = true for 429 or >=500 responses (and include any
retry meta), then update the template to render the current message: keep the
existing "Access Link Invalid" block for invalidToken, and add a separate
retryable block (with a retry button/clear instructions) for retryableError so
users aren’t misled when the issue is transient.
| const clientId = env.AUTH_GOOGLE_CLIENT_ID! | ||
| const clientSecret = env.AUTH_GOOGLE_CLIENT_SECRET! |
There was a problem hiding this comment.
Add validation for environment variables.
Unlike the initiation endpoint, this callback doesn't validate that AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET are present before using them. The non-null assertion will cause a runtime error if they're missing.
🛡️ Proposed fix
- const clientId = env.AUTH_GOOGLE_CLIENT_ID!
- const clientSecret = env.AUTH_GOOGLE_CLIENT_SECRET!
+ const clientId = env.AUTH_GOOGLE_CLIENT_ID
+ const clientSecret = env.AUTH_GOOGLE_CLIENT_SECRET
+
+ if (!clientId || !clientSecret) {
+ throw createError({
+ statusCode: 503,
+ statusMessage: 'Google sign-in is not configured',
+ })
+ }📝 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 clientId = env.AUTH_GOOGLE_CLIENT_ID! | |
| const clientSecret = env.AUTH_GOOGLE_CLIENT_SECRET! | |
| const clientId = env.AUTH_GOOGLE_CLIENT_ID | |
| const clientSecret = env.AUTH_GOOGLE_CLIENT_SECRET | |
| if (!clientId || !clientSecret) { | |
| throw createError({ | |
| statusCode: 503, | |
| statusMessage: 'Google sign-in is not configured', | |
| }) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/api/portal/auth/google/callback.get.ts` around lines 33 - 34, The
callback handler currently uses env.AUTH_GOOGLE_CLIENT_ID and
env.AUTH_GOOGLE_CLIENT_SECRET with non-null assertions (clientId, clientSecret)
without validation; add runtime checks at the start of the handler to verify
both environment variables are defined, and if not, return an appropriate error
response (or throw) and log the missing variable(s) instead of allowing a
runtime crash—use the same validation pattern as the initiation endpoint (check
env values, log which key is missing, and return HTTP 500/400) to locate and
update the clientId/clientSecret usage in this file.
| // Redirect to portal dashboard or return URL | ||
| const redirectTo = returnTo || '/portal' | ||
| return sendRedirect(event, redirectTo) |
There was a problem hiding this comment.
Validate returnTo to prevent open redirect vulnerability.
The returnTo value from the decoded state is used directly for the redirect without validation. An attacker could manipulate the state parameter to redirect users to a malicious external site after authentication, potentially for phishing.
🔒 Proposed fix
// Redirect to portal dashboard or return URL
- const redirectTo = returnTo || '/portal'
+ // Validate returnTo is a relative path to prevent open redirects
+ const isValidReturnTo = returnTo && returnTo.startsWith('/') && !returnTo.startsWith('//')
+ const redirectTo = isValidReturnTo ? returnTo : '/portal'
return sendRedirect(event, redirectTo)📝 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.
| // Redirect to portal dashboard or return URL | |
| const redirectTo = returnTo || '/portal' | |
| return sendRedirect(event, redirectTo) | |
| // Redirect to portal dashboard or return URL | |
| // Validate returnTo is a relative path to prevent open redirects | |
| const isValidReturnTo = returnTo && returnTo.startsWith('/') && !returnTo.startsWith('//') | |
| const redirectTo = isValidReturnTo ? returnTo : '/portal' | |
| return sendRedirect(event, redirectTo) |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/api/portal/auth/google/callback.get.ts` around lines 102 - 104,
Validate the decoded state's returnTo before calling sendRedirect: treat
returnTo as untrusted input and only allow safe internal redirects (e.g., paths
beginning with '/' and not containing '//' or a scheme) or check against a
whitelist of allowed origins/paths; if validation fails, fall back to '/portal'.
Update the callback handler to parse/inspect the returnTo value from the decoded
state, perform the check, and then pass the validated (or fallback) value to
sendRedirect.
| if (sessionToken) { | ||
| await db.delete(applicantSession) | ||
| .where(eq(applicantSession.token, sessionToken)) | ||
| .catch(() => {}) | ||
|
|
||
| deleteCookie(event, 'portal_session', { path: '/' }) | ||
| } |
There was a problem hiding this comment.
Don’t report a successful sign-out when revocation fails.
If the DB delete errors, this still returns success and only removes the local cookie, leaving the bearer session valid until expiry. Please stop swallowing revocation failures here; at minimum log them, and preferably fail the request so the caller knows the session was not actually invalidated.
Suggested fix
if (sessionToken) {
- await db.delete(applicantSession)
- .where(eq(applicantSession.token, sessionToken))
- .catch(() => {})
-
- deleteCookie(event, 'portal_session', { path: '/' })
+ try {
+ await db.delete(applicantSession)
+ .where(eq(applicantSession.token, sessionToken))
+ } catch (error) {
+ deleteCookie(event, 'portal_session', { path: '/' })
+ logError('portal.sign_out_failed', {
+ error_message: error instanceof Error ? error.message : String(error),
+ })
+ throw createError({ statusCode: 503, statusMessage: 'Failed to sign out. Please try again.' })
+ }
+
+ deleteCookie(event, 'portal_session', { path: '/' })
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/api/portal/auth/sign-out.post.ts` around lines 12 - 18, The current
sign-out swallows DB delete errors and still removes the cookie; update the
logic around db.delete(applicantSession).where(eq(applicantSession.token,
sessionToken)) so any thrown error is not ignored: catch the error, log it
(e.g., via your server logger or console.error) with context including
sessionToken and applicantSession, and then propagate failure to the caller
(throw or return an HTTP error) instead of deleting the portal_session cookie;
only deleteCookie(event, 'portal_session', { path: '/' }) after successful
revocation to ensure the bearer session is actually invalidated.
| await db.insert(applicantPortalToken).values({ | ||
| token, | ||
| applicationId, | ||
| candidateId, | ||
| organizationId, | ||
| expiresAt, |
There was a problem hiding this comment.
Hash portal and session tokens before storing them.
Both bearer tokens are persisted in plaintext. If these tables are ever exposed through a DB snapshot, admin read access, or logs, the attacker can reuse the values directly for portal access or authenticated sessions. Store a one-way digest instead and compare digests during validation.
Also applies to: 78-81
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/utils/portal-auth.ts` around lines 30 - 35, The code stores plaintext
tokens via db.insert(applicantPortalToken).values({... token ...}) (and the
analogous session insert around lines 78–81); change this to store a one-way
digest instead: compute a secure hash (e.g., HMAC-SHA256 with a per-app secret
or bcrypt/argon2) of the token before calling db.insert for applicantPortalToken
and the session token insert, persist only the digest and any salt/nonce
metadata, and update the token validation paths to hash the presented token and
compare digests (rather than comparing plaintext). Ensure you use a
constant-time comparison and keep the hashing secret/config in a secure config
value used by the verification logic.
| // Uploaded documents (names only — no download links) | ||
| db.select({ | ||
| id: document.id, | ||
| type: document.type, | ||
| originalFilename: document.originalFilename, | ||
| createdAt: document.createdAt, | ||
| }) | ||
| .from(document) | ||
| .where(and( | ||
| eq(document.candidateId, candidateId), | ||
| eq(document.organizationId, organizationId), | ||
| )) | ||
| .orderBy(desc(document.createdAt)), |
There was a problem hiding this comment.
Scope documents to the current application.
This query returns every document for the candidate within the organization, not just the application identified by applicationId. A token for application A can therefore expose filenames uploaded for application B at the same company, which widens the access scope beyond the dashboard contract.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/utils/portal-dashboard.ts` around lines 98 - 110, The query in
portal-dashboard.ts is missing an application scope check: when selecting
document fields in the db.select(...).from(document).where(...) call you must
include eq(document.applicationId, applicationId) alongside
eq(document.candidateId, candidateId) and eq(document.organizationId,
organizationId) so the results are limited to the current application; update
the where(...) clause in that query (the db.select(...) / .from(document) block)
to add this condition and reorder/add to the and(...) so only documents for the
specified applicationId are returned.
Summary
Type of change
Validation
DCO
Signed-off-by) viagit commit -sSummary by CodeRabbit
New Features
Bug Fixes