feat: implement workspace session management and enhance API proxy handling

This commit is contained in:
Najjar\NajjarV02 2026-05-20 16:23:19 +04:00
parent 4f0a2f790f
commit ccb9eacd86
8 changed files with 410 additions and 71 deletions

View File

@ -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

View File

@ -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<SearchParams>
}) {
const { token } = await props.params
const { ws, api } = await props.searchParams
if (!ws || !api) {
return (
<ActivationError
title="Activation link is incomplete"
detail="The link is missing the workspace identifier or backend URL. Please open the garage again from the SaaS dashboard."
/>
)
}
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 (
<ActivationError
title="Activation link is invalid"
detail="The backend URL provided in the activation link is malformed."
/>
)
}
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 (
<ActivationError
title="Cannot reach the garage backend"
detail={(err as Error).message}
/>
)
}
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 (
<ActivationError
title="Activation failed"
detail={payload.message ?? `HTTP ${response.status}`}
/>
)
}
await applyHandoff(payload.token, payload.user, parsed.toString().replace(/\/$/, ""), ws)
redirect("/")
}
function ActivationError({ title, detail }: { title: string; detail: string }) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 p-6">
<div className="max-w-md w-full text-center space-y-3 bg-white rounded-xl shadow-sm border p-8">
<AlertCircle className="size-12 mx-auto text-rose-500" />
<h1 className="text-lg font-semibold">{title}</h1>
<p className="text-sm text-muted-foreground">{detail}</p>
<p className="text-xs text-muted-foreground pt-2 flex items-center justify-center gap-1.5">
<ShieldCheck className="size-3.5" />
Re-open the garage from the SaaS dashboard to get a fresh link.
</p>
</div>
</div>
)
}

View File

@ -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

View File

@ -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)
}

View File

@ -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: '/**',
}
]
}

View File

@ -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<string, string> = {}
if (token) headers.Authorization = `Bearer ${token}`
if (workspaceUuid) headers["X-Workspace-UUID"] = workspaceUuid
return createApi({
baseUrl: apiBase,
headers: Object.keys(headers).length ? headers : undefined,
})
}

View File

@ -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 (
<div className="min-h-[60vh] flex items-center justify-center p-6">
<div className="max-w-md w-full text-center space-y-3 bg-white rounded-xl shadow-sm border p-8">
<AlertCircle className="size-10 mx-auto text-amber-500" />
<h1 className="text-lg font-semibold">Workspace session not found</h1>
<p className="text-sm text-muted-foreground">
Please open your garage from the SaaS dashboard to get a fresh handoff link.
</p>
<Link
href={fallbackUrl}
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm border bg-white hover:bg-muted/40 transition"
>
<ExternalLink className="size-3.5" />
Back to SaaS dashboard
</Link>
</div>
</div>
)
}

View File

@ -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: <absolute repareenew URL> }` 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()