diff --git a/react-auth/ProfileCreate.tsx b/react-auth/ProfileCreate.tsx new file mode 100644 index 0000000..9c02594 --- /dev/null +++ b/react-auth/ProfileCreate.tsx @@ -0,0 +1,122 @@ +import * as React from "react"; +import { + Box, + TextField, + Button, + Typography, + CircularProgress, +} from "@mui/material"; + +export interface ProfileCreateProps { + defaultUsername?: string; + defaultEmail?: string; + onSubmit: (data: { name: string; email: string }) => Promise; + onBack?: () => void; + loading?: boolean; + error?: string | null; +} + +export function ProfileCreate({ + defaultUsername, + defaultEmail, + onSubmit, + onBack, + loading = false, + error = null, +}: ProfileCreateProps) { + const [name, setName] = React.useState(""); + const [email, setEmail] = React.useState(defaultEmail ?? ""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await onSubmit({ name: name || defaultUsername || "", email }); + }; + + return ( + + + Complete Your Profile + + + + {defaultUsername && ( + + Welcome {defaultUsername}!{" "} + + )} + Fill in your details to get started. + + +
+ {defaultUsername && ( + + )} + + setName(e.target.value)} + placeholder={defaultUsername ?? "Your name"} + required + autoFocus={!defaultUsername} + /> + + setEmail(e.target.value)} + placeholder={defaultEmail ?? "you@example.com"} + required + /> + + {error && ( + + {error} + + )} + + + {onBack && ( + + )} + + + +
+ ); +} diff --git a/react-auth/ProfileEdit.tsx b/react-auth/ProfileEdit.tsx new file mode 100644 index 0000000..b732dcd --- /dev/null +++ b/react-auth/ProfileEdit.tsx @@ -0,0 +1,111 @@ +import * as React from "react"; +import { + Box, + TextField, + Button, + Typography, + CircularProgress, +} from "@mui/material"; + +export interface ProfileEditProps { + name: string; + username: string; + email: string; + onSubmit: (data: { name: string; email: string }) => Promise; + onBack?: () => void; + loading?: boolean; + error?: string | null; +} + +export function ProfileEdit({ + name: initialName, + username, + email: initialEmail, + onSubmit, + onBack, + loading = false, + error = null, +}: ProfileEditProps) { + const [name, setName] = React.useState(initialName); + const [email, setEmail] = React.useState(initialEmail); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await onSubmit({ name, email }); + }; + + return ( + + + Edit Profile + + +
+ + + setName(e.target.value)} + required + autoFocus + /> + + setEmail(e.target.value)} + required + /> + + {error && ( + + {error} + + )} + + + {onBack && ( + + )} + + + +
+ ); +} diff --git a/react-auth/ProfileView.tsx b/react-auth/ProfileView.tsx new file mode 100644 index 0000000..fcbbef3 --- /dev/null +++ b/react-auth/ProfileView.tsx @@ -0,0 +1,85 @@ +import * as React from "react"; +import { + Box, + Typography, + Button, + Avatar, + Paper, + CircularProgress, +} from "@mui/material"; + +export interface ProfileViewProps { + name: string; + username: string; + email: string; + onEdit?: () => void; + loading?: boolean; +} + +export function ProfileView({ + name, + username, + email, + onEdit, + loading = false, +}: ProfileViewProps) { + const initials = (name || username) + .split(" ") + .map((s) => s[0]) + .join("") + .toUpperCase() + .slice(0, 2); + + return ( + + + + {initials} + + + + + {name || username} + + + @{username} + + + + + + + Email + + {email} + + + {onEdit && ( + + )} + + ); +} diff --git a/react-auth/index.ts b/react-auth/index.ts index 3fe14d6..40ae68a 100644 --- a/react-auth/index.ts +++ b/react-auth/index.ts @@ -1,6 +1,12 @@ export { AuthProvider, useAuth } from "./contexts"; export { createApiClient } from "./axios"; export { AuthPage } from "./AuthPage"; +export { ProfileCreate } from "./ProfileCreate"; +export { ProfileEdit } from "./ProfileEdit"; +export { ProfileView } from "./ProfileView"; export type { AuthUser } from "./models"; export type { AuthMode } from "./AuthPage"; +export type { ProfileCreateProps } from "./ProfileCreate"; +export type { ProfileEditProps } from "./ProfileEdit"; +export type { ProfileViewProps } from "./ProfileView"; export { tokenStore } from "./token" diff --git a/react-openapi/index.ts b/react-openapi/index.ts index fb73fa2..9eb97bf 100644 --- a/react-openapi/index.ts +++ b/react-openapi/index.ts @@ -1,5 +1,6 @@ export { AppProvider } from "./src/context/AppProvider"; -export { Admin } from "./src/components/Admin"; +export { Admin, ProfileRoutes } from "./src/components/Admin"; +export type { AdminProps, ProfileRoute } from "./src/components/Admin"; export { useAppContext } from "./src/context/AppContext"; export { useResource } from "./src/context/useResource"; export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields"; @@ -11,4 +12,4 @@ export { useItemSse } from "./src/hooks/useItemSse"; export { sanitizePayload } from "./src/utils/sanitize-payload"; export type { FkResolver } from "./src/utils/sanitize-payload"; export type { FilterComponentProps } from "./src/context/useResource"; -export type { SpecConfiguration, ResourceConfig, FieldConfig, FKFieldConfig, ResourceRelationship } from "./src/types"; +export type { SpecConfiguration, ResourceConfig, FieldConfig, FKFieldConfig, ResourceRelationship, ProfileOperation, ProfileComponents } from "./src/types"; diff --git a/react-openapi/src/components/Admin.tsx b/react-openapi/src/components/Admin.tsx index 9d1b17e..9f83789 100644 --- a/react-openapi/src/components/Admin.tsx +++ b/react-openapi/src/components/Admin.tsx @@ -1,14 +1,15 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { Routes, Route, Navigate } from "react-router-dom"; -import { Box, CircularProgress } from "@mui/material"; +import { Box, CircularProgress, Alert } from "@mui/material"; import { useAppContext } from "../context/AppContext"; import { Layout } from "./Layout"; import { ResourceList } from "./ResourceList"; import { ResourceForm } from "./ResourceForm"; import { ResourceDetail } from "./ResourceDetail"; import { ValidationAlert } from "./ValidationAlert"; +import { getApi } from "../hooks/useApi"; -interface AdminProps { +export interface AdminProps { basePath: string; } @@ -42,7 +43,8 @@ export function Admin({ basePath }: AdminProps) { {warnings.length > 0 && } - } /> + } /> + {topLevel.map((r) => ( } /> @@ -60,3 +62,174 @@ export function Admin({ basePath }: AdminProps) { ); } + +export interface ProfileRoute { + path: string; + method: string; + operationId: string; + summary?: string; +} + +/** Derive a friendly profile route path from the operation's spec path. + * e.g. "/users/me" → "me", "/users/me/" → "me" */ +function friendlyProfilePath(specPath: string): string { + return specPath.replace(/^\/[^/]+\/?/, "").replace(/\/+$/, ""); +} + +export function ProfileRoutes() { + const { profileOperations, profileComponents } = useAppContext(); + const ops = profileOperations; + const getOp = ops.find((o) => o.method === "GET"); + + return ( + + } /> + {ops.map((op) => { + const basePath = friendlyProfilePath(op.path); + let Component: React.ComponentType | undefined; + let mode: "view" | "create" | "edit" | undefined; + + if (op.method === "GET" && profileComponents.view) { + Component = profileComponents.view; + mode = "view"; + } else if (op.method === "POST" && profileComponents.create) { + Component = profileComponents.create; + mode = "create"; + } else if ((op.method === "PUT" || op.method === "PATCH") && profileComponents.edit) { + Component = profileComponents.edit; + mode = "edit"; + } + + if (!Component || !mode) return null; + + const routePath = mode === "view" ? basePath : `${basePath}/edit`; + + return ( + + } + /> + ); + })} + + ); +} + +function ProfileComponentWrapper({ + Component, + mode, + operation, + getOperation, +}: { + Component: React.ComponentType; + mode: "view" | "create" | "edit"; + operation: { path: string; method: string }; + getOperation?: { path: string }; +}) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchPath = getOperation?.path ?? operation.path; + + const fetchData = async () => { + setLoading(true); + setError(null); + try { + const res = await getApi().get(fetchPath); + setData(res.data); + } catch (e: any) { + setError(e.response?.data?.detail ?? e.message ?? "Failed to load profile"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (mode !== "create") { + fetchData(); + } + }, [fetchPath, mode]); + + const handleSubmit = async (formData: Record) => { + setLoading(true); + setError(null); + try { + if (mode === "create") { + await getApi().post(operation.path, formData); + } else { + await getApi()({ method: operation.method.toLowerCase(), url: operation.path, data: formData }); + } + // Navigate back to the base profile view path + const base = friendlyProfilePath(operation.path); + window.history.pushState(null, "", `/${base}`); + window.location.reload(); + } catch (e: any) { + setError(e.response?.data?.detail ?? e.message ?? "Operation failed"); + } finally { + setLoading(false); + } + }; + + if (loading && !data && mode !== "create") { + return ( + + + + ); + } + + if (error && !data && mode !== "create") { + return ( + + {error} + + ); + } + + const props: Record = {}; + + if (mode === "view") { + if (data) { + props.name = data.name; + props.username = data.username; + props.email = data.email; + } + props.onEdit = () => { + window.history.pushState(null, "", `/profile/me/edit`); + window.location.reload(); + }; + props.loading = loading; + } + + if (mode === "create") { + props.defaultUsername = data?.username; + props.defaultEmail = data?.email; + props.onBack = () => window.history.back(); + props.onSubmit = handleSubmit; + props.loading = loading; + props.error = error; + } + + if (mode === "edit") { + if (data) { + props.name = data.name; + props.username = data.username; + props.email = data.email; + } + props.onBack = () => window.history.back(); + props.onSubmit = handleSubmit; + props.loading = loading; + props.error = error; + } + + return ; +} \ No newline at end of file diff --git a/react-openapi/src/components/Layout.tsx b/react-openapi/src/components/Layout.tsx index d67aae8..f52bff8 100644 --- a/react-openapi/src/components/Layout.tsx +++ b/react-openapi/src/components/Layout.tsx @@ -39,4 +39,4 @@ export function Layout({ resources, basePath, children }: LayoutProps) { ); -} +} \ No newline at end of file diff --git a/react-openapi/src/components/SideMenu.tsx b/react-openapi/src/components/SideMenu.tsx index 197f674..cb9918c 100644 --- a/react-openapi/src/components/SideMenu.tsx +++ b/react-openapi/src/components/SideMenu.tsx @@ -107,4 +107,4 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP ); } -export { drawerWidth }; +export { drawerWidth }; \ No newline at end of file diff --git a/react-openapi/src/context/AppContext.tsx b/react-openapi/src/context/AppContext.tsx index 7d4bb42..87cd2a9 100644 --- a/react-openapi/src/context/AppContext.tsx +++ b/react-openapi/src/context/AppContext.tsx @@ -1,9 +1,11 @@ import { createContext, useContext } from "react"; -import type { ResourceConfig, SpecConfiguration, ValidationMessage } from "../types"; +import type { ProfileComponents, ProfileOperation, ResourceConfig, SpecConfiguration, ValidationMessage } from "../types"; export interface AppContextValue { config: SpecConfiguration; resources: ResourceConfig[]; + profileOperations: ProfileOperation[]; + profileComponents: ProfileComponents; schemas: Record; loading: boolean; errors: ValidationMessage[]; diff --git a/react-openapi/src/context/AppProvider.tsx b/react-openapi/src/context/AppProvider.tsx index 5f5ecb7..38cdd1f 100644 --- a/react-openapi/src/context/AppProvider.tsx +++ b/react-openapi/src/context/AppProvider.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useMemo } from "react"; -import type { SpecConfiguration, ResourceConfig, ValidationMessage } from "../types"; +import type { ProfileOperation, SpecConfiguration, ResourceConfig, ValidationMessage } from "../types"; import { AppContext } from "./AppContext"; import { loadSpec } from "../spec-loader"; import { validateSpec } from "../spec-validator"; @@ -11,13 +11,35 @@ interface AppProviderProps { children: React.ReactNode; } +function extractProfileOperations(spec: any): ProfileOperation[] { + const ops: ProfileOperation[] = []; + const paths = spec.paths ?? {}; + for (const [path, methods] of Object.entries>(paths)) { + for (const [method, operation] of Object.entries(methods)) { + if (method.startsWith("x-")) continue; + if (operation["x-profile"] === true) { + ops.push({ + path, + method: method.toUpperCase(), + operationId: operation.operationId ?? "", + summary: operation.summary, + }); + } + } + } + return ops; +} + export function AppProvider({ specConfiguration, children }: AppProviderProps) { const [loading, setLoading] = useState(true); const [resources, setResources] = useState([]); + const [profileOperations, setProfileOperations] = useState([]); const [schemas, setSchemas] = useState>({}); const [errors, setErrors] = useState([]); const [warnings, setWarnings] = useState([]); + const profileComponents = specConfiguration.profileComponents ?? {}; + useEffect(() => { let cancelled = false; @@ -36,6 +58,7 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) { setErrors(errs); setWarnings(warns); setSchemas(spec.components?.schemas ?? {}); + setProfileOperations(extractProfileOperations(spec)); } if (errs.length === 0) { @@ -72,12 +95,14 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) { () => ({ config: specConfiguration, resources, + profileOperations, + profileComponents, schemas, loading, errors, warnings, }), - [specConfiguration, resources, schemas, loading, errors, warnings] + [specConfiguration, resources, profileOperations, profileComponents, schemas, loading, errors, warnings] ); return React.createElement(AppContext.Provider, { value }, children); diff --git a/react-openapi/src/hooks/useApi.ts b/react-openapi/src/hooks/useApi.ts index fc29595..22eacf8 100644 --- a/react-openapi/src/hooks/useApi.ts +++ b/react-openapi/src/hooks/useApi.ts @@ -1,5 +1,4 @@ import axios, { AxiosInstance } from "axios"; -import { tokenStore } from "../../../react-auth/token"; let apiClient: AxiosInstance | null = null; @@ -21,19 +20,6 @@ export function initApi(baseUrl: string, getToken?: () => string | null): AxiosI return config; }); - apiClient.interceptors.response.use( - (res) => res, - (error) => { - if (error.response?.status === 401 && getToken) { - const currentToken = getToken(); - if (currentToken) { - tokenStore.clear(); - } - } - return Promise.reject(error); - } - ); - return apiClient; } diff --git a/react-openapi/src/spec-validator.ts b/react-openapi/src/spec-validator.ts index d508912..6566c9f 100644 --- a/react-openapi/src/spec-validator.ts +++ b/react-openapi/src/spec-validator.ts @@ -45,6 +45,11 @@ export function validateSpec(spec: OpenApiSpec, specConfig?: SpecConfiguration): 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) { diff --git a/react-openapi/src/transformers/resource-config.ts b/react-openapi/src/transformers/resource-config.ts index d5d3f34..8751192 100644 --- a/react-openapi/src/transformers/resource-config.ts +++ b/react-openapi/src/transformers/resource-config.ts @@ -80,6 +80,13 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] { for (const path of sortedPaths) { const segments = getSegments(path); const pathObj = paths[path]; + + // Skip paths where ALL non-parameter operations are profile-only (x-profile: true). + // These are handled by Admin.tsx profile routes, not as CRUD resources. + 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; + const lastSeg = segments[segments.length - 1]; const isItemPath = /^\{.*\}$/.test(lastSeg); const paramIdx = segments.findIndex( diff --git a/react-openapi/src/types.ts b/react-openapi/src/types.ts index 659b44f..2dbf4f0 100644 --- a/react-openapi/src/types.ts +++ b/react-openapi/src/types.ts @@ -6,12 +6,27 @@ export interface ResourceConfiguration { }; } +export interface ProfileComponents { + create?: React.ComponentType; + edit?: React.ComponentType; + view?: React.ComponentType; +} + export interface SpecConfiguration { specUrl: string; baseApiUrl?: string; title?: string; getToken?: () => string | null; resourceConfig?: Record; + profileComponents?: ProfileComponents; +} + +/** Represents a single operation marked with `x-profile: true` in the spec. */ +export interface ProfileOperation { + path: string; + method: string; + operationId: string; + summary?: string; } export interface ValidationMessage { diff --git a/src/Header.tsx b/src/Header.tsx index 6bf9edc..78e266b 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -134,7 +134,7 @@ export default function Header({