## Summary
Wire spec-driven auth into the frontend. Auth config (server URL, paths) is extracted from the served OpenAPI spec by `AppProvider`. `AuthProvider` receives the config as a prop. 401 responses from both the main API and the auth server dispatch an `auth:unauthorized` event that triggers redirect to `/login`. Fix `ProfileRoutes` URL duplication bug.
## Changes
### Auth config from spec
- **`main.jsx`** — `AppProvider` wraps everything, loads spec, exposes `authConfig`. `AuthProvider` receives `authConfig` from `useAppContext()`. `onUnauthorized` passed to both `AppProvider` and `AuthProvider` wires `navigate("/login")`.
### 401 handling
- **`AppProvider.tsx`** — remove `onUnauthorized` prop (handled by `AuthProvider`'s event listener instead, avoiding double-navigation).
- **`useApi.ts`** — 401 response interceptor dispatches `auth:unauthorized` CustomEvent on `window`.
### Profile routing fix
- **`Admin.tsx:ProfileRoutes`** — replace nested `<Routes>` (which caused `/profile/me/me` URL duplication with React Router v6) with `useLocation()`/`useNavigate()` conditional rendering. Only allows `/profile/me` and `/profile/me/edit`.
- **`Admin.tsx:ProfileComponentWrapper`** — replace `pushState() + reload()` with React Router `navigate()` for both `onEdit` and `handleSubmit`.
### Debug logging
- Temporary console logs at every navigation point for diagnosing remaining issues.
Reviewed-on: #12
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
141 lines
6.2 KiB
TypeScript
141 lines
6.2 KiB
TypeScript
import type { OpenApiSpec, ValidationMessage, SpecConfiguration } from "./types";
|
|
|
|
function getSegments(path: string): string[] {
|
|
return path.split("/").filter(Boolean);
|
|
}
|
|
|
|
function getResponseSchemaRef(pathObj: any): string | undefined {
|
|
const response = pathObj?.get?.responses?.["200"] ?? pathObj?.get?.responses?.["201"]
|
|
?? pathObj?.post?.responses?.["200"] ?? pathObj?.post?.responses?.["201"];
|
|
const content = response?.content;
|
|
if (!content) return;
|
|
for (const mediaType of Object.values(content) as any[]) {
|
|
if (mediaType?.schema?.$ref) return mediaType.schema.$ref;
|
|
if (mediaType?.schema?.items?.$ref) return mediaType.schema.items.$ref;
|
|
if (mediaType?.schema?.properties?.items?.items?.$ref) return mediaType.schema.properties.items.items.$ref;
|
|
}
|
|
}
|
|
|
|
export function validateSpec(spec: OpenApiSpec, specConfig?: SpecConfiguration): ValidationMessage[] {
|
|
const messages: ValidationMessage[] = [];
|
|
const schemas = (spec.components?.schemas ?? {}) as Record<string, any>;
|
|
const paths = spec.paths ?? {};
|
|
|
|
if (!spec.openapi) {
|
|
messages.push({ type: "error", message: "Missing 'openapi' version field" });
|
|
}
|
|
|
|
if (!spec.info?.title) {
|
|
messages.push({ type: "error", message: "Missing 'info.title'" });
|
|
}
|
|
|
|
if (!spec.servers?.[0]?.url && !specConfig?.baseApiUrl) {
|
|
messages.push({ type: "warning", message: "No 'servers[0].url' defined — provide 'baseApiUrl' in specConfiguration" });
|
|
}
|
|
|
|
for (const [path, pathObj] of Object.entries(paths) as [string, any][]) {
|
|
if (!pathObj || typeof pathObj !== "object") continue;
|
|
|
|
const segments = getSegments(path);
|
|
const lastSeg = segments[segments.length - 1];
|
|
const isItemPath = /^\{.*\}$/.test(lastSeg);
|
|
const paramIdx = segments.findIndex((s, i) => i < segments.length - 1 && /^\{.*\}$/.test(s));
|
|
const isSubResource = paramIdx >= 0 && !isItemPath;
|
|
|
|
const hasSSE = pathObj?.get?.["x-sse"] === true;
|
|
if (hasSSE) continue;
|
|
|
|
// Skip paths where all operations are profile-only (x-profile: true).
|
|
const pathMethods = Object.entries(pathObj).filter(([k]) => !k.startsWith("x-") && k !== "parameters");
|
|
const allProfile = pathMethods.length > 0 && pathMethods.every(([, op]: any) => op["x-profile"] === true);
|
|
if (allProfile) continue;
|
|
|
|
if (isItemPath || isSubResource) {
|
|
const responseRef = getResponseSchemaRef(pathObj);
|
|
if (responseRef) {
|
|
const schemaName = responseRef.split("/").pop()!;
|
|
if (!schemas[schemaName]) {
|
|
messages.push({ type: "error", message: `Path "${path}" references schema "${schemaName}" which does not exist in components/schemas` });
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const responseRef = getResponseSchemaRef(pathObj);
|
|
if (responseRef) {
|
|
const schemaName = responseRef.split("/").pop()!;
|
|
const schema = schemas[schemaName];
|
|
if (!schema) {
|
|
messages.push({ type: "error", message: `Path "${path}" references schema "${schemaName}" which does not exist in components/schemas` });
|
|
continue;
|
|
}
|
|
|
|
if (!schema["x-primary-key"]) {
|
|
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-primary-key'` });
|
|
}
|
|
if (!schema["x-display-format"]) {
|
|
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-display-format'` });
|
|
}
|
|
if (!schema["x-list-columns"]) {
|
|
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-list-columns'` });
|
|
}
|
|
|
|
if (Array.isArray(schema["x-list-columns"])) {
|
|
const props = schema.properties ?? {};
|
|
for (const col of schema["x-list-columns"]) {
|
|
if (!props[col]) {
|
|
messages.push({ type: "error", message: `"${schemaName}.x-list-columns" references "${col}" but no such property exists` });
|
|
}
|
|
}
|
|
}
|
|
|
|
const props = schema.properties ?? {};
|
|
for (const [propName, _raw] of Object.entries(props)) {
|
|
const prop = _raw as any;
|
|
if (!prop || typeof prop !== "object") continue;
|
|
if (!prop["x-label"]) {
|
|
messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-label'` });
|
|
}
|
|
if (prop["x-order"] === undefined || prop["x-order"] === null) {
|
|
messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-order'` });
|
|
}
|
|
if (prop["$ref"] && !prop["x-fk"]) {
|
|
const refName = (prop["$ref"] as string).split("/").pop();
|
|
messages.push({ type: "info", message: `"${schemaName}.${propName}" uses $ref to "${refName}" without x-fk — will render inline` });
|
|
}
|
|
if (prop.type === "array" && prop.items?.$ref && !prop["x-fk"]) {
|
|
const refName = (prop.items.$ref as string).split("/").pop();
|
|
messages.push({ type: "info", message: `"${schemaName}.${propName}" is an array of $ref to "${refName}" without x-fk — will render inline` });
|
|
}
|
|
if (prop["x-fk"]) {
|
|
const fkResource = prop["x-fk"].resource as string;
|
|
const fkPaths = Object.keys(paths).filter((p) => !/^\{.*\}$/.test(getSegments(p).pop() ?? ""));
|
|
const targetExists = fkPaths.some((p) => getSegments(p).pop() === fkResource);
|
|
if (!targetExists) {
|
|
messages.push({ type: "error", message: `"${schemaName}.${propName}" x-fk references resource "${fkResource}" but no path matches that resource name` });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!pathObj?.get) {
|
|
messages.push({ type: "error", message: `"${path}" has no GET list endpoint — datatable cannot be populated` });
|
|
}
|
|
|
|
const listParams = pathObj?.get?.parameters ?? [];
|
|
const limitParam = listParams.find((p: any) => p.in === "query" && p.name === "limit");
|
|
const offsetParam = listParams.find((p: any) => p.in === "query" && p.name === "offset");
|
|
if (limitParam || offsetParam) {
|
|
if (!limitParam?.schema?.default) {
|
|
messages.push({ type: "error", message: `"${path}.get" has pagination params but 'limit' schema is missing 'default'` });
|
|
}
|
|
}
|
|
|
|
if (!pathObj?.post) {
|
|
messages.push({ type: "error", message: `"${path}" has no POST endpoint — creation not possible` });
|
|
}
|
|
}
|
|
|
|
return messages;
|
|
}
|