diff --git a/apps/dashboard/.env.local.example b/apps/dashboard/.env.local.example new file mode 100644 index 0000000..ad25a27 --- /dev/null +++ b/apps/dashboard/.env.local.example @@ -0,0 +1,10 @@ +# Fallback backend URL — only used if the per-session `api_base_url` cookie +# is not set. The SaaS → /activate/[token] handoff sets that cookie, and the +# server proxy at /api/proxy/* forwards to whatever URL it contains. +NEXT_PUBLIC_API_URL=http://localhost:8001 + +# Used by the "Workspace session not found" page to link back to the SaaS. +NEXT_PUBLIC_SAAS_URL=http://localhost:8000 + +# Image remotePatterns wildcard for SaaS-provisioned subdomains. +NEXT_PUBLIC_GARAGE_HOST_PATTERN=*.reparee.com diff --git a/apps/dashboard/app/activate/[token]/page.tsx b/apps/dashboard/app/activate/[token]/page.tsx new file mode 100644 index 0000000..895cee0 --- /dev/null +++ b/apps/dashboard/app/activate/[token]/page.tsx @@ -0,0 +1,100 @@ +import { redirect } from "next/navigation" +import { AlertCircle, ShieldCheck } from "lucide-react" + +import { applyHandoff } from "@/modules/auth/auth.actions" + +type SearchParams = { ws?: string; api?: string } + +export default async function ActivatePage(props: { + params: Promise<{ token: string }> + searchParams: Promise +}) { + const { token } = await props.params + const { ws, api } = await props.searchParams + + if (!ws || !api) { + return ( + + ) + } + + let parsed: URL + try { + parsed = new URL(api) + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Backend URL must be http(s).") + } + } catch { + return ( + + ) + } + + const endpoint = `${parsed.toString().replace(/\/$/, "")}/api/saas/handoff/exchange` + + let response: Response + try { + response = await fetch(endpoint, { + method: "POST", + cache: "no-store", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "X-Workspace-UUID": ws, + }, + body: JSON.stringify({ + handoff_token: token, + saas_workspace_uuid: ws, + }), + }) + } catch (err) { + return ( + + ) + } + + let payload: { token?: string; user?: any; message?: string } = {} + try { + payload = await response.json() + } catch { + // ignore — handled below + } + + if (!response.ok || !payload.token || !payload.user) { + return ( + + ) + } + + await applyHandoff(payload.token, payload.user, parsed.toString().replace(/\/$/, ""), ws) + + redirect("/") +} + +function ActivationError({ title, detail }: { title: string; detail: string }) { + return ( +
+
+ +

{title}

+

{detail}

+

+ + Re-open the garage from the SaaS dashboard to get a fresh link. +

+
+
+ ) +} diff --git a/apps/dashboard/app/api/proxy/[...path]/route.ts b/apps/dashboard/app/api/proxy/[...path]/route.ts new file mode 100644 index 0000000..35c2fc0 --- /dev/null +++ b/apps/dashboard/app/api/proxy/[...path]/route.ts @@ -0,0 +1,97 @@ +import { cookies } from "next/headers" +import { NextRequest, NextResponse } from "next/server" + +const HOP_BY_HOP = new Set([ + "host", + "connection", + "content-length", + "keep-alive", + "transfer-encoding", + "upgrade", + "te", + "trailer", + "proxy-authorization", + "proxy-authenticate", +]) + +async function forward(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { + const cookieStore = await cookies() + const apiBase = cookieStore.get("api_base_url")?.value + const wsUuid = cookieStore.get("workspace_uuid")?.value + const token = cookieStore.get("auth_token")?.value + + if (!apiBase || !wsUuid) { + return NextResponse.json( + { + message: "Workspace session not found. Please open your garage from the SaaS dashboard.", + code: "workspace_session_missing", + }, + { status: 412 }, + ) + } + + const { path } = await ctx.params + const url = new URL(req.url) + // The OpenAPI clients keep `/api/...` in their route patterns, so when the + // client uses baseUrl=`/api/proxy` the resulting URL is + // `/api/proxy/api/customers`. Forward the path verbatim and rejoin so the + // upstream becomes `{apiBase}/api/customers`. + const target = `${apiBase.replace(/\/$/, "")}/${(path ?? []).join("/")}${url.search}` + + const headers = new Headers() + req.headers.forEach((value, key) => { + if (!HOP_BY_HOP.has(key.toLowerCase()) && key.toLowerCase() !== "cookie") { + headers.set(key, value) + } + }) + headers.set("Accept", "application/json") + headers.set("X-Workspace-UUID", wsUuid) + if (token) { + headers.set("Authorization", `Bearer ${token}`) + } + + const init: RequestInit = { + method: req.method, + headers, + redirect: "manual", + } + + if (req.method !== "GET" && req.method !== "HEAD") { + init.body = await req.arrayBuffer() + } + + let upstream: Response + try { + upstream = await fetch(target, init) + } catch (err) { + return NextResponse.json( + { + message: "Failed to reach garage backend.", + code: "backend_unreachable", + error: (err as Error).message, + }, + { status: 502 }, + ) + } + + const respHeaders = new Headers() + upstream.headers.forEach((value, key) => { + const k = key.toLowerCase() + if (HOP_BY_HOP.has(k) || k === "set-cookie") return + respHeaders.set(key, value) + }) + + return new NextResponse(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: respHeaders, + }) +} + +export const GET = forward +export const POST = forward +export const PUT = forward +export const PATCH = forward +export const DELETE = forward +export const OPTIONS = forward +export const HEAD = forward diff --git a/apps/dashboard/modules/auth/auth.actions.ts b/apps/dashboard/modules/auth/auth.actions.ts index c842aa4..9b893a8 100644 --- a/apps/dashboard/modules/auth/auth.actions.ts +++ b/apps/dashboard/modules/auth/auth.actions.ts @@ -5,8 +5,12 @@ import type { AuthUser } from "@garage/api" const TOKEN_COOKIE = "auth_token" const USER_COOKIE = "auth_user" +const WORKSPACE_COOKIE = "workspace_uuid" +const API_BASE_COOKIE = "api_base_url" const DEFAULT_EXPIRES_IN = 60 * 60 * 24 * 7 // 7 days in seconds +const isProd = process.env.NODE_ENV === "production" + export async function setAuthCookies( token: string, user: AuthUser, @@ -19,12 +23,16 @@ export async function setAuthCookies( expires, path: "/", sameSite: "strict", + httpOnly: true, + secure: isProd, }) cookieStore.set(USER_COOKIE, JSON.stringify(user), { expires, path: "/", sameSite: "strict", + httpOnly: true, + secure: isProd, }) } @@ -32,6 +40,8 @@ export async function clearAuthCookies() { const cookieStore = await cookies() cookieStore.delete(TOKEN_COOKIE) cookieStore.delete(USER_COOKIE) + cookieStore.delete(WORKSPACE_COOKIE) + cookieStore.delete(API_BASE_COOKIE) } export async function getAuthCookies(): Promise<{ @@ -53,3 +63,65 @@ export async function getAuthCookies(): Promise<{ return { token, user } } + +/** + * Set the per-session workspace context: which garage backend to proxy to, + * and which workspace UUID to send on every upstream call. Written by the + * SaaS → garage-erp activation handoff. + */ +export async function setWorkspaceContext( + apiBase: string, + workspaceUuid: string, + expiresIn: number = DEFAULT_EXPIRES_IN, +) { + const cookieStore = await cookies() + const expires = new Date(Date.now() + expiresIn * 1000) + + cookieStore.set(API_BASE_COOKIE, apiBase, { + expires, + path: "/", + sameSite: "strict", + httpOnly: true, + secure: isProd, + }) + + cookieStore.set(WORKSPACE_COOKIE, workspaceUuid, { + expires, + path: "/", + sameSite: "strict", + httpOnly: true, + secure: isProd, + }) +} + +export async function getWorkspaceContext(): Promise<{ + apiBase: string | undefined + workspaceUuid: string | undefined +}> { + const cookieStore = await cookies() + return { + apiBase: cookieStore.get(API_BASE_COOKIE)?.value, + workspaceUuid: cookieStore.get(WORKSPACE_COOKIE)?.value, + } +} + +export async function clearWorkspaceContext() { + const cookieStore = await cookies() + cookieStore.delete(WORKSPACE_COOKIE) + cookieStore.delete(API_BASE_COOKIE) +} + +/** + * Server-action used by the SaaS handoff page. Wraps cookie writes for both + * the auth token/user and the workspace context atomically (from the client's + * point of view). + */ +export async function applyHandoff( + token: string, + user: AuthUser, + apiBase: string, + workspaceUuid: string, +) { + await setAuthCookies(token, user) + await setWorkspaceContext(apiBase, workspaceUuid) +} diff --git a/apps/dashboard/next.config.mjs b/apps/dashboard/next.config.mjs index 1ec8fe3..7956448 100644 --- a/apps/dashboard/next.config.mjs +++ b/apps/dashboard/next.config.mjs @@ -20,6 +20,12 @@ const nextConfig = { hostname: 'reparee.test', port: '', pathname: '/**', + }, + { + protocol: 'https', + hostname: process.env.NEXT_PUBLIC_GARAGE_HOST_PATTERN ?? '*.reparee.com', + port: '', + pathname: '/**', } ] } diff --git a/apps/dashboard/shared/api.ts b/apps/dashboard/shared/api.ts index fa0fdce..82e694c 100644 --- a/apps/dashboard/shared/api.ts +++ b/apps/dashboard/shared/api.ts @@ -1,12 +1,25 @@ -import { createApi, } from "@garage/api"; -import { useAuthStore } from "./stores/auth-store"; -import { getAuthCookies } from "@/modules/auth/auth.actions"; +import { createApi } from "@garage/api" +import { getAuthCookies, getWorkspaceContext } from "@/modules/auth/auth.actions" +/** + * Returns an authenticated API client for server-side use (server actions, RSC). + * + * Server-side calls bypass the /api/proxy route — the server already has + * cookie access and can talk to the workspace's repareenew install directly. + * Browser-side code should use the singleton `api` from @garage/api, which + * defaults to baseUrl="/api/proxy" and gets workspace context from cookies + * via the proxy route. + */ export const getAuthApi = async () => { - const { token } = await getAuthCookies(); - console.log(`Auth Token: ${token}`); - const api = createApi({ headers: token ? { Authorization: `Bearer ${token}` } : undefined }); - return api; + const { token } = await getAuthCookies() + const { apiBase, workspaceUuid } = await getWorkspaceContext() + + const headers: Record = {} + if (token) headers.Authorization = `Bearer ${token}` + if (workspaceUuid) headers["X-Workspace-UUID"] = workspaceUuid + + return createApi({ + baseUrl: apiBase, + headers: Object.keys(headers).length ? headers : undefined, + }) } - - diff --git a/apps/dashboard/shared/components/WorkspaceSessionMissing.tsx b/apps/dashboard/shared/components/WorkspaceSessionMissing.tsx new file mode 100644 index 0000000..c4cdc53 --- /dev/null +++ b/apps/dashboard/shared/components/WorkspaceSessionMissing.tsx @@ -0,0 +1,24 @@ +import Link from "next/link" +import { AlertCircle, ExternalLink } from "lucide-react" + +export function WorkspaceSessionMissing({ saasUrl }: { saasUrl?: string }) { + const fallbackUrl = saasUrl ?? process.env.NEXT_PUBLIC_SAAS_URL ?? "/" + return ( +
+
+ +

Workspace session not found

+

+ Please open your garage from the SaaS dashboard to get a fresh handoff link. +

+ + + Back to SaaS dashboard + +
+
+ ) +} diff --git a/packages/api/src/api.ts b/packages/api/src/api.ts index cf9e858..df75b31 100644 --- a/packages/api/src/api.ts +++ b/packages/api/src/api.ts @@ -60,70 +60,87 @@ import { InventoryCategoriesClient } from "./clients/inventory-categories" import { DocumentPrintClient } from "./clients/document-print" import { DocumentShareClient } from "./clients/document-share" -export function createApi(options?: ApiClientOptions) { +export type CreateApiOptions = ApiClientOptions & { baseUrl?: string } + +/** + * Default base URL used when no explicit override is passed. The dashboard + * runs every browser call through the Next.js server proxy at /api/proxy so + * the build-time NEXT_PUBLIC_API_URL never reaches the client. + * + * Server-side callers (server actions, RSC) can opt out by passing + * `{ baseUrl: }` from a cookie. + */ +const DEFAULT_BASE_URL = "/api/proxy" + +export function createApi(options?: CreateApiOptions) { + const baseUrl = options?.baseUrl ?? DEFAULT_BASE_URL + const restOptions: ApiClientOptions | undefined = options?.headers + ? { headers: options.headers } + : undefined + return { - auth: new AuthClient(undefined, options), - customers: new CustomersClient(undefined, options), - referralSources: new ReferralSourcesClient(undefined, options), - vehicles: new VehiclesClient(undefined, options), - vehicleAttributes: new VehicleAttributesClient(undefined, options), - vehicleDocuments: new VehicleDocumentsClient(undefined, options), - departments: new DepartmentsClient(undefined, options), - employees: new EmployeesClient(undefined, options), - geo: new GeoClient(undefined, options), - paymentTerms: new PaymentTermsClient(undefined, options), - shopTypes: new ShopTypesClient(undefined, options), - inventory: new InventoryClient(undefined, options), - vendors: new VendorsClient(undefined, options), - inspections: new InspectionsClient(undefined, options), - inspectionTemplates: new InspectionTemplatesClient(undefined, options), - labels: new LabelsClient(undefined, options), - insuranceTypes: new InsuranceTypesClient(undefined, options), - estimates: new EstimatesClient(undefined, options), - quickRemarks: new QuickRemarksClient(undefined, options), - quickNotes: new QuickNotesClient(undefined, options), - shopRecommendations: new ShopRecommendationsClient(undefined, options), - jobCards: new JobCardsClient(undefined, options), - paymentModes: new PaymentModesClient(undefined, options), - paymentReceived: new PaymentReceivedClient(undefined, options), - parts: new PartsClient(undefined, options), - purchaseOrders: new PurchaseOrdersClient(undefined, options), - services: new ServicesClient(undefined, options), - serviceGroups: new ServiceGroupsClient(undefined, options), - expenses: new ExpensesClient(undefined, options), - tasks: new TasksClient(undefined, options), - taskTypes: new TaskTypesClient(undefined, options), - taskSections: new TaskSectionsClient(undefined, options), - appointments: new AppointmentsClient(undefined, options), - shopTimings: new ShopTimingsClient(undefined, options), - shopCalendars: new ShopCalendarsClient(undefined, options), - holidayYears: new HolidayYearsClient(undefined, options), - taxes: new TaxesClient(undefined, options), - invoices: new InvoicesClient(undefined, options), - home: new HomeClient(undefined, options), - bills: new BillsClient(undefined, options), - reasons: new ReasonsClient(undefined, options), - holidays: new HolidaysClient(undefined, options), - makeAndModels: new MakeAndModelsClient(undefined, options), - timeSheets: new TimeSheetsClient(undefined, options), - invoiceSequences: new InvoiceSequencesClient(undefined, options), - creditNotes: new CreditNotesClient(undefined, options), - paymentMades: new PaymentMadesClient(undefined, options), - vendorCredits: new VendorCreditsClient(undefined, options), - inventoryAdjustments: new InventoryAdjustmentsClient(undefined, options), - serviceGroupIncludes: new ServiceGroupIncludesClient(undefined, options), - serviceGroupPricings: new ServiceGroupPricingsClient(undefined, options), - serviceGroupServices: new ServiceGroupServicesClient(undefined, options), - serviceGroupParts: new ServiceGroupPartsClient(undefined, options), - settings: new SettingsClient(undefined, options), - configurations: new ConfigurationsClient(undefined, options), - autoGenerate: new AutoGenerateClient(undefined, options), - expenseItems: new ExpenseItemsClient(undefined, options), - inventoryCategories: new InventoryCategoriesClient(undefined, options), - documentPrint: new DocumentPrintClient(undefined, options), - documentShare: new DocumentShareClient(undefined, options), + auth: new AuthClient(baseUrl, restOptions), + customers: new CustomersClient(baseUrl, restOptions), + referralSources: new ReferralSourcesClient(baseUrl, restOptions), + vehicles: new VehiclesClient(baseUrl, restOptions), + vehicleAttributes: new VehicleAttributesClient(baseUrl, restOptions), + vehicleDocuments: new VehicleDocumentsClient(baseUrl, restOptions), + departments: new DepartmentsClient(baseUrl, restOptions), + employees: new EmployeesClient(baseUrl, restOptions), + geo: new GeoClient(baseUrl, restOptions), + paymentTerms: new PaymentTermsClient(baseUrl, restOptions), + shopTypes: new ShopTypesClient(baseUrl, restOptions), + inventory: new InventoryClient(baseUrl, restOptions), + vendors: new VendorsClient(baseUrl, restOptions), + inspections: new InspectionsClient(baseUrl, restOptions), + inspectionTemplates: new InspectionTemplatesClient(baseUrl, restOptions), + labels: new LabelsClient(baseUrl, restOptions), + insuranceTypes: new InsuranceTypesClient(baseUrl, restOptions), + estimates: new EstimatesClient(baseUrl, restOptions), + quickRemarks: new QuickRemarksClient(baseUrl, restOptions), + quickNotes: new QuickNotesClient(baseUrl, restOptions), + shopRecommendations: new ShopRecommendationsClient(baseUrl, restOptions), + jobCards: new JobCardsClient(baseUrl, restOptions), + paymentModes: new PaymentModesClient(baseUrl, restOptions), + paymentReceived: new PaymentReceivedClient(baseUrl, restOptions), + parts: new PartsClient(baseUrl, restOptions), + purchaseOrders: new PurchaseOrdersClient(baseUrl, restOptions), + services: new ServicesClient(baseUrl, restOptions), + serviceGroups: new ServiceGroupsClient(baseUrl, restOptions), + expenses: new ExpensesClient(baseUrl, restOptions), + tasks: new TasksClient(baseUrl, restOptions), + taskTypes: new TaskTypesClient(baseUrl, restOptions), + taskSections: new TaskSectionsClient(baseUrl, restOptions), + appointments: new AppointmentsClient(baseUrl, restOptions), + shopTimings: new ShopTimingsClient(baseUrl, restOptions), + shopCalendars: new ShopCalendarsClient(baseUrl, restOptions), + holidayYears: new HolidayYearsClient(baseUrl, restOptions), + taxes: new TaxesClient(baseUrl, restOptions), + invoices: new InvoicesClient(baseUrl, restOptions), + home: new HomeClient(baseUrl, restOptions), + bills: new BillsClient(baseUrl, restOptions), + reasons: new ReasonsClient(baseUrl, restOptions), + holidays: new HolidaysClient(baseUrl, restOptions), + makeAndModels: new MakeAndModelsClient(baseUrl, restOptions), + timeSheets: new TimeSheetsClient(baseUrl, restOptions), + invoiceSequences: new InvoiceSequencesClient(baseUrl, restOptions), + creditNotes: new CreditNotesClient(baseUrl, restOptions), + paymentMades: new PaymentMadesClient(baseUrl, restOptions), + vendorCredits: new VendorCreditsClient(baseUrl, restOptions), + inventoryAdjustments: new InventoryAdjustmentsClient(baseUrl, restOptions), + serviceGroupIncludes: new ServiceGroupIncludesClient(baseUrl, restOptions), + serviceGroupPricings: new ServiceGroupPricingsClient(baseUrl, restOptions), + serviceGroupServices: new ServiceGroupServicesClient(baseUrl, restOptions), + serviceGroupParts: new ServiceGroupPartsClient(baseUrl, restOptions), + settings: new SettingsClient(baseUrl, restOptions), + configurations: new ConfigurationsClient(baseUrl, restOptions), + autoGenerate: new AutoGenerateClient(baseUrl, restOptions), + expenseItems: new ExpenseItemsClient(baseUrl, restOptions), + inventoryCategories: new InventoryCategoriesClient(baseUrl, restOptions), + documentPrint: new DocumentPrintClient(baseUrl, restOptions), + documentShare: new DocumentShareClient(baseUrl, restOptions), } } -/** Unauthenticated singleton — use for public calls (login, register) */ +/** Unauthenticated singleton — use for public calls (login, register). */ export const api = createApi()