From b52d4a62870b1cace76782052d74eb3d23566555 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 22 Sep 2026 22:24:14 +0200 Subject: [PATCH] Enhance intake link functionality and theme management - Added `NEXT_PUBLIC_APP_URL` to `.env.example` for app URL configuration. - Updated `README.md` to document the new `NEXT_PUBLIC_APP_URL` variable. - Improved CSS to support light and dark color schemes. - Refactored intake link actions to utilize the new app URL and handle token generation more efficiently. - Enhanced public intake route to validate work area selection. - Updated `IntakeLinksPanel` to allow copying of generated links. - Improved error handling and user experience in `BriefPreviewPublic` and `IntakePublic` components. - Refined theme synchronization and toggle components for better user preference management. --- .env.example | 4 + README.md | 1 + src/app/actions/intake-links.ts | 17 ++-- src/app/api/public/intake/route.test.ts | 59 ++++++++++++ src/app/api/public/intake/route.ts | 10 +- src/app/globals.css | 25 ++--- src/components/admin/intake-links-panel.tsx | 18 +++- .../intake/brief-preview-public.tsx | 46 ++++----- src/components/intake/intake-public.tsx | 93 +++++++++++++------ src/components/theme/theme-sync.tsx | 25 +++-- src/components/theme/theme-toggle.tsx | 26 ++---- src/lib/intake-links/url.test.ts | 20 ++++ src/lib/intake-links/url.ts | 19 ++++ src/lib/theme/preference.test.ts | 34 +++++++ src/lib/theme/preference.ts | 7 ++ 15 files changed, 301 insertions(+), 103 deletions(-) create mode 100644 src/app/api/public/intake/route.test.ts create mode 100644 src/lib/intake-links/url.test.ts create mode 100644 src/lib/intake-links/url.ts create mode 100644 src/lib/theme/preference.test.ts create mode 100644 src/lib/theme/preference.ts diff --git a/.env.example b/.env.example index c77dab3..b160d0c 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,10 @@ # to it directly — so the URL itself is not a secret. NEXT_PUBLIC_PB_URL=https://pocketbase.example.com +# Public address of this app. Used as a fallback when a link is generated +# without a browser Origin header. Set this to your production HTTPS domain. +NEXT_PUBLIC_APP_URL=https://planner.example.com + # Superuser credentials, used server-side only by scripts/setup-remote.ts, # scripts/seed-demo.ts, and the /api/client/* route handlers. PB_SUPERUSER_EMAIL=you@example.com diff --git a/README.md b/README.md index f2b035e..8133416 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Fill in `.env.local`: | Variable | Notes | | --- | --- | | `NEXT_PUBLIC_PB_URL` | The Coolify PocketBase URL. Public by design — the browser connects to it directly. | +| `NEXT_PUBLIC_APP_URL` | Public HTTPS address of the app, used if a link request has no Origin header. Set it to the deployed domain, for example `https://planner.apphub.co.za`. | | `PB_SUPERUSER_EMAIL` | Server-side only. Never exposed to the browser. | | `PB_SUPERUSER_PASSWORD` | Server-side only. Never commit this. | diff --git a/src/app/actions/intake-links.ts b/src/app/actions/intake-links.ts index b9f4c48..784b63e 100644 --- a/src/app/actions/intake-links.ts +++ b/src/app/actions/intake-links.ts @@ -1,10 +1,12 @@ "use server"; import { revalidatePath } from "next/cache"; +import { headers } from "next/headers"; import { getAdminPb } from "@/lib/pb/admin"; import { requireSession } from "@/lib/auth/session"; import { generateIntakeToken } from "@/lib/intake/token"; +import { intakeUrl } from "@/lib/intake-links/url"; import type { IntakeLinkRecord } from "@/lib/pb/types"; /** Owner-only entry point. See src/app/actions/log-work.ts for the pattern. */ @@ -37,10 +39,12 @@ export async function createIntakeLinkAction(input: { const label = input.label.trim().slice(0, 80); if (!label) throw new Error("A label is required"); + const token = generateIntakeToken(); + const url = await intakeUrlFor(token); const createFields: Record = { label, - token: generateIntakeToken(), + token, active: true, expires_at: input.expiresAt ?? "", max_submissions_per_day: input.maxSubmissionsPerDay ?? 100, @@ -66,7 +70,7 @@ export async function createIntakeLinkAction(input: { return { id: record.id, token: record.token, - url: intakeUrlFor(record.token), + url, }; } @@ -80,10 +84,11 @@ export async function rotateIntakeTokenAction(input: { const pb = await getAdminPb(); const token = generateIntakeToken(); + const url = await intakeUrlFor(token); await pb.collection("intake_links").update(input.linkId, { token }); revalidatePath(adminPathForScope(input.scope)); - return { token, url: intakeUrlFor(token) }; + return { token, url }; } export async function setIntakeLinkActiveAction(input: { @@ -130,7 +135,7 @@ export async function deleteIntakeLinkAction(input: { revalidatePath(adminPathForScope(input.scope)); } -function intakeUrlFor(token: string): string { - const base = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; - return `${base}/intake/${token}`; +async function intakeUrlFor(token: string): Promise { + const requestOrigin = (await headers()).get("origin"); + return intakeUrl(token, requestOrigin, process.env.NEXT_PUBLIC_APP_URL, process.env.NODE_ENV === "production"); } diff --git a/src/app/api/public/intake/route.test.ts b/src/app/api/public/intake/route.test.ts new file mode 100644 index 0000000..b296df6 --- /dev/null +++ b/src/app/api/public/intake/route.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; + +import { POST } from "@/app/api/public/intake/route"; +import { getAdminPb } from "@/lib/pb/admin"; +import { resolveIntakeLink } from "@/lib/intake-links"; +import { resolveWorkArea } from "@/lib/work-areas/server"; + +vi.mock("@/lib/pb/admin", () => ({ getAdminPb: vi.fn() })); +vi.mock("@/lib/intake-links", () => ({ + resolveIntakeLink: vi.fn(), + isLinkProjectUsable: vi.fn(), + bumpDailyCapForProject: vi.fn(), + bumpSubmissionCount: vi.fn(), +})); +vi.mock("@/lib/work-areas/server", () => ({ resolveWorkArea: vi.fn() })); + +describe("public intake submission", () => { + it("rejects a request with no area before creating a card", async () => { + const createCard = vi.fn(async () => ({ id: "card-1", number: 1, title: "Request" })); + const collections = { + columns: { getFirstListItem: vi.fn(async () => ({ id: "inbox-1" })) }, + cards: { + getFirstListItem: vi.fn(async () => { throw new Error("No cards"); }), + getFullList: vi.fn(async () => []), + create: createCard, + }, + activity: { create: vi.fn(async () => ({})) }, + }; + vi.mocked(getAdminPb).mockResolvedValue({ + collection: (name: string) => collections[name as keyof typeof collections], + } as never); + vi.mocked(resolveIntakeLink).mockResolvedValue({ + id: "link-1", + token: "a".repeat(32), + active: true, + expires_at: "", + project: "project-1", + client: "", + } as never); + vi.mocked(resolveWorkArea).mockResolvedValue(""); + + const body = { + token: "a".repeat(32), + brief: { title: "Request", summary: "Please help", subtasks: [], openQuestions: [] }, + transcript: [], + }; + const submit = (payload: Record) => POST(new Request("http://localhost/api/public/intake", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + })); + const response = await submit(body); + const malformed = await submit({ ...body, areaId: { id: "area-1" } }); + + expect(response.status).toBe(400); + expect(malformed.status).toBe(400); + expect(createCard).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/public/intake/route.ts b/src/app/api/public/intake/route.ts index a29c1d9..5271de6 100644 --- a/src/app/api/public/intake/route.ts +++ b/src/app/api/public/intake/route.ts @@ -13,6 +13,7 @@ import { import { resolveProjectForClient } from "@/lib/intake-links/client-projects"; import { jsonError, jsonOk } from "@/lib/http/json"; import { resolveWorkArea } from "@/lib/work-areas/server"; +import { cleanWorkAreaName } from "@/lib/work-areas/core"; import type { CardRecord, ColumnRecord } from "@/lib/pb/types"; /** @@ -62,6 +63,11 @@ export async function POST(request: Request): Promise { ) { return jsonError("Not found", 404); } + const areaId = typeof record.areaId === "string" ? record.areaId.trim() : ""; + const newAreaName = cleanWorkAreaName(record.newAreaName); + if (!areaId && !newAreaName) { + return jsonError("Choose an area", 400); + } const pb = await getAdminPb(); @@ -111,8 +117,8 @@ export async function POST(request: Request): Promise { } const workArea = await resolveWorkArea(pb, resolvedProjectId, { - areaId: record.areaId as string | undefined, - newAreaName: record.newAreaName as string | undefined, + areaId, + newAreaName, }).catch(() => null); if (workArea === null) return jsonError("Area not found in this project", 400); diff --git a/src/app/globals.css b/src/app/globals.css index fd58d32..9c1ac68 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -18,6 +18,7 @@ * board becomes unreadable on a client's mid-range laptop. */ :root { + color-scheme: light; /* Structure: a tinted page with a raised panel floating on it. */ --page: #eef2f5; --panel: #ffffff; @@ -58,6 +59,7 @@ } .dark { + color-scheme: dark; --page: #051a2d; --panel: #0a2338; --surface: #122d44; @@ -154,6 +156,7 @@ body { */ @media (prefers-color-scheme: dark) { :root:not(.light) { + color-scheme: dark; --page: #051a2d; --panel: #0a2338; --surface: #122d44; @@ -164,23 +167,23 @@ body { --text-primary: #f0f4f8; --text-secondary: #b8c8d8; - --text-muted: #8aa1b5; + --text-muted: #8499ad; --brand: #10bbc3; - --brand-hover: #4dd0d6; - --brand-soft: #103e44; + --brand-hover: #2dd0d8; + --brand-soft: #0c3a45; - --accent: #fea24f; - --accent-soft: #3a2410; + --accent: #ff5782; + --accent-soft: #4d1629; - --priority-critical: #fea24f; - --priority-high: #f0a16d; + --priority-critical: #ff5782; + --priority-high: #ffb775; --priority-medium: #10bbc3; - --priority-low: #8aa1b5; + --priority-low: #8499ad; - --shadow-panel: 0 1px 2px rgba(0, 0, 0, 0.3), + --shadow-panel: 0 1px 2px rgba(0, 0, 0, 0.4), 0 16px 40px -12px rgba(0, 0, 0, 0.6); - --shadow-tile: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-tile: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-drag: 0 12px 28px -6px rgba(0, 0, 0, 0.7); } } @@ -204,4 +207,4 @@ body { :focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; -} \ No newline at end of file +} diff --git a/src/components/admin/intake-links-panel.tsx b/src/components/admin/intake-links-panel.tsx index f9a9a67..e7e2fbd 100644 --- a/src/components/admin/intake-links-panel.tsx +++ b/src/components/admin/intake-links-panel.tsx @@ -21,10 +21,9 @@ import type { IntakeLinkRecord } from "@/lib/pb/types"; * The two paths share the same UI; only the `scope` they forward to the * Server Actions differs. * - * The URL is shown once on creation and on rotation. After that the owner - * can only re-copy the same URL — losing the URL does not lose access, - * because the admin page itself can read it. The token itself never leaves - * this panel except via a clipboard write the owner explicitly triggers. + * The URL is shown on creation and rotation. An existing link can also be + * copied again using the current app origin, so links first created during + * local development can be shared from the production admin page. * * Notes are persisted on save; the others act on click. The list shows * status, expiry, daily cap and the lifetime submission count, which is @@ -166,7 +165,7 @@ export function IntakeLinksPanel({ {newUrl && (

- New URL — copy it now, it won't be shown again in full: + New URL — copy it to share with your client:

@@ -210,6 +209,15 @@ export function IntakeLinksPanel({ <> · expires {link.expires_at.slice(0, 10)} )} +
)} - +