diff --git a/apps/backend/internal/services/functions.go b/apps/backend/internal/services/functions.go index a43e729..b0d05b2 100644 --- a/apps/backend/internal/services/functions.go +++ b/apps/backend/internal/services/functions.go @@ -228,6 +228,11 @@ func (s *PlatformService) CreateFunction(ctx context.Context, actor *models.Acto if err != nil { return FunctionSummary{}, err } + if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil { + _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "function.created", "function", row.ID.String(), map[string]any{ + "name": row.Name, "runtime": row.Runtime, + })) + } return functionSummary(row), nil } @@ -282,6 +287,11 @@ func (s *PlatformService) UpdateFunction(ctx context.Context, actor *models.Acto if err != nil { return FunctionSummary{}, err } + if project, err := s.repo.Queries().GetProjectByID(ctx, row.ProjectID); err == nil { + _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, row.ProjectID, actor, requestID, "function.updated", "function", functionID.String(), map[string]any{ + "name": updated.Name, + })) + } return functionSummary(updated), nil } @@ -293,10 +303,18 @@ func (s *PlatformService) DeleteFunction(ctx context.Context, actor *models.Acto if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil { return err } - return s.repo.Queries().DeleteFunction(ctx, db.DeleteFunctionParams{ + if err := s.repo.Queries().DeleteFunction(ctx, db.DeleteFunctionParams{ ID: functionID, ProjectID: row.ProjectID, - }) + }); err != nil { + return err + } + if project, err := s.repo.Queries().GetProjectByID(ctx, row.ProjectID); err == nil { + _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, row.ProjectID, actor, requestID, "function.deleted", "function", functionID.String(), map[string]any{ + "name": row.Name, + })) + } + return nil } // InvokeFunction executes a function with a JSON payload, records the run, @@ -309,7 +327,16 @@ func (s *PlatformService) InvokeFunction(ctx context.Context, actor *models.Acto if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil { return FunctionRunSummary{}, err } - return s.executeFunction(ctx, row, payload, "manual") + summary, err := s.executeFunction(ctx, row, payload, "manual") + if err != nil { + return FunctionRunSummary{}, err + } + if project, err := s.repo.Queries().GetProjectByID(ctx, row.ProjectID); err == nil { + _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, row.ProjectID, actor, requestID, "function.invoked", "function", functionID.String(), map[string]any{ + "name": row.Name, "status": summary.Status, + })) + } + return summary, nil } // executeFunction runs a function row and records the outcome — shared by diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index 5f3780b..fa569a9 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -27,13 +27,20 @@ RUN npm run build # Stage 2: Serve with Nginx FROM nginx:1.27-alpine COPY --from=builder /app/apps/frontend/dist /usr/share/nginx/html -# Add simple nginx config for SPA routing +# Add simple nginx config for SPA routing. index.html must always revalidate — +# it references hashed assets that change every deploy. Hashed /assets files are +# immutable and safe to cache long-term. RUN echo 'server { \ listen 80; \ + root /usr/share/nginx/html; \ + index index.html; \ + location /assets/ { \ + try_files $uri =404; \ + add_header Cache-Control "public, max-age=31536000, immutable"; \ + } \ location / { \ - root /usr/share/nginx/html; \ - index index.html; \ try_files $uri $uri/ /index.html; \ + add_header Cache-Control "no-cache, must-revalidate"; \ } \ }' > /etc/nginx/conf.d/default.conf diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 5cb3577..1e09fc3 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -26,7 +26,7 @@ import { import { Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js"; import { authClient, fetchApiToken } from "./lib/auth-client"; -import { configureApiToken } from "./lib/api"; +import { configureApiToken, errorMessage } from "./lib/api"; import { isDemoMode, disableDemoMode, enableDemoMode, demoSession, demoService } from "./lib/demo-mode"; import { AppShell, @@ -84,11 +84,7 @@ type ObjectPreview = | { kind: "unsupported"; message: string }; function getErrorMessage(error: unknown, fallback: string) { - if (error && typeof error === "object" && "message" in error) { - const message = (error as { message?: unknown }).message; - if (typeof message === "string" && message.length > 0) return message; - } - return fallback; + return errorMessage(error, fallback); } function formatDate(value?: string | null) { diff --git a/apps/frontend/src/components/Modal.tsx b/apps/frontend/src/components/Modal.tsx index f7ab917..9621067 100644 --- a/apps/frontend/src/components/Modal.tsx +++ b/apps/frontend/src/components/Modal.tsx @@ -1,4 +1,4 @@ -import { type JSX, Show, createEffect, onCleanup, splitProps } from "solid-js"; +import { type JSX, Show, createEffect, createUniqueId, onCleanup, splitProps } from "solid-js"; import { Portal } from "solid-js/web"; interface ModalProps extends JSX.HTMLAttributes { @@ -6,6 +6,7 @@ interface ModalProps extends JSX.HTMLAttributes { onClose: () => void; title?: string; description?: string; + error?: JSX.Element; size?: "sm" | "md" | "lg" | "xl" | "full"; closeOnEscape?: boolean; closeOnBackdrop?: boolean; @@ -26,6 +27,7 @@ export function Modal(props: ModalProps) { "onClose", "title", "description", + "error", "size", "closeOnEscape", "closeOnBackdrop", @@ -38,6 +40,8 @@ export function Modal(props: ModalProps) { const closeOnEscape = () => local.closeOnEscape ?? true; const closeOnBackdrop = () => local.closeOnBackdrop ?? true; const showClose = () => local.showClose ?? true; + const titleId = createUniqueId(); + const descriptionId = createUniqueId(); createEffect(() => { if (local.open) { @@ -71,8 +75,8 @@ export function Modal(props: ModalProps) { class="fixed inset-0 z-50 flex items-center justify-center p-4 animate-fade-in" role="dialog" aria-modal="true" - aria-labelledby={local.title ? "modal-title" : undefined} - aria-describedby={local.description ? "modal-description" : undefined} + aria-labelledby={local.title ? titleId : undefined} + aria-describedby={local.description ? descriptionId : undefined} > {/* Backdrop */}
-

{local.title}

-

{local.description}

@@ -116,7 +120,14 @@ export function Modal(props: ModalProps) { {/* Body */} -
{local.children}
+
+ + + + {local.children} +
diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index f5f58a5..60c113c 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -12,3 +12,20 @@ OpenAPI.TOKEN = async () => (await fetchApiToken()) ?? ""; export function configureApiToken(tokenResolver?: () => Promise) { OpenAPI.TOKEN = tokenResolver ? async () => (await tokenResolver()) ?? "" : async () => (await fetchApiToken()) ?? ""; } + +/** + * Extracts a human-readable message from an API failure. + * ApiError.message is only the HTTP status text ("Bad Request") — the backend + * sends the actionable reason in body.error.message. Prefer it. + */ +export function errorMessage(error: unknown, fallback = "Request failed"): string { + if (error && typeof error === "object") { + const body = (error as { body?: { error?: { message?: unknown } } }).body; + if (typeof body?.error?.message === "string" && body.error.message.length > 0) { + return body.error.message; + } + const message = (error as { message?: unknown }).message; + if (typeof message === "string" && message.length > 0) return message; + } + return fallback; +} diff --git a/apps/frontend/src/pages/AuditPage.tsx b/apps/frontend/src/pages/AuditPage.tsx index 655b9e7..2f6dcfe 100644 --- a/apps/frontend/src/pages/AuditPage.tsx +++ b/apps/frontend/src/pages/AuditPage.tsx @@ -21,25 +21,63 @@ const PAGE_SIZE = 25; const ACTION_OPTIONS = [ "", + "platform.bootstrap", + "organization.created", + "organization.updated", + "organization.deleted", + "organization.member.role_updated", + "organization.member.removed", "project.created", "project.updated", "project.deleted", + "project.member.role_updated", + "project.member.removed", + "invitation.created", + "invitation.accepted", + "invitation.revoked", "bucket.created", "bucket.updated", "bucket.deleted", "object.uploaded", "object.updated", "object.deleted", + "object.copied", + "object.moved", "api_key.created", "api_key.revoked", - "member.invited", - "member.role_updated", - "member.removed", "collection.created", + "collection.updated", "collection.deleted", "document.created", "document.updated", "document.deleted", + "db_connection.created", + "db_connection.deleted", + "db_connection.transferred", + "secret.set", + "secret.revealed", + "secret.deleted", + "function.created", + "function.updated", + "function.deleted", + "function.invoked", + "job.created", + "job.updated", + "job.deleted", + "job.run", + "webhook.created", + "webhook.updated", + "webhook.deleted", + "webhook.tested", + "inbound_hook.created", + "inbound_hook.deleted", + "integration.created", + "integration.deleted", + "integration.tested", + "alert.created", + "alert.updated", + "alert.deleted", + "deploy.marker", ]; function actionVariant(action: string): "success" | "error" | "primary" | "warning" | "neutral" { diff --git a/apps/frontend/src/pages/AutomationPage.tsx b/apps/frontend/src/pages/AutomationPage.tsx index 94a9764..8ff9dcb 100644 --- a/apps/frontend/src/pages/AutomationPage.tsx +++ b/apps/frontend/src/pages/AutomationPage.tsx @@ -12,6 +12,7 @@ import { demoService } from "../lib/demo-mode"; import { Badge } from "../components/Badge"; import { Modal } from "../components/Modal"; import { Input, Textarea } from "../components/Input"; +import { errorMessage } from "../lib/api"; import { IconPlus, IconRefresh, @@ -37,7 +38,7 @@ interface RealtimeEvent { data: Record; } -const err = (e: unknown) => (e instanceof Error ? e.message : String(e)); +const err = (e: unknown) => errorMessage(e, String(e)); const svc = (demo: boolean) => (demo ? (demoService as unknown as typeof AutomationService) : AutomationService); @@ -532,7 +533,7 @@ export function AutomationPage(props: AutomationPageProps) { - setShowCreate(false)} title="New schedule" size="md"> + setShowCreate(false)} title="New schedule" size="md">
t !== "redis"; -const err = (e: unknown) => (e instanceof Error ? e.message : String(e)); +const err = (e: unknown) => errorMessage(e, String(e)); /* the demo service mirrors the generated DatabasesService call shapes */ const svc = (demo: boolean) => @@ -933,6 +934,7 @@ export function DatabasesPage(props: DatabasesPageProps) { setAddOpen(false)} + error={error()} title="Add connection" description="Credentials are stored server-side and never sent back to the browser." > @@ -1024,6 +1026,7 @@ export function DatabasesPage(props: DatabasesPageProps) { setConfirmDelete(undefined)} + error={error()} title="Delete connection" size="sm" > diff --git a/apps/frontend/src/pages/FunctionsPage.tsx b/apps/frontend/src/pages/FunctionsPage.tsx index e92250f..c746fd8 100644 --- a/apps/frontend/src/pages/FunctionsPage.tsx +++ b/apps/frontend/src/pages/FunctionsPage.tsx @@ -10,6 +10,7 @@ import { Modal, ModalFooter } from "../components/Modal"; import { Input, Select, Textarea } from "../components/Input"; import { Badge } from "../components/Badge"; import { IconPlus, IconTrash, IconPlay, IconTerminal } from "../components/Icons"; +import { errorMessage } from "../lib/api"; interface FunctionsPageProps { projectID?: string; @@ -17,7 +18,7 @@ interface FunctionsPageProps { demoMode: boolean; } -const err = (e: unknown) => (e instanceof Error ? e.message : String(e)); +const err = (e: unknown) => errorMessage(e, String(e)); const fmtTime = (ts?: string) => (ts ? new Date(ts).toLocaleString() : "—"); const STARTER_CODE = `// The payload arrives on stdin and as PRIMORA_PAYLOAD. @@ -409,7 +410,7 @@ export function FunctionsPage(props: FunctionsPageProps) { - setShowCreate(false)} title="New function"> + setShowCreate(false)} title="New function"> - setShowCreate(false)} title="New secret" size="md"> + setShowCreate(false)} title="New secret" size="md"> setForm({ ...form(), name: e.currentTarget.value })} required /> setForm({ ...form(), value: e.currentTarget.value })} required /> @@ -389,7 +390,7 @@ export function VaultPage(props: VaultPageProps) { - setShowImport(false)} title="Import .env" size="md"> + setShowImport(false)} title="Import .env" size="md">