Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Stop hand-editing YAML. Build observability pipelines with drag-and-drop<br>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)**

</div>

<br>
Expand Down
2 changes: 2 additions & 0 deletions docker/server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
141 changes: 141 additions & 0 deletions src/app/(auth)/login/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<LoginPage />);

// 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(<LoginPage />);

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("");
});
});
10 changes: 9 additions & 1 deletion src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,17 @@ function LoginPageContent() {
: null;
const [error, setError] = useState<string | null>(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<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
defaultValues: {
email: prefill ? "demo@demo.local" : "",
password: prefill ? "demo" : "",
},
mode: "onBlur",
});

Expand Down
59 changes: 36 additions & 23 deletions src/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -133,10 +135,12 @@ export default function DashboardLayout({
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut({ callbackUrl: "/login" })}>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
{!isDemoMode() && (
<DropdownMenuItem onClick={() => signOut({ callbackUrl: "/login" })}>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
Expand Down Expand Up @@ -166,10 +170,12 @@ export default function DashboardLayout({
>
Request Access
</Button>
<Button variant="outline" onClick={() => signOut({ callbackUrl: "/login" })}>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</Button>
{!isDemoMode() && (
<Button variant="outline" onClick={() => signOut({ callbackUrl: "/login" })}>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</Button>
)}
</div>
</div>
</main>
Expand All @@ -178,16 +184,20 @@ export default function DashboardLayout({
);
}

const showDemoBanner = isDemoMode();

return (
<SidebarProvider>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground focus:shadow-lg focus:outline-none focus:ring-2 focus:ring-ring"
>
Skip to main content
</a>
<AppSidebar />
<SidebarInset>
<>
{showDemoBanner && <DemoBanner />}
<SidebarProvider>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground focus:shadow-lg focus:outline-none focus:ring-2 focus:ring-ring"
>
Skip to main content
</a>
<AppSidebar />
<SidebarInset>
<header className="flex h-14 shrink-0 items-center gap-3 border-b px-4" aria-label="Dashboard header">
<TeamSelector />
<Separator orientation="vertical" className="!h-5" />
Expand Down Expand Up @@ -250,12 +260,14 @@ export default function DashboardLayout({
Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: "/login" })}
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
{!isDemoMode() && (
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: "/login" })}
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
Expand All @@ -273,5 +285,6 @@ export default function DashboardLayout({
</LazyMotionProvider>
</SidebarInset>
</SidebarProvider>
</>
);
}
Loading
Loading