diff --git a/README.md b/README.md index be430c12..cd2869d7 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Stop hand-editing YAML. Build observability pipelines with drag-and-drop
and [Documentation](https://terrifiedbug.gitbook.io/vectorflow) · [Quick start](#quick-start) · [Deployment](#deployment) · [Features](#features) · [Configuration](#configuration) · [Development](#development) +> 🌐 **[Try the live demo →](https://demo.terrifiedbug.com)** +
diff --git a/docker/server/Dockerfile b/docker/server/Dockerfile index ceb677fd..880fcc4e 100644 --- a/docker/server/Dockerfile +++ b/docker/server/Dockerfile @@ -31,6 +31,8 @@ COPY prisma.config.ts ./prisma.config.ts RUN pnpm exec prisma generate COPY src ./src COPY public ./public +ARG NEXT_PUBLIC_VF_DEMO_MODE +ENV NEXT_PUBLIC_VF_DEMO_MODE=${NEXT_PUBLIC_VF_DEMO_MODE} RUN --mount=type=cache,target=/app/.next/cache \ pnpm build # Save Prisma client version for the runner stage (standalone strips node_modules) diff --git a/src/app/(auth)/login/__tests__/page.test.tsx b/src/app/(auth)/login/__tests__/page.test.tsx new file mode 100644 index 00000000..f4f76c0b --- /dev/null +++ b/src/app/(auth)/login/__tests__/page.test.tsx @@ -0,0 +1,141 @@ +// @vitest-environment jsdom + +/** + * LoginPage prefill tests. + * + * Verifies that visiting /login?prefill=demo pre-populates the email and + * password fields with the demo credentials, and that absent params leave + * the fields empty. + * + * Mock conventions match error-state.test.tsx and flow-canvas.test.tsx: + * - mock motion/react-m → plain HTML (no animation runtime) + * - mock @/hooks/use-reduced-motion → returns false (motion on, but + * m.div is already a plain div so this branch is harmless) + * - vitest globals:false — import everything from vitest explicitly + * - no auto-cleanup — call cleanup() in afterEach + */ + +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, cleanup, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; + +// --------------------------------------------------------------------------- +// Motion mock — m.div → plain div so no animation runtime is needed +// --------------------------------------------------------------------------- +vi.mock("motion/react-m", () => ({ div: "div" })); + +// --------------------------------------------------------------------------- +// Reduced-motion mock — keep motion branch consistent; value doesn't affect +// the prefill logic under test +// --------------------------------------------------------------------------- +vi.mock("@/hooks/use-reduced-motion", () => ({ + useReducedMotion: () => false, +})); + +// --------------------------------------------------------------------------- +// next/navigation — useRouter + useSearchParams +// We swap useSearchParams per test via the factory below. +// --------------------------------------------------------------------------- +const mockSearchParams = vi.fn(() => new URLSearchParams()); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), + useSearchParams: () => mockSearchParams(), +})); + +// --------------------------------------------------------------------------- +// next-auth/react — signIn is irrelevant to prefill; stub it out +// --------------------------------------------------------------------------- +vi.mock("next-auth/react", () => ({ + signIn: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// fetch — the component fires fetch('/api/setup') and fetch('/api/auth/oidc-status') +// in a useEffect after mount. Stub both to avoid network errors, returning +// values that keep the page in the normal (local-auth) state. +// --------------------------------------------------------------------------- +vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + if (url === "/api/setup") { + return Promise.resolve({ + json: () => Promise.resolve({ setupRequired: false }), + }); + } + if (url === "/api/auth/oidc-status") { + return Promise.resolve({ + json: () => + Promise.resolve({ + enabled: false, + displayName: "SSO", + localAuthDisabled: false, + }), + }); + } + return Promise.resolve({ json: () => Promise.resolve({}) }); + }), +); + +// --------------------------------------------------------------------------- +// Import component after all mocks are registered +// --------------------------------------------------------------------------- +import LoginPage from "../page"; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("LoginPage prefill", () => { + it("prefills email and password when ?prefill=demo", async () => { + mockSearchParams.mockReturnValue(new URLSearchParams("prefill=demo")); + + const { container } = render(); + + // The component shows a spinner while fetching /api/setup and /api/auth/oidc-status. + // Wait for the email input to appear after the setup check resolves. + await waitFor(() => { + const email = container.querySelector('input[name="email"]'); + expect(email).not.toBeNull(); + }); + + const email = container.querySelector( + 'input[name="email"]', + ) as HTMLInputElement; + const password = container.querySelector( + 'input[name="password"]', + ) as HTMLInputElement; + + expect(password).not.toBeNull(); + expect(email.value).toBe("demo@demo.local"); + expect(password.value).toBe("demo"); + }); + + it("renders empty fields when prefill param is absent", async () => { + mockSearchParams.mockReturnValue(new URLSearchParams()); + + const { container } = render(); + + await waitFor(() => { + const email = container.querySelector('input[name="email"]'); + expect(email).not.toBeNull(); + }); + + const email = container.querySelector( + 'input[name="email"]', + ) as HTMLInputElement; + const password = container.querySelector( + 'input[name="password"]', + ) as HTMLInputElement; + + expect(password).not.toBeNull(); + expect(email.value).toBe(""); + expect(password.value).toBe(""); + }); +}); diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index c5ee6c73..c646dbff 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -76,9 +76,17 @@ function LoginPageContent() { : null; const [error, setError] = useState(initialError); + // Pre-fill credentials when ?prefill=demo is present in the URL. + // Gated only on the URL param — not on VF_DEMO_MODE — so it works on any + // instance (auth will simply fail if the demo user doesn't exist there). + const prefill = searchParams.get("prefill") === "demo"; + const form = useForm({ resolver: zodResolver(loginSchema), - defaultValues: { email: "", password: "" }, + defaultValues: { + email: prefill ? "demo@demo.local" : "", + password: prefill ? "demo" : "", + }, mode: "onBlur", }); diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index f995af6c..941ef040 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -35,6 +35,8 @@ import { UpdateBanner } from "@/components/update-banner"; import { CommandPalette, triggerCommandPalette } from "@/components/command-palette"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { useEnvironmentStore } from "@/stores/environment-store"; +import { DemoBanner } from "@/components/dashboard/demo-banner"; +import { isDemoMode } from "@/lib/is-demo-mode"; export default function DashboardLayout({ children, @@ -133,10 +135,12 @@ export default function DashboardLayout({ - signOut({ callbackUrl: "/login" })}> - - Sign out - + {!isDemoMode() && ( + signOut({ callbackUrl: "/login" })}> + + Sign out + + )} @@ -166,10 +170,12 @@ export default function DashboardLayout({ > Request Access - + {!isDemoMode() && ( + + )} @@ -178,16 +184,20 @@ export default function DashboardLayout({ ); } + const showDemoBanner = isDemoMode(); + return ( - - - Skip to main content - - - + <> + {showDemoBanner && } + + + Skip to main content + + +
@@ -250,12 +260,14 @@ export default function DashboardLayout({ Profile - signOut({ callbackUrl: "/login" })} - > - - Sign out - + {!isDemoMode() && ( + signOut({ callbackUrl: "/login" })} + > + + Sign out + + )} @@ -273,5 +285,6 @@ export default function DashboardLayout({ + ); } diff --git a/src/app/(dashboard)/settings/service-accounts/_client.tsx b/src/app/(dashboard)/settings/service-accounts/_client.tsx new file mode 100644 index 00000000..4149cb73 --- /dev/null +++ b/src/app/(dashboard)/settings/service-accounts/_client.tsx @@ -0,0 +1,656 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { useTeamStore } from "@/stores/team-store"; +import { copyToClipboard } from "@/lib/utils"; +import { toast } from "sonner"; +import { + Plus, + Loader2, + Copy, + Trash2, + Ban, + KeyRound, + ShieldCheck, + Clock, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { Breadcrumb } from "@/components/breadcrumb"; +import { EmptyState } from "@/components/empty-state"; +import { QueryError } from "@/components/query-error"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ConfirmDialog } from "@/components/confirm-dialog"; +import { Textarea } from "@/components/ui/textarea"; + +// ─── Helpers ──────────────────────────────────────────────────────────────────── + +function formatRelativeTime(date: Date | string | null | undefined): string { + if (!date) return "Never"; + const d = typeof date === "string" ? new Date(date) : date; + const now = Date.now(); + const diffMs = now - d.getTime(); + const diffSec = Math.floor(diffMs / 1000); + if (diffSec < 60) return "Just now"; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + return `${diffDay}d ago`; +} + +function formatExpiresAt(date: Date | string | null | undefined): string { + if (!date) return "Never"; + const d = typeof date === "string" ? new Date(date) : date; + const now = Date.now(); + if (d.getTime() < now) return "Expired"; + const diffMs = d.getTime() - now; + const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + if (diffDays <= 1) return "Today"; + return `${diffDays} days`; +} + +// ─── Permission Definitions ───────────────────────────────────────────────────── + +const PERMISSION_GROUPS = [ + { + label: "Pipelines", + permissions: [ + { value: "pipelines.read", label: "Read" }, + { value: "pipelines.deploy", label: "Deploy" }, + ], + }, + { + label: "Nodes", + permissions: [ + { value: "nodes.read", label: "Read" }, + { value: "nodes.manage", label: "Manage" }, + ], + }, + { + label: "Secrets", + permissions: [ + { value: "secrets.read", label: "Read" }, + { value: "secrets.manage", label: "Manage" }, + ], + }, + { + label: "Alerts", + permissions: [ + { value: "alerts.read", label: "Read" }, + { value: "alerts.manage", label: "Manage" }, + ], + }, + { + label: "Audit", + permissions: [{ value: "audit.read", label: "Read" }], + }, +] as const; + +type PermissionValue = (typeof PERMISSION_GROUPS)[number]["permissions"][number]["value"]; + +// ─── Main Page ────────────────────────────────────────────────────────────────── + +export function ServiceAccountsSettings() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { selectedTeamId } = useTeamStore(); + + const [createOpen, setCreateOpen] = useState(false); + const [keyModalOpen, setKeyModalOpen] = useState(false); + const [createdKey, setCreatedKey] = useState(null); + const [revokeTarget, setRevokeTarget] = useState<{ id: string; name: string } | null>(null); + const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null); + + // Form state + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [selectedEnvId, setSelectedEnvId] = useState(""); + const [expiration, setExpiration] = useState("never"); + const [selectedPermissions, setSelectedPermissions] = useState>(new Set()); + + // Queries + const environmentsQuery = useQuery( + trpc.environment.list.queryOptions( + { teamId: selectedTeamId ?? "" }, + { enabled: !!selectedTeamId }, + ), + ); + + const environments = environmentsQuery.data ?? []; + + // If user selected an environment for the list, use it; otherwise use first available + const listEnvId = selectedEnvId || environments[0]?.id || ""; + + const serviceAccountsQuery = useQuery( + trpc.serviceAccount.list.queryOptions( + { environmentId: listEnvId }, + { enabled: !!listEnvId }, + ), + ); + + // Mutations + const createMutation = useMutation( + trpc.serviceAccount.create.mutationOptions({ + onSuccess: (data) => { + setCreatedKey(data.rawKey); + setKeyModalOpen(true); + setCreateOpen(false); + resetForm(); + queryClient.invalidateQueries({ + queryKey: trpc.serviceAccount.list.queryKey(), + }); + toast.success("Service account created"); + }, + onError: (err) => { + toast.error(err.message || "Failed to create service account", { duration: 6000 }); + }, + }), + ); + + const revokeMutation = useMutation( + trpc.serviceAccount.revoke.mutationOptions({ + onSuccess: () => { + setRevokeTarget(null); + queryClient.invalidateQueries({ + queryKey: trpc.serviceAccount.list.queryKey(), + }); + toast.success("Service account revoked"); + }, + onError: (err) => { + toast.error(err.message || "Failed to revoke service account", { duration: 6000 }); + }, + }), + ); + + const deleteMutation = useMutation( + trpc.serviceAccount.delete.mutationOptions({ + onSuccess: () => { + setDeleteTarget(null); + queryClient.invalidateQueries({ + queryKey: trpc.serviceAccount.list.queryKey(), + }); + toast.success("Service account deleted"); + }, + onError: (err) => { + toast.error(err.message || "Failed to delete service account", { duration: 6000 }); + }, + }), + ); + + function resetForm() { + setName(""); + setDescription(""); + setExpiration("never"); + setSelectedPermissions(new Set()); + } + + function togglePermission(perm: string) { + setSelectedPermissions((prev) => { + const next = new Set(prev); + if (next.has(perm)) { + next.delete(perm); + } else { + next.add(perm); + } + return next; + }); + } + + function handleCreate() { + if (!name.trim() || !selectedEnvId || selectedPermissions.size === 0) { + toast.error("Please fill in all required fields and select at least one permission", { duration: 6000 }); + return; + } + + const expiresInDays = + expiration === "never" ? undefined : parseInt(expiration, 10); + + createMutation.mutate({ + environmentId: selectedEnvId, + name: name.trim(), + description: description.trim() || undefined, + permissions: Array.from(selectedPermissions) as PermissionValue[], + expiresInDays, + }); + } + + const serviceAccounts = serviceAccountsQuery.data ?? []; + const isLoading = serviceAccountsQuery.isLoading || environmentsQuery.isLoading; + + if (serviceAccountsQuery.isError) return serviceAccountsQuery.refetch()} />; + + return ( +
+ {/* Header */} +
+

+ Manage API keys for programmatic access to the REST API +

+ +
+ + {/* Environment Selector */} + {environments.length > 1 && ( +
+ + +
+ )} + + {/* Service Accounts Table */} + + + + + Service Accounts + + + Service accounts provide API keys for the REST API. Keys are shown + once at creation and cannot be retrieved afterwards. + + + + {isLoading ? ( +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+ ) : serviceAccounts.length === 0 ? ( + + ) : ( +
+ + + + Name + Key Prefix + Permissions + Last Used + Expires + Status + Created By + Actions + + + + {serviceAccounts.map((sa) => { + const permissions = (sa.permissions as string[]) ?? []; + const isExpired = + sa.expiresAt && new Date(sa.expiresAt) < new Date(); + const status = !sa.enabled + ? "Revoked" + : isExpired + ? "Expired" + : "Active"; + const statusVariant = + status === "Active" + ? "default" + : status === "Revoked" + ? "destructive" + : "secondary"; + + return ( + + +
+
{sa.name}
+ {sa.description && ( +
+ {sa.description} +
+ )} +
+
+ + + {sa.keyPrefix}... + + + +
+ {permissions.map((p) => ( + + {p} + + ))} +
+
+ +
+ + {formatRelativeTime(sa.lastUsedAt)} +
+
+ + {formatExpiresAt(sa.expiresAt)} + + + {status} + + + {sa.createdBy?.name || sa.createdBy?.email || "Unknown"} + + +
+ {sa.enabled && ( + + )} + +
+
+
+ ); + })} +
+
+
+ )} +
+
+ + {/* Create Dialog */} + { + if (!open) resetForm(); + setCreateOpen(open); + }} + > + + + Create Service Account + + Generate an API key for programmatic access. The key will only be + shown once. + + + +
+ {/* Name */} +
+ + setName(e.target.value)} + /> +
+ + {/* Description */} +
+ +