From 8a5b43da694e6d68dca0e963908473bcb4e680f9 Mon Sep 17 00:00:00 2001 From: leini8891 Date: Sun, 28 Jun 2026 15:34:12 +0800 Subject: [PATCH] Add Supabase auth isolation to web app --- .env.example | 5 + README.md | 60 ++- apps/web/package.json | 4 +- .../(dashboard)/applications/[id]/page.tsx | 394 +++++++++-------- .../src/app/(dashboard)/applications/page.tsx | 44 +- .../src/app/(dashboard)/interviews/page.tsx | 68 +-- .../app/(dashboard)/knowledge-base/actions.ts | 3 + .../app/(dashboard)/knowledge-base/page.tsx | 90 +++- apps/web/src/app/(dashboard)/picks/page.tsx | 410 ++++++++++-------- apps/web/src/app/(dashboard)/runs/page.tsx | 66 +-- .../web/src/app/(dashboard)/settings/page.tsx | 110 ++--- apps/web/src/app/api/_lib.ts | 21 +- apps/web/src/app/api/account/data/route.ts | 27 +- .../api/applications/[id]/receipt/route.ts | 8 +- .../app/api/applications/[id]/review/route.ts | 8 +- .../app/api/applications/[id]/status/route.ts | 8 +- .../api/applications/[id]/workflow/route.ts | 25 +- .../web/src/app/api/assets/[...path]/route.ts | 19 +- .../src/app/api/dashboard/summary/route.ts | 7 +- apps/web/src/app/api/interviews/route.ts | 8 +- apps/web/src/app/api/jobs/save/route.ts | 7 +- apps/web/src/app/api/jobs/score/route.ts | 7 +- apps/web/src/app/api/picks/daily/route.ts | 6 +- apps/web/src/app/api/preferences/route.ts | 8 +- apps/web/src/app/api/profile/parse/route.ts | 8 +- apps/web/src/app/api/resumes/upload/route.ts | 8 +- apps/web/src/app/api/runs/start/route.ts | 7 +- apps/web/src/app/auth/callback/route.ts | 27 ++ apps/web/src/app/auth/login/route.ts | 42 ++ apps/web/src/app/auth/signout/route.ts | 18 + apps/web/src/app/auth/signup/route.ts | 50 +++ apps/web/src/app/globals.css | 51 ++- apps/web/src/app/login/page.tsx | 87 ++++ apps/web/src/app/page.tsx | 223 ++++++---- apps/web/src/components/app-shell.tsx | 15 +- apps/web/src/lib/env.ts | 12 +- apps/web/src/lib/supabase.ts | 6 +- apps/web/src/middleware.ts | 43 ++ apps/web/src/server/auth.ts | 118 +++++ apps/web/src/server/services/app-service.ts | 397 +++++++++++------ apps/web/src/server/services/store.ts | 295 +++++++++---- pnpm-lock.yaml | 96 ++-- .../0003_auth_rls_storage_isolation.sql | 227 ++++++++++ tests/app-service.test.ts | 56 ++- tests/auth.test.ts | 104 +++++ 45 files changed, 2323 insertions(+), 980 deletions(-) create mode 100644 apps/web/src/app/auth/callback/route.ts create mode 100644 apps/web/src/app/auth/login/route.ts create mode 100644 apps/web/src/app/auth/signout/route.ts create mode 100644 apps/web/src/app/auth/signup/route.ts create mode 100644 apps/web/src/app/login/page.tsx create mode 100644 apps/web/src/middleware.ts create mode 100644 apps/web/src/server/auth.ts create mode 100644 supabase/migrations/0003_auth_rls_storage_isolation.sql create mode 100644 tests/auth.test.ts diff --git a/.env.example b/.env.example index 7ff06dc..541d740 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,15 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# Optional Supabase multi-user mode. Leave blank for local single-user mode. NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= NEXT_PUBLIC_SUPABASE_ANON_KEY= + +# Optional trusted-server key for future background/admin operations only. SUPABASE_SECRET_KEY= SUPABASE_SERVICE_ROLE_KEY= SUPABASE_STORAGE_BUCKET=applypilot-assets + OPENAI_API_KEY= OPENAI_MODEL=gpt-4.1-mini APPLYPILOT_LOCAL_STORE_PATH= diff --git a/README.md b/README.md index a324e83..f557475 100644 --- a/README.md +++ b/README.md @@ -24,17 +24,18 @@ Most job-search tools stop at keyword matching or blind auto-apply. ApplyPilot i This version moves the center of gravity from browser automation alone to the part that can be reliable every day: local knowledge ingestion, retrieval, scoring, prep assets, application workflow preparation, and tracker sync. The LinkedIn/MyCareersFuture extension remains an assisted apply layer, while the core product now works as a usable job-search cockpit even when no external services are configured. -The product is intentionally single-user and local-first: +The product is local-first by default, with optional Supabase Auth for real multi-user isolation: - Public-safe career stories live in `knowledge_base/`. - Private interview notes can live in `local_workspace/knowledge_base_private/`, which is ignored by Git. - The app works with a gitignored local JSON store by default, so it runs without Supabase or OpenAI. +- When Supabase env is configured, each signed-in user reads and writes rows as `auth.uid()` through RLS. - AI calls have deterministic fallbacks, keeping the workflow usable without external services. ## Product Tour -| Knowledge base | Daily picks | Application tracker | -| --- | --- | --- | +| Knowledge base | Daily picks | Application tracker | +| ------------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------- | | ![Knowledge Base](docs/assets/knowledge-base.png) | ![Daily Picks](docs/assets/daily-picks.png) | ![Application Tracker](docs/assets/application-tracker.png) | ### Application Workflow @@ -43,15 +44,15 @@ The product is intentionally single-user and local-first: ## What It Does -| Capability | What it means | -| --- | --- | -| Role matching | Scores saved jobs against profile, preferences, keywords, skills, region, salary, remote policy, and application friction. | -| Resume and story retrieval | Retrieves resume evidence plus reusable stories, interview notes, job profiles, and answer playbooks for a role. | -| Local Markdown/JSON knowledge base | Reads structured Markdown, JSON sidecars, standalone JSON entries, and private local-only entries. | -| Daily Picks | Ranks saved jobs and attaches prep assets under each role so review starts with evidence, not a blank page. | -| Application workflow | Turns a saved role into a human-reviewable checklist with matched resume evidence, story assets, next actions, and tracker state. | -| Application tracker sync | Manual job saves create `drafted` tracker records, and repeat saves do not reset existing statuses. | -| Human-in-the-loop automation | The Chrome extension can assist LinkedIn and MyCareersFuture flows, while risky cases route to review. | +| Capability | What it means | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Role matching | Scores saved jobs against profile, preferences, keywords, skills, region, salary, remote policy, and application friction. | +| Resume and story retrieval | Retrieves resume evidence plus reusable stories, interview notes, job profiles, and answer playbooks for a role. | +| Local Markdown/JSON knowledge base | Reads structured Markdown, JSON sidecars, standalone JSON entries, and private local-only entries. | +| Daily Picks | Ranks saved jobs and attaches prep assets under each role so review starts with evidence, not a blank page. | +| Application workflow | Turns a saved role into a human-reviewable checklist with matched resume evidence, story assets, next actions, and tracker state. | +| Application tracker sync | Manual job saves create `drafted` tracker records, and repeat saves do not reset existing statuses. | +| Human-in-the-loop automation | The Chrome extension can assist LinkedIn and MyCareersFuture flows, while risky cases route to review. | ## Core Workflow @@ -159,10 +160,43 @@ Open [http://localhost:3000](http://localhost:3000). Without Supabase, saved local data is written to `local_workspace/applypilot-store.json`. For a database-backed setup, copy `.env.example` to `.env.local`, fill in Supabase/OpenAI values as needed, and apply the SQL migrations under `supabase/migrations/`. +### Supabase Auth mode + +Required env: + +```bash +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your_publishable_key +SUPABASE_STORAGE_BUCKET=applypilot-assets +``` + +`NEXT_PUBLIC_SUPABASE_ANON_KEY` is still accepted for older projects. `SUPABASE_SECRET_KEY` or `SUPABASE_SERVICE_ROLE_KEY` is optional and reserved for trusted background/admin operations; normal web requests use a request-scoped anon client with the signed-in user's JWT so existing RLS policies run. + +In Supabase Auth, enable email/password sign-in and add this local redirect URL: + +```text +http://localhost:3000/auth/callback +``` + +Apply migrations in order, for example with the Supabase CLI or SQL editor: + +```bash +supabase db push +``` + +`0003_auth_rls_storage_isolation.sql` adds explicit Data API grants, binds user-owned rows to `auth.uid()`, makes the asset bucket private, and limits Storage paths to `resumes/{uid}/...`, `tailored-resumes/{uid}/...`, and `receipts/{uid}/...`. + +Local auth smoke test: + +1. Start `pnpm dev:web`. +2. Open `http://localhost:3000/login`, create or sign into account A, save a role or upload a resume. +3. Sign out, sign into account B, and confirm account A's profile, resumes, saved jobs, applications, and local asset URLs are not visible. +4. Remove Supabase env values and restart; the app should open in local single-user mode with `local_workspace/applypilot-store.json`. + ## Verification ```bash -pnpm test # 22 tests covering scoring, KB parsing/retrieval, workflow prep, tracker sync, store behavior +pnpm test # unit and integration tests for scoring, KB retrieval, auth routing, tracker sync, store behavior pnpm build # production build for all workspace packages pnpm lint # typecheck and lint ``` diff --git a/apps/web/package.json b/apps/web/package.json index 2f5fef2..ee329d1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,7 +13,8 @@ "@applypilot/domain": "workspace:*", "@applypilot/ui": "workspace:*", "@react-pdf/renderer": "^4.3.0", - "@supabase/supabase-js": "^2.49.8", + "@supabase/ssr": "^0.12.0", + "@supabase/supabase-js": "^2.108.2", "mammoth": "^1.9.1", "next": "^15.3.2", "openai": "^4.103.0", @@ -23,4 +24,3 @@ "zod": "^3.24.4" } } - diff --git a/apps/web/src/app/(dashboard)/applications/[id]/page.tsx b/apps/web/src/app/(dashboard)/applications/[id]/page.tsx index 013bc28..9747bb4 100644 --- a/apps/web/src/app/(dashboard)/applications/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/applications/[id]/page.tsx @@ -6,7 +6,12 @@ import { SectionCard, StatusPill } from '@applypilot/ui'; import { ApplicationWorkflowActions } from '@/components/application-workflow-actions'; import { applicationTone, humanizeStatus } from '@/components/status-utils'; import { formatDateTime } from '@/lib/utils'; -import { getApplicationDetail, getApplicationWorkflow } from '@/server/services/app-service'; +import { requirePageAuth } from '@/server/auth'; +import { + getApplicationDetail, + getApplicationWorkflow, + withAppStore, +} from '@/server/services/app-service'; const checklistTone = (state: string): 'success' | 'danger' | 'warning' => { if (state === 'ready') { @@ -26,199 +31,238 @@ export default async function ApplicationDetailPage({ params: Promise<{ id: string }>; }) { const { id } = await params; - const detail = await getApplicationDetail('demo-user', id); + const auth = await requirePageAuth(); - if (!detail) { - notFound(); - } + return withAppStore(auth.store, async () => { + const detail = await getApplicationDetail(auth.candidateId, id); - let workflow: Awaited> | null = null; - let workflowError = ''; - - if (detail.job) { - try { - workflow = await getApplicationWorkflow({ - candidateId: 'demo-user', - applicationId: id, - }); - } catch (error) { - workflowError = error instanceof Error ? error.message : 'Application workflow is not available.'; + if (!detail) { + notFound(); } - } - return ( -
- Back to board} - description="Job details, matched materials, checklist, and tracker state." - eyebrow="Application" - title={detail.job?.title ?? detail.attempt.metadata.title?.toString() ?? 'Application detail'} - > -
-
-

Company

- {detail.job?.company ?? detail.attempt.metadata.company?.toString() ?? 'Unknown'} -
-
-

Status

- -
-
-

Submitted

- {formatDateTime(detail.attempt.submittedAt)} -
-
-

Review note

- {detail.attempt.reviewReason ?? 'None'} -
-
-

Prepared

- {formatDateTime(workflow?.preparedAt)} -
-
- {workflow ? ( - - ) : null} - {workflowError ?

{workflowError}

: null} -
+ let workflow: Awaited> | null = + null; + let workflowError = ''; + + if (detail.job) { + try { + workflow = await getApplicationWorkflow({ + candidateId: auth.candidateId, + applicationId: id, + }); + } catch (error) { + workflowError = + error instanceof Error + ? error.message + : 'Application workflow is not available.'; + } + } - {workflow ? ( + return ( +
+ Back to board + + } + description="Job details, matched materials, checklist, and tracker state." + eyebrow="Application" + title={ + detail.job?.title ?? + detail.attempt.metadata.title?.toString() ?? + 'Application detail' + } > -
- {workflow.checklist.map((item) => ( -
-
- {item.label} -

{item.detail}

-
- -
- ))} +
+
+

Company

+ + {detail.job?.company ?? + detail.attempt.metadata.company?.toString() ?? + 'Unknown'} + +
+
+

Status

+ +
+
+

Submitted

+ {formatDateTime(detail.attempt.submittedAt)} +
+
+

Review note

+ {detail.attempt.reviewReason ?? 'None'} +
+
+

Prepared

+ {formatDateTime(workflow?.preparedAt)} +
- - ) : null} - -
- -

{detail.job?.description ?? 'No job description stored.'}

+ {workflow ? ( + + ) : null} + {workflowError ?

{workflowError}

: null}
- -
- {workflow ? ( - workflow.nextActions.map((action) => ( -
- {action} + {workflow ? ( + +
+ {workflow.checklist.map((item) => ( +
+
+ {item.label} +

{item.detail}

+
+
- )) - ) : ( -

No workflow generated yet.

- )} -
-
-
+ ))} +
+ + ) : null} - {workflow ? (
- -
- {workflow.resumeMatches.length === 0 ? ( -

No resume evidence matched this role yet.

- ) : ( - workflow.resumeMatches.map((match) => ( -
-
-

{match.sourceLabel}

-

{match.title}

-

{match.reason}

-
-
    - {match.highlights.map((highlight) => ( -
  • - {highlight} -
  • - ))} -
-
- )) - )} -
+ +

+ {detail.job?.description ?? 'No job description stored.'} +

- -
- {workflow.knowledgeMatches.length === 0 ? ( -

No story or playbook assets matched this role yet.

- ) : ( - workflow.knowledgeMatches.map((match) => ( -
-
-

{match.kindLabel}

-

{match.title}

-

{match.reason}

-
-
    - {match.answerPoints.map((point) => ( -
  • - {point} -
  • - ))} -
-

{match.relativePath}

+ +
+ {workflow ? ( + workflow.nextActions.map((action) => ( +
+ {action}
)) + ) : ( +

No workflow generated yet.

)}
- ) : null} - - -
- {detail.reviewItems.length === 0 ? ( -

No review items linked to this application.

- ) : ( - detail.reviewItems.map((item) => ( -
-
- {item.reason} -

{formatDateTime(item.createdAt)}

-
- -
- )) - )} -
-
- - -
- {detail.interviews.length === 0 ? ( -

No interview notes yet.

- ) : ( - detail.interviews.map((interview) => ( -
-
- {interview.stage} -

{interview.notes}

-
- {formatDateTime(interview.scheduledAt)} -
- )) - )} -
-
-
- ); + + {workflow ? ( +
+ +
+ {workflow.resumeMatches.length === 0 ? ( +

+ No resume evidence matched this role yet. +

+ ) : ( + workflow.resumeMatches.map((match) => ( +
+
+

{match.sourceLabel}

+

{match.title}

+

{match.reason}

+
+
    + {match.highlights.map((highlight) => ( +
  • + {highlight} +
  • + ))} +
+
+ )) + )} +
+
+ + +
+ {workflow.knowledgeMatches.length === 0 ? ( +

+ No story or playbook assets matched this role yet. +

+ ) : ( + workflow.knowledgeMatches.map((match) => ( +
+
+

{match.kindLabel}

+

{match.title}

+

{match.reason}

+
+
    + {match.answerPoints.map((point) => ( +
  • + {point} +
  • + ))} +
+

{match.relativePath}

+
+ )) + )} +
+
+
+ ) : null} + + +
+ {detail.reviewItems.length === 0 ? ( +

+ No review items linked to this application. +

+ ) : ( + detail.reviewItems.map((item) => ( +
+
+ {item.reason} +

{formatDateTime(item.createdAt)}

+
+ +
+ )) + )} +
+
+ + +
+ {detail.interviews.length === 0 ? ( +

No interview notes yet.

+ ) : ( + detail.interviews.map((interview) => ( +
+
+ {interview.stage} +

{interview.notes}

+
+ + {formatDateTime(interview.scheduledAt)} + +
+ )) + )} +
+
+
+ ); + }); } diff --git a/apps/web/src/app/(dashboard)/applications/page.tsx b/apps/web/src/app/(dashboard)/applications/page.tsx index 5604ba0..221ec7e 100644 --- a/apps/web/src/app/(dashboard)/applications/page.tsx +++ b/apps/web/src/app/(dashboard)/applications/page.tsx @@ -1,27 +1,31 @@ import { SectionCard } from '@applypilot/ui'; import { KanbanBoard } from '@/components/kanban-board'; -import { getDashboardData } from '@/server/services/app-service'; -import { store } from '@/server/services/store'; +import { requirePageAuth } from '@/server/auth'; +import { getDashboardData, withAppStore } from '@/server/services/app-service'; export default async function ApplicationsPage() { - const snapshot = await getDashboardData(); - const items = snapshot.attempts.map((attempt) => ({ attempt })); - const jobs = await store.listJobs(); - const jobMap = new Map(jobs.map((job) => [job.id, job])); + const auth = await requirePageAuth(); - return ( - - ({ - ...item, - job: jobMap.get(item.attempt.jobPostingId) ?? null, - }))} - /> - - ); + return withAppStore(auth.store, async () => { + const snapshot = await getDashboardData(auth.candidateId); + const items = snapshot.attempts.map((attempt) => ({ attempt })); + const jobs = await auth.store.listJobs(auth.candidateId); + const jobMap = new Map(jobs.map((job) => [job.id, job])); + + return ( + + ({ + ...item, + job: jobMap.get(item.attempt.jobPostingId) ?? null, + }))} + /> + + ); + }); } diff --git a/apps/web/src/app/(dashboard)/interviews/page.tsx b/apps/web/src/app/(dashboard)/interviews/page.tsx index 2fd25a3..1224e2c 100644 --- a/apps/web/src/app/(dashboard)/interviews/page.tsx +++ b/apps/web/src/app/(dashboard)/interviews/page.tsx @@ -2,39 +2,45 @@ import { SectionCard } from '@applypilot/ui'; import { InterviewForm } from '@/components/interview-form'; import { formatDateTime } from '@/lib/utils'; -import { getDashboardData } from '@/server/services/app-service'; +import { requirePageAuth } from '@/server/auth'; +import { getDashboardData, withAppStore } from '@/server/services/app-service'; export default async function InterviewsPage() { - const snapshot = await getDashboardData(); - const applicationOptions = snapshot.attempts.map((attempt) => ({ - id: attempt.id, - label: `${attempt.metadata.company?.toString() ?? 'Company'} - ${attempt.metadata.title?.toString() ?? 'Role'}`, - })); + const auth = await requirePageAuth(); - return ( -
- - - + return withAppStore(auth.store, async () => { + const snapshot = await getDashboardData(auth.candidateId); + const applicationOptions = snapshot.attempts.map((attempt) => ({ + id: attempt.id, + label: `${attempt.metadata.company?.toString() ?? 'Company'} - ${attempt.metadata.title?.toString() ?? 'Role'}`, + })); - -
- {snapshot.interviews.map((interview) => ( -
-
- {interview.stage} -

{interview.notes}

-
- {formatDateTime(interview.scheduledAt)} -
- ))} -
-
-
- ); -} + return ( +
+ + + + +
+ {snapshot.interviews.map((interview) => ( +
+
+ {interview.stage} +

{interview.notes}

+
+ + {formatDateTime(interview.scheduledAt)} + +
+ ))} +
+
+
+ ); + }); +} diff --git a/apps/web/src/app/(dashboard)/knowledge-base/actions.ts b/apps/web/src/app/(dashboard)/knowledge-base/actions.ts index eddc5c9..ee84994 100644 --- a/apps/web/src/app/(dashboard)/knowledge-base/actions.ts +++ b/apps/web/src/app/(dashboard)/knowledge-base/actions.ts @@ -7,6 +7,7 @@ import { saveKnowledgeBaseEntry, type KnowledgeEntryKind, } from '@/server/services/knowledge-base'; +import { requirePageAuth } from '@/server/auth'; const getText = (formData: FormData, key: string) => { const value = formData.get(key); @@ -47,6 +48,8 @@ const getKind = (formData: FormData): KnowledgeEntryKind => { }; export async function createKnowledgeBaseEntry(formData: FormData) { + await requirePageAuth(); + await saveKnowledgeBaseEntry({ kind: getKind(formData), title: getRequiredText(formData, 'title', 'Title'), diff --git a/apps/web/src/app/(dashboard)/knowledge-base/page.tsx b/apps/web/src/app/(dashboard)/knowledge-base/page.tsx index 104d3af..c227438 100644 --- a/apps/web/src/app/(dashboard)/knowledge-base/page.tsx +++ b/apps/web/src/app/(dashboard)/knowledge-base/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { EmptyState, SectionCard, StatCard, StatusPill } from '@applypilot/ui'; import { formatDateTime } from '@/lib/utils'; +import { requirePageAuth } from '@/server/auth'; import { getKnowledgeBaseEntries, getKnowledgeBaseTags, @@ -24,7 +25,8 @@ type KnowledgeBasePageProps = { type StatusTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger'; -const normalizeParam = (value?: string | string[]) => (Array.isArray(value) ? value[0] : value); +const normalizeParam = (value?: string | string[]) => + Array.isArray(value) ? value[0] : value; const matchesTag = (entry: KnowledgeEntry, selectedTag: string) => entry.tags.some((tag) => tag.toLowerCase() === selectedTag.toLowerCase()); @@ -34,13 +36,24 @@ const structureTone = (entry: KnowledgeEntry): StatusTone => const previewList = (items: string[]) => items.slice(0, 3); -export default async function KnowledgeBasePage({ searchParams }: KnowledgeBasePageProps) { +export default async function KnowledgeBasePage({ + searchParams, +}: KnowledgeBasePageProps) { + await requirePageAuth(); + const params = await searchParams; const requestedKind = normalizeParam(params?.kind); - const selectedKind = isKnowledgeEntryKind(requestedKind) ? requestedKind : null; + const selectedKind = isKnowledgeEntryKind(requestedKind) + ? requestedKind + : null; const selectedTag = normalizeParam(params?.tag) ?? null; - const [entries, tags] = await Promise.all([getKnowledgeBaseEntries(), getKnowledgeBaseTags()]); - const completeEntries = entries.filter((entry) => entry.missingSections.length === 0); + const [entries, tags] = await Promise.all([ + getKnowledgeBaseEntries(), + getKnowledgeBaseTags(), + ]); + const completeEntries = entries.filter( + (entry) => entry.missingSections.length === 0, + ); const visibleEntries = entries.filter((entry) => { const kindMatch = selectedKind ? entry.kind === selectedKind : true; const tagMatch = selectedTag ? matchesTag(entry, selectedTag) : true; @@ -78,7 +91,11 @@ export default async function KnowledgeBasePage({ searchParams }: KnowledgeBaseP - +
@@ -102,7 +119,11 @@ export default async function KnowledgeBasePage({ searchParams }: KnowledgeBaseP
@@ -143,12 +164,20 @@ export default async function KnowledgeBasePage({ searchParams }: KnowledgeBaseP