Skip to content
30 changes: 29 additions & 1 deletion cli/src/build-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,38 @@ const root = fileURLToPath(new URL("../..", import.meta.url))
const srcDir = fileURLToPath(new URL("../../src", import.meta.url))
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"))

// Generated static templates inline their scripts/styles, so script/style must
// allow 'unsafe-inline' (and 'unsafe-eval' for ajv's runtime schema compilation).
// The hardening that still applies: object-src/base-uri/frame-ancestors and
// restricting where scripts/images may load from.
const TEMPLATE_CSP = [
"default-src 'self'",
"connect-src * data: blob: ws: wss:",
"img-src 'self' data: https:",
"font-src 'self' data:",
"style-src 'self' 'unsafe-inline'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
].join("; ")

function templateCspPlugin() {
return {
name: "apilot-template-csp",
transformIndexHtml(html: string) {
return html.replace(
"</title>",
`</title>\n <meta http-equiv="Content-Security-Policy" content="${TEMPLATE_CSP}" />`,
)
},
}
}

const sharedConfig: InlineConfig = {
configFile: false,
root,
plugins: [react(), tailwindcss()],
plugins: [react(), tailwindcss(), templateCspPlugin()],
resolve: {
alias: {
"@": srcDir,
Expand Down
1 change: 0 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,6 @@ function AppContent() {

useSettings({
setAuthType: auth.setAuthType,
setAuthToken: auth.setAuthToken,
}, loadFromUrl)

const isEmbedded = isEmbeddedMode()
Expand Down
4 changes: 3 additions & 1 deletion src/components/channels/ChannelTestTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export function ChannelTestTab({ channel }: { channel: ParsedChannel }) {
}
url = url.replace(/\/$/, "") + address
if (authToken) {
// Browsers can't set custom headers on WebSocket, so the token must go in the
// query string. It's masked in the URL preview to avoid shoulder-surfing.
url += (url.includes("?") ? "&" : "?") + `token=${encodeURIComponent(authToken)}`
}
return url
Expand Down Expand Up @@ -147,7 +149,7 @@ export function ChannelTestTab({ channel }: { channel: ParsedChannel }) {
{/* URL preview + connect/disconnect */}
<div className="flex items-center gap-2">
<code className="text-[10px] font-mono text-muted-foreground truncate flex-1">
{buildUrl() || "ws://..."}
{buildUrl().replace(/(token=)[^&]+/, "$1***") || "ws://..."}
</code>
{ws.status === "disconnected" || ws.status === "error" ? (
<Button size="sm" className="h-7 text-xs gap-1" onClick={handleConnect} disabled={!buildUrl()}>
Expand Down
25 changes: 21 additions & 4 deletions src/components/console/ConsoleActionButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,42 @@ import type { ResourceAction } from "@/lib/console/types"
import { getRequestBodySchema } from "@/lib/console/schema-inference"
import { isDangerousAction } from "@/lib/console/template-utils"
import { ConfirmDialog } from "./ConfirmDialog"
import { PathParamFields, getPathParams, hasAllRequiredPathParams } from "./PathParamFields"
import { toast } from "sonner"

type FormOutput = Record<string, unknown> | unknown[]

export function ConsoleActionButton({ action }: { action: ResourceAction }) {
export function ConsoleActionButton({ action, pathParams = {} }: { action: ResourceAction; pathParams?: Record<string, string> }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [confirmOpen, setConfirmOpen] = useState(false)
const { mutate, loading } = useConsoleFetch()
const [formData, setFormData] = useState<FormOutput>({})
const [localPathParams, setLocalPathParams] = useState<Record<string, string>>({})

const schema = getRequestBodySchema(action.route)
const hasBody = !!schema
const dangerous = isDangerousAction(action.route)

// Path params (e.g. {id} in POST /users/{id}/activate). Some may already be
// supplied by the parent (a detail page); collect any that aren't.
const routePathParams = getPathParams(action.route)
const uncovered = routePathParams.filter(p => !pathParams[p.name]?.trim())
const needsForm = hasBody || uncovered.length > 0
const effectiveParams = { ...pathParams, ...localPathParams }
const canExecute = hasAllRequiredPathParams(action.route, effectiveParams)

const handleChange = useCallback((v: FormOutput) => setFormData(v), [])

const execute = async () => {
const body = formData && Object.keys(formData).length > 0 ? JSON.stringify(formData) : ""
const ok = await mutate(action.route, { body })
const ok = await mutate(action.route, { body, params: effectiveParams })
if (ok) toast.success(`${action.label}: ${t("console.ok")}`)
else toast.error(`${action.label}: ${t("console.requestFailed")}`)
setOpen(false)
}

if (!hasBody) {
if (!needsForm) {
return (
<>
<Button
Expand Down Expand Up @@ -73,14 +83,21 @@ export function ConsoleActionButton({ action }: { action: ResourceAction }) {
<p className="text-xs text-muted-foreground font-mono">
{action.route.method.toUpperCase()} {action.route.path}
</p>
{routePathParams.length > 0 && (
<PathParamFields
route={action.route}
values={effectiveParams}
onChange={setLocalPathParams}
/>
)}
{schema && (
<div className="-mx-6 min-h-0 flex-1 overflow-y-auto px-6 pb-4">
<SchemaForm schema={schema} value={formData} onChange={handleChange} />
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>{t("console.cancel")}</Button>
<Button onClick={execute} disabled={loading}>
<Button onClick={execute} disabled={loading || !canExecute}>
{loading ? t("console.running") : t("console.execute")}
</Button>
</DialogFooter>
Expand Down
9 changes: 6 additions & 3 deletions src/components/console/ConsoleListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { useConsoleContext } from "@/contexts/ConsoleContext"
import type { ConsoleResource } from "@/lib/console/types"
import type { Parameter } from "@/lib/openapi/types"
import { PAGINATION_TOTAL_FIELDS } from "@/lib/console/schema-inference"
import { Skeleton } from "@/components/ui/skeleton"
import { ConsoleFilterBar } from "./ConsoleFilterBar"
import { ConfirmDialog } from "./ConfirmDialog"
Expand Down Expand Up @@ -63,7 +64,7 @@
const [filters, setFilters] = useState<Record<string, string>>({})

const listOp = resource.operations.list
const listParams = listOp?.route.parameters ?? []

Check warning on line 67 in src/components/console/ConsoleListPage.tsx

View workflow job for this annotation

GitHub Actions / Lint & Typecheck & Build

The 'listParams' logical expression could make the dependencies of useMemo Hook (at line 88) change on every render. To fix this, wrap the initialization of 'listParams' in its own useMemo() Hook

Check warning on line 67 in src/components/console/ConsoleListPage.tsx

View workflow job for this annotation

GitHub Actions / Lint & Typecheck & Build

The 'listParams' logical expression could make the dependencies of useMemo Hook (at line 71) change on every render. To fix this, wrap the initialization of 'listParams' in its own useMemo() Hook

const { offsetParam, limitParam, isPageBased, sortParams } = useMemo(
() => detectPaginationParams(listParams),
Expand All @@ -89,9 +90,11 @@
const extractTotalCount = useCallback((parsed: unknown) => {
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>
for (const key of ["total", "count", "total_count", "totalCount", "totalItems"]) {
if (typeof obj[key] === "number") {
setTotalCount(obj[key] as number)
// Case-insensitive match, consistent with detectPagination's field set.
for (const key of PAGINATION_TOTAL_FIELDS) {
const match = Object.keys(obj).find(k => k.toLowerCase() === key)
if (match && typeof obj[match] === "number") {
setTotalCount(obj[match] as number)
return
}
}
Expand Down Expand Up @@ -129,7 +132,7 @@
setError(`${result.status} ${result.statusText}`)
}
}
}, [resource, sendRequest, listOp, hasPagination, offsetParam, limitParam, isPageBased, pagination, filters, extractTotalCount, consoleState.refreshCounter])

Check warning on line 135 in src/components/console/ConsoleListPage.tsx

View workflow job for this annotation

GitHub Actions / Lint & Typecheck & Build

React Hook useCallback has unnecessary dependencies: 'consoleState.refreshCounter' and 'resource'. Either exclude them or remove the dependency array

useEffect(() => {
setPagination(prev => prev.limit === 100 && defaultLimit !== 100 ? { offset: 0, limit: defaultLimit } : prev)
Expand Down
61 changes: 61 additions & 0 deletions src/components/console/PathParamFields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useTranslation } from "react-i18next"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Field, FieldLabel } from "@/components/ui/field"
import type { ParsedRoute, Parameter } from "@/lib/openapi/types"

/** Path (`in: "path"`) parameters declared on a route, e.g. {id} in /users/{id}. */
export function getPathParams(route: ParsedRoute): Parameter[] {
return (route.parameters ?? []).filter(p => p.in === "path")
}

/** True if every required path parameter has a non-empty value. */
export function hasAllRequiredPathParams(route: ParsedRoute, values: Record<string, string>): boolean {
return getPathParams(route).every(p => !p.required || !!values[p.name]?.trim())
}

/**
* Inline editor for a route's path parameters with an optional "load" action.
* Detail/editor/config/stats templates render this so a resource whose read
* operation needs an id (GET /users/{id}) is actually usable instead of silently
* blank. Renders nothing when the route has no path parameters.
*/
export function PathParamFields({
route, values, onChange, onLoad, loading, loadLabel,
}: {
route: ParsedRoute
values: Record<string, string>
onChange: (next: Record<string, string>) => void
onLoad?: () => void
loading?: boolean
loadLabel?: string
}) {
const { t } = useTranslation()
const params = getPathParams(route)
if (params.length === 0) return null
const ready = hasAllRequiredPathParams(route, values)
return (
<div className="flex flex-wrap items-end gap-2">
{params.map(p => (
<Field key={p.name} className="w-44">
<FieldLabel htmlFor={`pp-${p.name}`} className="text-xs">
{p.name}{p.required ? " *" : ""}
</FieldLabel>
<Input
id={`pp-${p.name}`}
className="h-8 text-xs font-mono"
value={values[p.name] ?? ""}
placeholder={p.name}
onChange={e => onChange({ ...values, [p.name]: e.target.value })}
onKeyDown={e => { if (e.key === "Enter" && ready && onLoad) onLoad() }}
/>
</Field>
))}
{onLoad && (
<Button size="sm" className="h-8" onClick={onLoad} disabled={!ready || loading}>
{loadLabel ?? t("console.load")}
</Button>
)}
</div>
)
}
17 changes: 12 additions & 5 deletions src/components/console/templates/ActionFormTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useConsoleContext } from "@/contexts/ConsoleContext"
import { applyFieldLayout } from "@/lib/console/apply-layout"
import type { FormFieldConfig, ResourceAction } from "@/lib/console/types"
import { getRequestBodySchema } from "@/lib/console/schema-inference"
import { PathParamFields, getPathParams, hasAllRequiredPathParams } from "../PathParamFields"
import { toast } from "sonner"
import type { TemplateProps } from "./index"

Expand Down Expand Up @@ -38,14 +39,17 @@ function ActionCard({ action, fieldConfigs }: { action: ResourceAction; fieldCon
const { submitJson, loading } = useConsoleFetch()
const [formData, setFormData] = useState<FormOutput>({})
const [response, setResponse] = useState<string | null>(null)
const [pathParams, setPathParams] = useState<Record<string, string>>({})

const rawSchema = getRequestBodySchema(action.route)
const schema = rawSchema ? applyFieldLayout(rawSchema, fieldConfigs) : null
const routePathParams = getPathParams(action.route)
const canSubmit = hasAllRequiredPathParams(action.route, pathParams)
const handleChange = useCallback((v: FormOutput) => setFormData(v), [])

const handleSubmit = async () => {
const body = schema ? JSON.stringify(formData) : ""
const { ok, response: resp } = await submitJson(action.route, body)
const { ok, response: resp } = await submitJson(action.route, body, pathParams)
if (ok) toast.success(`${action.label}: OK`)
else toast.error(`${action.label}: failed`)
setResponse(resp)
Expand All @@ -64,13 +68,16 @@ function ActionCard({ action, fieldConfigs }: { action: ResourceAction; fieldCon
<CardDescription className="text-xs">{action.route.description}</CardDescription>
)}
</CardHeader>
{schema && (
<CardContent>
<SchemaForm schema={schema} value={formData} onChange={handleChange} />
{(routePathParams.length > 0 || schema) && (
<CardContent className="flex flex-col gap-3">
{routePathParams.length > 0 && (
<PathParamFields route={action.route} values={pathParams} onChange={setPathParams} />
)}
{schema && <SchemaForm schema={schema} value={formData} onChange={handleChange} />}
</CardContent>
)}
<CardFooter className="flex gap-2">
<Button size="sm" onClick={handleSubmit} disabled={loading}>
<Button size="sm" onClick={handleSubmit} disabled={loading || !canSubmit}>
{loading ? t("console.running") : t("console.execute")}
</Button>
</CardFooter>
Expand Down
25 changes: 20 additions & 5 deletions src/components/console/templates/ConfigFormTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useConsoleContext } from "@/contexts/ConsoleContext"
import { applyFieldLayout } from "@/lib/console/apply-layout"
import { stableEqual } from "@/lib/console/template-utils"
import { ConfirmDialog } from "../ConfirmDialog"
import { PathParamFields, getPathParams, hasAllRequiredPathParams } from "../PathParamFields"
import { toast } from "sonner"
import type { TemplateProps } from "./index"

Expand All @@ -25,23 +26,25 @@ export function ConfigFormTemplate({ resource, layoutOverride }: TemplateProps)
const [formData, setFormData] = useState<FormOutput>({})
const [error, setError] = useState<string | null>(null)
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false)
const [pathParams, setPathParams] = useState<Record<string, string>>({})

const readOp = resource.operations.read
const updateOp = resource.operations.update
const rawSchema = resource.updateSchema ?? resource.detailSchema
const schema = rawSchema ? applyFieldLayout(rawSchema, layout?.formFields) : null
const needsInput = !!readOp && getPathParams(readOp.route).length > 0

const dirty = useMemo(() => data !== null && !stableEqual(formData, data), [formData, data])

const fetchConfig = useCallback(async () => {
if (!readOp) return
if (!readOp || !hasAllRequiredPathParams(readOp.route, pathParams)) return
setError(null)
const { data: parsed, error: err } = await fetchJson<FormOutput>(readOp.route)
const { data: parsed, error: err } = await fetchJson<FormOutput>(readOp.route, pathParams)
if (parsed) { setData(parsed); setFormData(parsed) }
setError(err)
}, [readOp, fetchJson])
}, [readOp, fetchJson, pathParams])

useEffect(() => { fetchConfig() }, [fetchConfig])
useEffect(() => { if (!needsInput) fetchConfig() }, [needsInput, fetchConfig])

const handleChange = useCallback((v: FormOutput) => setFormData(v), [])

Expand All @@ -52,7 +55,7 @@ export function ConfigFormTemplate({ resource, layoutOverride }: TemplateProps)

const handleSave = async () => {
if (!updateOp) return
const ok = await mutate(updateOp.route, { body: JSON.stringify(formData) })
const ok = await mutate(updateOp.route, { body: JSON.stringify(formData), params: pathParams })
if (ok) { toast.success(t("console.updated")); fetchConfig() }
else toast.error(t("console.updateFailed", { status: "" }))
}
Expand All @@ -71,6 +74,18 @@ export function ConfigFormTemplate({ resource, layoutOverride }: TemplateProps)
</CardHeader>

<CardContent>
{needsInput && readOp && (
<div className="mb-4">
<PathParamFields
route={readOp.route}
values={pathParams}
onChange={setPathParams}
onLoad={fetchConfig}
loading={loading}
/>
</div>
)}

{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive mb-4">{error}</div>
)}
Expand Down
26 changes: 21 additions & 5 deletions src/components/console/templates/DetailCardTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useConsoleContext } from "@/contexts/ConsoleContext"
import { applyDetailLayout } from "@/lib/console/apply-layout"
import { ConsoleFormDialog } from "../ConsoleFormDialog"
import { ConsoleActionButton } from "../ConsoleActionButton"
import { PathParamFields, getPathParams, hasAllRequiredPathParams } from "../PathParamFields"
import type { TemplateProps } from "./index"

export function DetailCardTemplate({ resource, layoutOverride }: TemplateProps) {
Expand All @@ -19,19 +20,22 @@ export function DetailCardTemplate({ resource, layoutOverride }: TemplateProps)
const { fetchJson, loading } = useConsoleFetch()
const [data, setData] = useState<Record<string, unknown> | null>(null)
const [error, setError] = useState<string | null>(null)
const [pathParams, setPathParams] = useState<Record<string, string>>({})

const readOp = resource.operations.read
const hasUpdate = !!resource.operations.update
const needsInput = !!readOp && getPathParams(readOp.route).length > 0

const fetchDetail = useCallback(async () => {
if (!readOp) return
if (!readOp || !hasAllRequiredPathParams(readOp.route, pathParams)) return
setError(null)
const { data: parsed, error: err } = await fetchJson<Record<string, unknown>>(readOp.route)
const { data: parsed, error: err } = await fetchJson<Record<string, unknown>>(readOp.route, pathParams)
setData(parsed)
setError(err)
}, [readOp, fetchJson])
}, [readOp, fetchJson, pathParams])

useEffect(() => { fetchDetail() }, [fetchDetail])
// Auto-load only when no path parameter input is required.
useEffect(() => { if (!needsInput) fetchDetail() }, [needsInput, fetchDetail])

return (
<div className="flex flex-col gap-4 py-4 h-full overflow-auto">
Expand All @@ -56,6 +60,18 @@ export function DetailCardTemplate({ resource, layoutOverride }: TemplateProps)
</CardHeader>

<CardContent>
{needsInput && readOp && (
<div className="mb-4">
<PathParamFields
route={readOp.route}
values={pathParams}
onChange={setPathParams}
onLoad={fetchDetail}
loading={loading}
/>
</div>
)}

{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive mb-4">
{error}
Expand Down Expand Up @@ -92,7 +108,7 @@ export function DetailCardTemplate({ resource, layoutOverride }: TemplateProps)
{resource.actions.length > 0 && (
<div className="flex items-center gap-2 mt-4 flex-wrap">
{resource.actions.map((action, i) => (
<ConsoleActionButton key={i} action={action} />
<ConsoleActionButton key={i} action={action} pathParams={pathParams} />
))}
</div>
)}
Expand Down
Loading
Loading