diff --git a/.cursor/rules/sync-before-code-changes.mdc b/.cursor/rules/sync-before-code-changes.mdc index cf3513c..0ae3b5f 100644 --- a/.cursor/rules/sync-before-code-changes.mdc +++ b/.cursor/rules/sync-before-code-changes.mdc @@ -15,6 +15,7 @@ Before **any** file edit, commit, or new branch work: 3. If this branch has (or had) an open PR, check merge state **before** stacking more commits: - `gh pr list --head $(git branch --show-current) --state all --json number,state,mergedAt` - If `MERGED`: do **not** keep committing on this branch. Follow `CLAUDE.md` — stash if needed, fast-forward `main`, start a **new** branch for follow-up work. + - If it is **not** merged and `git log origin/main..HEAD` is non-empty: **keep this branch**. Rebase onto `origin/main` if behind; do not open a second branch for the same line of work. 4. If `git rev-parse --verify MERGE_HEAD` or `.git/rebase-merge` exists, finish or abort that operation first; do not stash over an in-progress merge/rebase without asking the user. Only after the branch is aligned with intent (rebased on current `main` or confirmed still the right feature branch) should you edit files. diff --git a/CLAUDE.md b/CLAUDE.md index 114e570..81dc08f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,9 @@ Also check the **current branch** before editing: `git log HEAD..origin/main --oneline` and whether its PR is already merged (`gh pr list --head --state all`). If the PR merged, stop committing on that branch — rebase or branch fresh from `origin/main` for follow-ups (see step 2 below). +If the PR is **not** merged and the branch already has commits not in +`origin/main`, keep working on that branch (rebase if behind); do not start +a second branch for the same work. If `origin/main` has commits not in local `main`: diff --git a/apps/bff/src/contributions/contributions.controller.ts b/apps/bff/src/contributions/contributions.controller.ts index 90103ef..524d245 100644 --- a/apps/bff/src/contributions/contributions.controller.ts +++ b/apps/bff/src/contributions/contributions.controller.ts @@ -1,10 +1,19 @@ import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { ContributionsService } from './contributions.service.js'; -import { VerifiedGuard, CurrentUser, type AuthenticatedUser } from '@bubltec/mycota-auth'; +import { + JwtAuthGuard, + VerifiedGuard, + CurrentUser, + type AuthenticatedUser, +} from '@bubltec/mycota-auth'; // Value import — `import type` erases the class before emitDecoratorMetadata runs, // so ValidationPipe sees paramtypes [Function, Object] and never transforms `payload`. import { CreateContributionDto } from './dto/create-contribution.dto.js'; +// Prod still requires verifiedContributor. Dev/local only needs a session — +// otherwise a first login (email, no quiz) hits 403 and the queue looks empty. +const ModerationGuard = process.env.STAGE === 'prod' ? VerifiedGuard : JwtAuthGuard; + @Controller('contributions') export class ContributionsController { constructor(private readonly contributions: ContributionsService) {} @@ -16,15 +25,15 @@ export class ContributionsController { } @Get('pending') - @UseGuards(VerifiedGuard) + @UseGuards(ModerationGuard) async listPending() { - // MVP: any verified contributor can see the moderation queue. Restrict to + // Prod: verified contributors. Non-prod: any signed-in user. Restrict to // an admin allowlist before opening this up publicly. return this.contributions.listPending(); } @Post(':thingId/:sk/approve') - @UseGuards(VerifiedGuard) + @UseGuards(ModerationGuard) async approve( @Param('thingId') thingId: string, @Param('sk') sk: string, diff --git a/apps/e2e/scripts/app-context.ts b/apps/e2e/scripts/app-context.ts index 093bdf8..14effc2 100644 --- a/apps/e2e/scripts/app-context.ts +++ b/apps/e2e/scripts/app-context.ts @@ -79,7 +79,10 @@ matter here since the test-code endpoint reads the code straight from the database, never the inbox.) Verification: a brand-new signed-in user has verifiedContributor: false and -cannot submit things or see the moderation queue. Two real paths exist (a +cannot submit things. On prod they also cannot see the moderation queue; on +non-prod, a signed-in session is enough for the contribution queue (the page +also POSTs /api/auth/test/verify so organization reviews unlock). Two real +paths exist (a pop quiz with randomized answers, or a slower human-reviewed professional path) but neither is scriptable deterministically. Instead, call POST /api/auth/test/verify (authenticated, i.e. after completing email sign-in diff --git a/apps/web/src/components/Nav.tsx b/apps/web/src/components/Nav.tsx index e3a85dc..08e089c 100644 --- a/apps/web/src/components/Nav.tsx +++ b/apps/web/src/components/Nav.tsx @@ -1,4 +1,5 @@ import { Link } from 'react-router-dom'; +import { isNonProdHost } from '../lib/env.js'; import { useCurrentUser } from '../lib/useCurrentUser.js'; import { EmailSignInDialog } from './EmailSignInDialog.js'; @@ -21,7 +22,7 @@ export function Nav() { Add a thing Add - {user?.verifiedContributor && ( + {user && (user.verifiedContributor || isNonProdHost()) && ( Moderation diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index f922cba..e380bbc 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -17,7 +17,9 @@ async function request(path: string, init?: RequestInit): Promise { export type CurrentUser = User; export const api = { - listThings: (params: { q?: string; petType?: string; thingType?: string; breed?: string } = {}) => { + listThings: ( + params: { q?: string; petType?: string; thingType?: string; breed?: string } = {}, + ) => { const entries = Object.entries(params).filter((entry): entry is [string, string] => Boolean(entry[1]), ); @@ -39,6 +41,8 @@ export const api = { submitContribution: (payload: Record, thingId?: string) => request('/contributions', { method: 'POST', body: JSON.stringify({ thingId, payload }) }), listPendingContributions: () => request('/contributions/pending'), + unlockDevContributor: () => + request<{ verifiedContributor: boolean }>('/auth/test/verify', { method: 'POST' }), requestProfessionalVerification: (email: string) => request<{ orgClassification?: string }>('/verification/professional/request', { method: 'POST', diff --git a/apps/web/src/lib/env.ts b/apps/web/src/lib/env.ts new file mode 100644 index 0000000..4901b41 --- /dev/null +++ b/apps/web/src/lib/env.ts @@ -0,0 +1,6 @@ +/** Runtime host check — not SITE_ORIGIN, which is hardcoded to prod for prerender. */ +export function isNonProdHost(): boolean { + if (typeof window === 'undefined') return false; + const host = window.location.hostname; + return host === 'localhost' || host === '127.0.0.1' || host.startsWith('dev.'); +} diff --git a/apps/web/src/lib/useCurrentUser.ts b/apps/web/src/lib/useCurrentUser.ts index 2e1d5ed..dfe8c3d 100644 --- a/apps/web/src/lib/useCurrentUser.ts +++ b/apps/web/src/lib/useCurrentUser.ts @@ -1,23 +1,25 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { api, type CurrentUser } from './api.js'; export function useCurrentUser() { const [user, setUser] = useState(undefined); + const refresh = useCallback( + () => + api + .me() + .then(setUser) + .catch(() => setUser(null)), + [], + ); + useEffect(() => { - api - .me() - .then(setUser) - .catch(() => setUser(null)); - }, []); + void refresh(); + }, [refresh]); return { user, loading: user === undefined, - refresh: () => - api - .me() - .then(setUser) - .catch(() => setUser(null)), + refresh, }; } diff --git a/apps/web/src/pages/ModerationPage.tsx b/apps/web/src/pages/ModerationPage.tsx index c276191..eccee8c 100644 --- a/apps/web/src/pages/ModerationPage.tsx +++ b/apps/web/src/pages/ModerationPage.tsx @@ -2,6 +2,9 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import type { User } from '@btfp/shared-types'; import { api } from '../lib/api.js'; +import { isNonProdHost } from '../lib/env.js'; +import { useCurrentUser } from '../lib/useCurrentUser.js'; +import { EmailSignInDialog } from '../components/EmailSignInDialog.js'; interface PendingContribution { PK: string; @@ -12,15 +15,38 @@ interface PendingContribution { createdAt: string; } +function SignInPrompt({ onSignedIn }: { onSignedIn: () => void }) { + return ( +
+

Sign in to review the queue.

+ +
+ ); +} + function ContributionsSection() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); function load() { setLoading(true); + setError(null); api .listPendingContributions() .then((data) => setItems(data as PendingContribution[])) + .catch((err: unknown) => { + setItems([]); + setError(err instanceof Error ? err.message : 'Could not load pending contributions'); + }) .finally(() => setLoading(false)); } @@ -30,19 +56,33 @@ function ContributionsSection() { async function approve(item: PendingContribution) { const thingId = item.PK.replace('THING#', ''); - await fetch(`/api/contributions/${thingId}/${encodeURIComponent(item.SK)}/approve`, { - method: 'POST', - credentials: 'include', - }); - load(); + try { + const res = await fetch( + `/api/contributions/${thingId}/${encodeURIComponent(item.SK)}/approve`, + { + method: 'POST', + credentials: 'include', + }, + ); + if (!res.ok) { + const body = (await res.json().catch(() => ({ message: undefined }))) as { + message?: string; + }; + throw new Error(body.message ?? `Approve failed: ${res.status}`); + } + load(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Approve failed'); + } } return (

Pending contributions

+ {error &&

{error}

} {loading ? (

Loading…

- ) : items.length === 0 ? ( + ) : items.length === 0 && !error ? (

Nothing pending. 🎉

) : (
    @@ -79,12 +119,18 @@ function ContributionsSection() { function ProfessionalVerificationsSection() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); function load() { setLoading(true); + setError(null); api .listPendingProfessionalVerifications() .then(setItems) + .catch((err: unknown) => { + setItems([]); + setError(err instanceof Error ? err.message : 'Could not load organization verifications'); + }) .finally(() => setLoading(false)); } @@ -93,17 +139,22 @@ function ProfessionalVerificationsSection() { }, []); async function review(user: User, approve: boolean) { - const reason = approve ? undefined : (prompt('Rejection reason (optional):') ?? undefined); - await api.reviewProfessionalVerification(user.id, approve, reason); - load(); + try { + const reason = approve ? undefined : (prompt('Rejection reason (optional):') ?? undefined); + await api.reviewProfessionalVerification(user.id, approve, reason); + load(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Review failed'); + } } return (

    Pending organization verifications

    + {error &&

    {error}

    } {loading ? (

    Loading…

    - ) : items.length === 0 ? ( + ) : items.length === 0 && !error ? (

    Nothing pending. 🎉

    ) : (
      @@ -139,14 +190,64 @@ function ProfessionalVerificationsSection() { ); } +function DevUnlock({ onUnlocked }: { onUnlocked: () => void }) { + const [error, setError] = useState(null); + const [busy, setBusy] = useState(true); + + useEffect(() => { + let cancelled = false; + api + .unlockDevContributor() + .then(() => { + if (!cancelled) onUnlocked(); + }) + .catch((err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Could not unlock moderation on dev'); + } + }) + .finally(() => { + if (!cancelled) setBusy(false); + }); + return () => { + cancelled = true; + }; + }, [onUnlocked]); + + if (busy) { + return

      Unlocking moderation on this environment…

      ; + } + if (error) { + return

      {error}

      ; + } + return null; +} + export function ModerationPage() { + const { user, loading, refresh } = useCurrentUser(); + const needsDevUnlock = Boolean(user && !user.verifiedContributor && isNonProdHost()); + + if (loading) { + return
      Loading…
      ; + } + return (

      Moderation queue

      -
      - - -
      + {!user ? ( + + ) : !user.verifiedContributor && !isNonProdHost() ? ( +

      + You're signed in, but only verified contributors can review the queue. Take the quiz + from Add a thing first. +

      + ) : ( +
      + {needsDevUnlock && } + + {user.verifiedContributor && } +
      + )}
      ); }