Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ dist/
build/
.vite/

# Local-only Personal Mode state and generated credentials
.careeros/

# Supabase local/temporary state
.supabase/
supabase/.temp/
Expand Down
31 changes: 28 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ hackathons, scholarships, and other resume-building opportunities; compares them
detailed saved profile; explains where the student is competitive and where they are not; and
tracks the full lifecycle from "found it" to "applied" to "interviewed."

Phase 0 is implemented. Phase 1A currently provides routed manual profile, primary-education,
and experience/research workflows: a React/TypeScript/Vite frontend, a local Supabase stack
Phase 2A is implemented as a local personal workspace. It provides a one-command Personal Mode,
local Greenhouse discovery, opportunity/application tracking, and routed manual profile,
primary-education, and experience/research workflows: a React/TypeScript/Vite frontend, a local Supabase stack
(Postgres/Auth/PostgREST), `profiles`, `education_entries`, and `work_experience` protected by
Row Level Security, and CI. Projects, links, skills, preferences, and resume features remain
deferred. See [Local development setup](#local-development-setup) below to run it.
Expand Down Expand Up @@ -57,6 +58,29 @@ matter of enabling sign-ups, not rearchitecting the schema or the pipeline. See

## Local development setup

### Personal Mode — normal daily use

With Docker or Colima running, use just:

```bash
npm run personal
```

It starts the local stack when needed, applies only pending migrations (never `supabase db reset`),
generates gitignored local runtime configuration, reuses the existing `dev-owner@careeros.local`
identity when present, refreshes the configured Greenhouse sources, and starts CareerOS at
`http://127.0.0.1:5173`. Personal Mode signs that local owner in invisibly; it never shows email
or password UI, and `sign-in` routes back into the workspace. The generated local session
credential lives only in gitignored `.careeros/` and `app/.env.local`; it is not a service-role
credential and public authentication remains dormant/recoverable for a later deployment.

The page-level **Refresh opportunities** action talks only to the loopback companion started by
this command. It runs the trusted local ingestion CLI without exposing its service-role key to the
browser. Re-running `npm run personal` is safe: existing opportunities, saved state, applications,
interview notes, profile data, and version history are preserved.

### Advanced maintainer and isolated-test setup

Prerequisites:

- Node.js `22.23.1` (the root [.nvmrc](.nvmrc) is authoritative). Run `nvm use` from the
Expand Down Expand Up @@ -87,7 +111,8 @@ supabase start
# bound to 127.0.0.1.
./supabase/scripts/dev-tunnel.sh

# 4. Apply every migration from an empty database
# 4. Apply every migration from an empty database (isolated test setup only;
# never use this during Personal Mode or against personal data)
supabase db reset

# 5. Create the two local-only development login users (idempotent).
Expand Down
14 changes: 9 additions & 5 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ React + TypeScript + Vite single-page app. Talks directly to the local Supabase
(Postgres/Auth/PostgREST) — no backend server in this project (see
[../docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md), [ADR-002](../docs/adr/ADR-002-frontend-and-backend-boundaries.md)).

See the repository root [README.md](../README.md) for full local setup instructions
(Colima/Docker, Supabase CLI, the loopback dev tunnel).
For normal personal use, run `npm run personal` from the repository root. It writes this app's
gitignored `.env.local`, establishes an invisible local Personal Mode session, refreshes configured
sources, and starts Vite. See the repository root [README.md](../README.md) for the full local
runtime notes and advanced maintainer setup.

## Scripts

Expand All @@ -23,6 +25,8 @@ See the repository root [README.md](../README.md) for full local setup instructi

## Environment variables

Copy `.env.example` to `.env.local` and fill in the values `supabase status` prints
for the local stack. Only `VITE_`-prefixed variables are exposed to the browser bundle —
never put a secret/service-role key in one.
`npm run personal` creates `.env.local` automatically for the local personal runtime. Only
`VITE_`-prefixed values are exposed to the browser bundle — never put a secret/service-role key in
one. `VITE_PERSONAL_MODE=true` is a local-only convenience boundary: it uses a generated local
owner credential to establish the existing RLS-scoped session without rendering authentication UI.
Set it false or omit it when returning to the dormant public-auth route.
40 changes: 37 additions & 3 deletions app/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import App from './App'

const mockGetSession = vi.fn()
const mockOnAuthStateChange = vi.fn()
const mockMaybeSingle = vi.fn()
const mockSignInWithPassword = vi.fn()

vi.mock('./lib/supabaseClient', () => ({
supabase: {
auth: {
getSession: () => mockGetSession(),
onAuthStateChange: (...args: unknown[]) => mockOnAuthStateChange(...args),
signInWithPassword: (...args: unknown[]) =>
mockSignInWithPassword(...args),
signOut: vi.fn(),
},
from: () => ({
Expand Down Expand Up @@ -44,16 +47,23 @@ vi.mock('./lib/profileReviewRepository', () => ({
state: () => Promise.resolve({ data: [], error: null }),
},
}))
vi.mock('./pages/DashboardPage', () => ({
DashboardPage: () => <h1>Dashboard</h1>,
}))

describe('App', () => {
beforeEach(() => {
vi.stubEnv('VITE_PERSONAL_MODE', 'false')
mockGetSession.mockReset()
mockSignInWithPassword.mockReset()
mockOnAuthStateChange.mockReturnValue({
data: { subscription: { unsubscribe: vi.fn() } },
})
mockMaybeSingle.mockResolvedValue({ data: null, error: null })
})

afterEach(() => vi.unstubAllEnvs())

it('shows the sign-in screen when there is no session (unauthenticated state)', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } })

Expand All @@ -64,18 +74,42 @@ describe('App', () => {
).toBeInTheDocument()
})

it('shows the profile screen when a session exists (authenticated state)', async () => {
it('shows the dashboard when a session exists (authenticated state)', async () => {
mockGetSession.mockResolvedValue({
data: { session: { user: { id: 'user-1' } } },
})

render(<App />)

expect(
await screen.findByRole('heading', { name: /your profile/i }),
await screen.findByRole('heading', { name: /dashboard/i }),
).toBeInTheDocument()
})

it('uses the local Personal Mode session without rendering a sign-in screen', async () => {
vi.stubEnv('VITE_PERSONAL_MODE', 'true')
vi.stubEnv('VITE_PERSONAL_OWNER_EMAIL', 'dev-owner@careeros.local')
vi.stubEnv('VITE_PERSONAL_OWNER_PASSWORD', 'local-only-password')
mockGetSession.mockResolvedValue({ data: { session: null }, error: null })
mockSignInWithPassword.mockResolvedValue({
data: { session: { user: { id: 'user-1' } } },
error: null,
})

render(<App />)

expect(
await screen.findByRole('heading', { name: /dashboard/i }),
).toBeInTheDocument()
expect(
screen.queryByRole('heading', { name: /sign in/i }),
).not.toBeInTheDocument()
expect(mockSignInWithPassword).toHaveBeenCalledWith({
email: 'dev-owner@careeros.local',
password: 'local-only-password',
})
})

it('shows a retryable error instead of loading forever when session init returns an error', async () => {
mockGetSession.mockResolvedValue({
data: { session: null },
Expand Down
9 changes: 6 additions & 3 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { PrivateOpportunityDetailPage } from './pages/PrivateOpportunityDetailPa
import { ManualOpportunityFormPage } from './pages/ManualOpportunityFormPage'
import { ApplicationsListPage } from './pages/ApplicationsListPage'
import { ApplicationDetailPage } from './pages/ApplicationDetailPage'
import { DashboardPage } from './pages/DashboardPage'

function AuthPending() {
const { initError, loading, retryInit } = useAuth()
Expand All @@ -46,7 +47,7 @@ function AuthPending() {
function RootRedirect() {
const { session, loading, initError } = useAuth()
if (loading || initError) return <AuthPending />
return <Navigate to={session ? '/profile' : '/sign-in'} replace />
return <Navigate to={session ? '/dashboard' : '/sign-in'} replace />
}

function RequireAuth() {
Expand All @@ -56,9 +57,10 @@ function RequireAuth() {
}

function SignInRoute() {
const { session, loading, initError } = useAuth()
const { session, loading, initError, personalMode } = useAuth()
if (loading || initError) return <AuthPending />
return session ? <Navigate to="/profile" replace /> : <SignInPage />
if (personalMode || session) return <Navigate to="/dashboard" replace />
return <SignInPage />
}

function NotFoundPage() {
Expand Down Expand Up @@ -97,6 +99,7 @@ const router = createBrowserRouter([
{
element: <AppLayout />,
children: [
{ path: '/dashboard', element: <DashboardPage /> },
{
path: '/profile',
element: <ProfileLayout />,
Expand Down
64 changes: 48 additions & 16 deletions app/src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ interface AuthContextValue {
initError: string | null
retryInit: () => void
signOut: () => Promise<{ error: string | null }>
personalMode: boolean
}

const AuthContext = createContext<AuthContextValue | undefined>(undefined)

export function AuthProvider({ children }: { children: ReactNode }) {
const personalMode = import.meta.env.VITE_PERSONAL_MODE === 'true'
const [session, setSession] = useState<Session | null>(null)
const [loading, setLoading] = useState(true)
const [initError, setInitError] = useState<string | null>(null)
Expand All @@ -27,26 +29,55 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
let active = true

supabase.auth
.getSession()
.then(({ data, error }) => {
if (!active) return
if (error) {
setInitError('Could not check your sign-in status. Please try again.')
setLoading(false)
return
}
async function initialize() {
const { data, error } = await supabase.auth.getSession()
if (!active) return
if (error) {
setInitError(
'Could not check your local CareerOS session. Please try again.',
)
setLoading(false)
return
}
if (data.session || !personalMode) {
setSession(data.session)
setLoading(false)
})
.catch(() => {
if (!active) return
return
}
const email = import.meta.env.VITE_PERSONAL_OWNER_EMAIL
const password = import.meta.env.VITE_PERSONAL_OWNER_PASSWORD
if (!email || !password) {
setInitError(
'Could not reach the authentication service. Please try again.',
'Personal Mode is not ready. Run npm run personal from the repository root.',
)
setLoading(false)
})
return
}
const signIn = await supabase.auth.signInWithPassword({ email, password })
if (!active) return
if (signIn.error || !signIn.data.session) {
setInitError(
'Personal Mode could not connect to the local workspace. Run npm run personal and try again.',
)
setLoading(false)
return
}
setSession(signIn.data.session)
setLoading(false)
}

void initialize().catch(() => {
if (!active) return
setInitError(
'Could not reach the local CareerOS workspace. Please try again.',
)
setLoading(false)
})

/*
* Public authentication is intentionally left available for a later
* deployment. Personal Mode only establishes a local session invisibly.
*/
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, newSession) => {
Expand All @@ -57,7 +88,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
active = false
subscription.unsubscribe()
}
}, [retryCount])
}, [retryCount, personalMode])

function retryInit() {
setLoading(true)
Expand All @@ -66,13 +97,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}

async function signOut(): Promise<{ error: string | null }> {
if (personalMode) return { error: null }
const { error } = await supabase.auth.signOut()
return { error: error ? 'Sign-out failed. Please try again.' : null }
}

return (
<AuthContext.Provider
value={{ session, loading, initError, retryInit, signOut }}
value={{ session, loading, initError, retryInit, signOut, personalMode }}
>
{children}
</AuthContext.Provider>
Expand Down
Loading