diff --git a/app/client/modules/auth/components/alternative-sign-in-section.tsx b/app/client/modules/auth/components/alternative-sign-in-section.tsx new file mode 100644 index 00000000..ca4f97bc --- /dev/null +++ b/app/client/modules/auth/components/alternative-sign-in-section.tsx @@ -0,0 +1,35 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useServerFn } from "@tanstack/react-start"; +import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen"; +import { SsoLoginButtons } from "~/client/modules/sso/components/sso-login-buttons"; +import { getLoginOptions } from "~/server/lib/functions/login-options"; +import { PasskeySignInButton } from "./passkey-sign-in-button"; + +type AlternativeSignInSectionProps = { + onPasskeySignIn: () => Promise; +}; + +export function AlternativeSignInSection({ onPasskeySignIn }: AlternativeSignInSectionProps) { + const getOptions = useServerFn(getLoginOptions); + const { data: ssoProviders } = useSuspenseQuery({ + ...getPublicSsoProvidersOptions(), + }); + const { data: loginOptions } = useSuspenseQuery({ + queryKey: ["login-options"], + queryFn: getOptions, + }); + + if (ssoProviders.providers.length === 0 && !loginOptions.hasPasskeySignIn) { + return null; + } + + return ( +
+

Alternative Sign-in

+
+ {loginOptions.hasPasskeySignIn && } + +
+
+ ); +} diff --git a/app/client/modules/auth/components/passkey-sign-in-button.tsx b/app/client/modules/auth/components/passkey-sign-in-button.tsx new file mode 100644 index 00000000..b5ce7117 --- /dev/null +++ b/app/client/modules/auth/components/passkey-sign-in-button.tsx @@ -0,0 +1,64 @@ +import { useMutation } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { Fingerprint } from "lucide-react"; +import { Button } from "~/client/components/ui/button"; +import { authClient } from "~/client/lib/auth-client"; +import { logger } from "~/client/lib/logger"; +import { LOGIN_ERROR_CODES, PASSKEY_LOGIN_FAILED_ERROR, type LoginErrorCode } from "~/lib/sso-errors"; + +type PasskeySignInButtonProps = { + onSignIn: () => Promise; +}; + +type PasskeySignInError = { + code?: string; + message?: string; + status?: number; + statusText?: string; +}; + +const LOGIN_ERROR_CODE_SET = new Set(LOGIN_ERROR_CODES); + +function getPasskeyLoginErrorCode(code: string | undefined): LoginErrorCode { + if (code && LOGIN_ERROR_CODE_SET.has(code)) { + return code as LoginErrorCode; + } + + return PASSKEY_LOGIN_FAILED_ERROR; +} + +export function PasskeySignInButton({ onSignIn }: PasskeySignInButtonProps) { + const navigate = useNavigate(); + const passkeyLoginMutation = useMutation({ + mutationFn: async () => { + const { error } = await authClient.signIn.passkey(); + if (error) throw error; + + await onSignIn(); + }, + onError: (error) => { + logger.error(error); + + void navigate({ + to: "/login", + search: { + error: getPasskeyLoginErrorCode(error.code), + }, + }); + }, + }); + + return ( + + ); +} diff --git a/app/client/modules/auth/routes/__tests__/login.test.tsx b/app/client/modules/auth/routes/__tests__/login.test.tsx index 13c18da4..4c429d34 100644 --- a/app/client/modules/auth/routes/__tests__/login.test.tsx +++ b/app/client/modules/auth/routes/__tests__/login.test.tsx @@ -1,16 +1,49 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { HttpResponse, http, server } from "~/test/msw/server"; -import { cleanup, render, screen } from "~/test/test-utils"; +import { cleanup, render, screen, userEvent, waitFor } from "~/test/test-utils"; +import { PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors"; + +const { mockGetLoginOptions, mockNavigate, mockPasskeySignIn } = vi.hoisted(() => ({ + mockGetLoginOptions: vi.fn(async () => ({ hasPasskeySignIn: false })), + mockNavigate: vi.fn(async () => {}), + mockPasskeySignIn: vi.fn( + async (): Promise<{ + data: unknown; + error: { code: string; message: string } | null; + }> => ({ data: null, error: null }), + ), +})); vi.mock("@tanstack/react-router", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - useNavigate: (() => vi.fn(async () => {})) as typeof actual.useNavigate, + useNavigate: (() => mockNavigate) as typeof actual.useNavigate, }; }); +vi.mock("@tanstack/react-start", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useServerFn: (fn: unknown) => fn, + }; +}); + +vi.mock("~/server/lib/functions/login-options", () => ({ + getLoginOptions: mockGetLoginOptions, +})); + +vi.mock("~/client/lib/auth-client", () => ({ + authClient: { + signIn: { + passkey: mockPasskeySignIn, + }, + }, +})); + import { LoginPage } from "../login"; const inviteOnlyMessage = "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO."; @@ -29,6 +62,12 @@ const mockSsoProvidersRequest = ( }; afterEach(() => { + mockGetLoginOptions.mockClear(); + mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: false }); + mockNavigate.mockClear(); + mockPasskeySignIn.mockClear(); + mockPasskeySignIn.mockResolvedValue({ data: null, error: null }); + vi.unstubAllGlobals(); cleanup(); }); @@ -81,6 +120,18 @@ describe("LoginPage", () => { expect(await screen.findByText("SSO authentication failed. Please try again.")).toBeTruthy(); }); + test("shows passkey login failure message when passkey returns the login error code", async () => { + mockSsoProvidersRequest(); + + render(, { withSuspense: true }); + + expect( + await screen.findByText( + "Passkey sign-in failed. If 2FA is enabled, use a passkey protected by a PIN, biometrics, or screen lock, or sign in with your password and authenticator code.", + ), + ).toBeTruthy(); + }); + test("does not show error message for invalid error codes", async () => { mockSsoProvidersRequest(); @@ -90,11 +141,141 @@ describe("LoginPage", () => { expect(screen.queryByText(inviteOnlyMessage)).toBeNull(); }); - test("renders available SSO providers from the real SSO section", async () => { + test("renders available SSO providers from the alternative sign-in section", async () => { mockSsoProvidersRequest([{ providerId: "acme", organizationSlug: "acme-org" }]); render(, { withSuspense: true }); expect(await screen.findByRole("button", { name: "Log in with acme" })).toBeTruthy(); }); + + test("renders passkey sign-in when an active user has a passkey", async () => { + mockSsoProvidersRequest(); + mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true }); + + render(, { withSuspense: true }); + + expect(await screen.findByRole("button", { name: "Sign in with passkey" })).toBeTruthy(); + }); + + test("redirects passkey verification failures to the login error box", async () => { + mockSsoProvidersRequest(); + mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true }); + mockPasskeySignIn.mockResolvedValue({ + data: null, + error: { code: "AUTHENTICATION_FAILED", message: "Authentication failed" }, + }); + + render(, { withSuspense: true }); + + await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" })); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith({ + to: "/login", + search: { + error: PASSKEY_LOGIN_FAILED_ERROR, + }, + }); + }); + }); + + test("redirects unauthorized passkey failures to the login error box", async () => { + mockSsoProvidersRequest(); + mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true }); + mockPasskeySignIn.mockResolvedValue({ + data: null, + error: { code: "UNAUTHORIZED", message: "Unauthorized" }, + }); + + render(, { withSuspense: true }); + + await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" })); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith({ + to: "/login", + search: { + error: PASSKEY_LOGIN_FAILED_ERROR, + }, + }); + }); + }); + + test("preserves specific passkey login error codes", async () => { + mockSsoProvidersRequest(); + mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true }); + mockPasskeySignIn.mockResolvedValue({ + data: null, + error: { code: "ERROR_INVALID_RP_ID", message: "Auth cancelled" }, + }); + + render(, { withSuspense: true }); + + await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" })); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith({ + to: "/login", + search: { + error: "ERROR_INVALID_RP_ID", + }, + }); + }); + }); + + test("redirects conditional passkey autofill failures to the login error box", async () => { + mockSsoProvidersRequest(); + mockPasskeySignIn.mockResolvedValue({ + data: null, + error: { code: "AUTHENTICATION_FAILED", message: "Authentication failed" }, + }); + vi.stubGlobal("PublicKeyCredential", { + isConditionalMediationAvailable: vi.fn(async () => true), + }); + + render(, { withSuspense: true }); + + await waitFor(() => { + expect(mockPasskeySignIn).toHaveBeenCalledWith({ + autoFill: true, + }); + expect(mockNavigate).toHaveBeenCalledWith({ + to: "/login", + search: { + error: "PASSKEY_LOGIN_FAILED", + }, + }); + }); + }); + + test("ignores conditional passkey autofill cancellation", async () => { + mockSsoProvidersRequest(); + mockPasskeySignIn.mockResolvedValue({ + data: null, + error: { code: "AUTH_CANCELLED", message: "Authentication cancelled" }, + }); + vi.stubGlobal("PublicKeyCredential", { + isConditionalMediationAvailable: vi.fn(async () => true), + }); + + render(, { withSuspense: true }); + + await waitFor(() => { + expect(mockPasskeySignIn).toHaveBeenCalledWith({ + autoFill: true, + }); + }); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + test("hides alternative sign-in when no SSO providers or passkeys are available", async () => { + mockSsoProvidersRequest(); + + render(, { withSuspense: true }); + + await screen.findByText("Login to your account"); + expect(screen.queryByText("Alternative Sign-in")).toBeNull(); + expect(screen.queryByRole("button", { name: "Sign in with passkey" })).toBeNull(); + }); }); diff --git a/app/client/modules/auth/routes/login.tsx b/app/client/modules/auth/routes/login.tsx index 30a261e3..2b18ef2e 100644 --- a/app/client/modules/auth/routes/login.tsx +++ b/app/client/modules/auth/routes/login.tsx @@ -1,5 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { AuthLayout } from "~/client/components/auth-layout"; @@ -12,11 +12,12 @@ import { authClient } from "~/client/lib/auth-client"; import { logger } from "~/client/lib/logger"; import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip"; import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors"; +import { PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors"; import { ResetPasswordDialog } from "../components/reset-password-dialog"; import { useNavigate } from "@tanstack/react-router"; import { normalizeUsername } from "~/lib/username"; import { cn } from "~/client/lib/utils"; -import { SsoLoginSection } from "~/client/modules/sso/components/sso-login-section"; +import { AlternativeSignInSection } from "../components/alternative-sign-in-section"; import { z } from "zod"; const loginSchema = z.object({ @@ -33,6 +34,17 @@ type LoginPageProps = { error?: string; }; +type PasskeySignInError = { + code?: string; + message?: string; + status?: number; + statusText?: string; +}; + +function isPasskeyVerificationFailure(error: PasskeySignInError | null) { + return error?.code === "AUTHENTICATION_FAILED" || error?.code === "UNAUTHORIZED"; +} + function hasSkippedRecoveryKeyDownload(userId: string) { return document.cookie .split(";") @@ -50,6 +62,20 @@ export function LoginPage({ error }: LoginPageProps = {}) { const errorCode = decodeLoginError(error); const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null; + const navigateAfterLogin = useCallback(async () => { + const session = await authClient.getSession(); + + if ( + session.data?.user && + !session.data.user.hasDownloadedResticPassword && + !hasSkippedRecoveryKeyDownload(session.data.user.id) + ) { + void navigate({ to: "/download-recovery-key" }); + } else { + void navigate({ to: "/volumes" }); + } + }, [navigate]); + useEffect(() => { const autoSignIn = async () => { if ( @@ -60,28 +86,27 @@ export function LoginPage({ error }: LoginPageProps = {}) { return; } - await authClient.signIn.passkey({ + const { data, error } = await authClient.signIn.passkey({ autoFill: true, - fetchOptions: { - onResponse: async () => { - const session = await authClient.getSession(); - - if ( - session.data?.user && - !session.data.user.hasDownloadedResticPassword && - !hasSkippedRecoveryKeyDownload(session.data.user.id) - ) { - void navigate({ to: "/download-recovery-key" }); - } else { - void navigate({ to: "/volumes" }); - } - }, - }, }); + + if (isPasskeyVerificationFailure(error)) { + void navigate({ + to: "/login", + search: { + error: PASSKEY_LOGIN_FAILED_ERROR, + }, + }); + return; + } + + if (data) { + await navigateAfterLogin(); + } }; void autoSignIn(); - }, [navigate]); + }, [navigate, navigateAfterLogin]); const form = useForm({ resolver: zodResolver(loginSchema), @@ -116,12 +141,7 @@ export function LoginPage({ error }: LoginPageProps = {}) { return; } - const d = await authClient.getSession(); - if (data.user && !d.data?.user.hasDownloadedResticPassword && !hasSkippedRecoveryKeyDownload(data.user.id)) { - void navigate({ to: "/download-recovery-key" }); - } else { - void navigate({ to: "/volumes" }); - } + await navigateAfterLogin(); }; const handleVerify2FA = async () => { @@ -305,7 +325,7 @@ export function LoginPage({ error }: LoginPageProps = {}) { - + diff --git a/app/client/modules/sso/components/sso-login-buttons.tsx b/app/client/modules/sso/components/sso-login-buttons.tsx new file mode 100644 index 00000000..4ac38838 --- /dev/null +++ b/app/client/modules/sso/components/sso-login-buttons.tsx @@ -0,0 +1,54 @@ +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Button } from "~/client/components/ui/button"; +import { authClient } from "~/client/lib/auth-client"; +import { logger } from "~/client/lib/logger"; + +type SsoProvider = { + providerId: string; +}; + +type SsoLoginButtonsProps = { + providers: SsoProvider[]; +}; + +export function SsoLoginButtons({ providers }: SsoLoginButtonsProps) { + const ssoLoginMutation = useMutation({ + mutationFn: async (providerId: string) => { + const callbackPath = "/login"; + const { data, error } = await authClient.signIn.sso({ + providerId: providerId, + callbackURL: callbackPath, + errorCallbackURL: "/api/v1/auth/login-error", + }); + if (error) throw error; + + return data; + }, + onSuccess: (data) => { + window.location.href = data.url; + }, + onError: (error) => { + logger.error(error); + toast.error("SSO Login failed", { description: error.message }); + }, + }); + + return ( + <> + {providers.map((provider) => ( + + ))} + + ); +} diff --git a/app/client/modules/sso/components/sso-login-section.tsx b/app/client/modules/sso/components/sso-login-section.tsx deleted file mode 100644 index e4896d23..00000000 --- a/app/client/modules/sso/components/sso-login-section.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { Button } from "~/client/components/ui/button"; -import { authClient } from "~/client/lib/auth-client"; -import { logger } from "~/client/lib/logger"; -import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen"; - -export function SsoLoginSection() { - const { data: ssoProviders } = useSuspenseQuery({ - ...getPublicSsoProvidersOptions(), - }); - - const ssoLoginMutation = useMutation({ - mutationFn: async (providerId: string) => { - const callbackPath = "/login"; - const { data, error } = await authClient.signIn.sso({ - providerId: providerId, - callbackURL: callbackPath, - errorCallbackURL: "/api/v1/auth/login-error", - }); - if (error) throw error; - - return data; - }, - onSuccess: (data) => { - window.location.href = data.url; - }, - onError: (error) => { - logger.error(error); - toast.error("SSO Login failed", { description: error.message }); - }, - }); - - if (ssoProviders.providers.length === 0) { - return null; - } - - return ( -
-

Alternative Sign-in

-
- {ssoProviders.providers.map((provider) => ( - - ))} -
-
- ); -} diff --git a/app/lib/sso-errors.ts b/app/lib/sso-errors.ts index df3f8a48..8c0f83eb 100644 --- a/app/lib/sso-errors.ts +++ b/app/lib/sso-errors.ts @@ -1,9 +1,13 @@ +export const PASSKEY_LOGIN_FAILED_ERROR = "PASSKEY_LOGIN_FAILED"; + export const LOGIN_ERROR_CODES = [ "ACCOUNT_LINK_REQUIRED", "EMAIL_NOT_VERIFIED", "INVITE_REQUIRED", "BANNED_USER", "SSO_LOGIN_FAILED", + PASSKEY_LOGIN_FAILED_ERROR, + "ERROR_INVALID_RP_ID", ] as const; export type LoginErrorCode = (typeof LOGIN_ERROR_CODES)[number]; @@ -20,5 +24,9 @@ export function getLoginErrorDescription(errorCode: LoginErrorCode): string { return "You have been banned from this application. Please contact support if you believe this is an error."; case "SSO_LOGIN_FAILED": return "SSO authentication failed. Please try again."; + case PASSKEY_LOGIN_FAILED_ERROR: + return "Passkey sign-in failed. If 2FA is enabled, use a passkey protected by a PIN, biometrics, or screen lock, or sign in with your password and authenticator code."; + case "ERROR_INVALID_RP_ID": + return "You can only sign in with a passkey on the domain set by the BASE_URL environment variable"; } } diff --git a/app/server/lib/auth.ts b/app/server/lib/auth.ts index 8327eb07..48cbca27 100644 --- a/app/server/lib/auth.ts +++ b/app/server/lib/auth.ts @@ -174,6 +174,25 @@ export const auth = betterAuth({ passkey({ rpID: new URL(config.baseUrl).hostname, rpName: "Zerobyte", + authentication: { + afterVerification: async ({ verification, clientData }) => { + if (verification.authenticationInfo.userVerified) { + return; + } + + const passkeyRecord = await db.query.passkey.findFirst({ + where: { credentialID: clientData.id }, + with: { usersTable: { columns: { twoFactorEnabled: true } } }, + }); + + if (passkeyRecord?.usersTable?.twoFactorEnabled) { + throw new APIError("UNAUTHORIZED", { + message: + "Your passkey was accepted, but it did not confirm your identity with a PIN, biometrics, or screen lock. Because 2FA is enabled on this account, please use a verified passkey or sign in with your password and authenticator code.", + }); + } + }, + }, }), tanstackStartCookies(), ...(process.env.NODE_ENV === "test" ? [testUtils()] : []), diff --git a/app/server/lib/functions/login-options.ts b/app/server/lib/functions/login-options.ts new file mode 100644 index 00000000..66c98a6f --- /dev/null +++ b/app/server/lib/functions/login-options.ts @@ -0,0 +1,6 @@ +import { createServerFn } from "@tanstack/react-start"; +import { hasActivePasskeyUser } from "~/server/modules/auth/helpers"; + +export const getLoginOptions = createServerFn({ method: "GET" }).handler(async () => ({ + hasPasskeySignIn: await hasActivePasskeyUser(), +})); diff --git a/app/server/modules/auth/__tests__/helpers.test.ts b/app/server/modules/auth/__tests__/helpers.test.ts index 23e0a500..63fda9a6 100644 --- a/app/server/modules/auth/__tests__/helpers.test.ts +++ b/app/server/modules/auth/__tests__/helpers.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; +import { eq } from "drizzle-orm"; import { db } from "~/server/db/db"; -import { account, usersTable } from "~/server/db/schema"; +import { account, passkey, usersTable } from "~/server/db/schema"; import { createUser, randomId, randomSlug } from "~/test/helpers/user-org"; -import { userHasCredentialPassword, verifyUserPassword } from "../helpers"; +import { hasActivePasskeyUser, userHasCredentialPassword, verifyUserPassword } from "../helpers"; const { verifyPassword } = vi.hoisted(() => ({ verifyPassword: vi.fn(async ({ hash }: { hash: string }) => hash === "credential-password-hash"), @@ -30,9 +31,24 @@ async function createAccount({ }); } +async function createPasskey(userId: string) { + await db.insert(passkey).values({ + id: randomId(), + name: "Test passkey", + publicKey: randomSlug("public-key"), + userId, + credentialID: randomSlug("credential"), + counter: 0, + deviceType: "singleDevice", + backedUp: false, + transports: "internal", + }); +} + describe("verifyUserPassword", () => { beforeEach(async () => { verifyPassword.mockClear(); + await db.delete(passkey); await db.delete(account); await db.delete(usersTable); }); @@ -64,6 +80,7 @@ describe("verifyUserPassword", () => { describe("userHasCredentialPassword", () => { beforeEach(async () => { + await db.delete(passkey); await db.delete(account); await db.delete(usersTable); }); @@ -89,3 +106,32 @@ describe("userHasCredentialPassword", () => { await expect(userHasCredentialPassword(userId)).resolves.toBe(false); }); }); + +describe("hasActivePasskeyUser", () => { + beforeEach(async () => { + await db.delete(passkey); + await db.delete(account); + await db.delete(usersTable); + }); + + test("returns true when a non-banned user has a passkey", async () => { + const userId = await createUser(`${randomSlug("user")}@example.com`); + await createPasskey(userId); + + await expect(hasActivePasskeyUser()).resolves.toBe(true); + }); + + test("returns false when only banned users have passkeys", async () => { + const userId = await createUser(`${randomSlug("user")}@example.com`); + await db.update(usersTable).set({ banned: true }).where(eq(usersTable.id, userId)); + await createPasskey(userId); + + await expect(hasActivePasskeyUser()).resolves.toBe(false); + }); + + test("returns false when no users have passkeys", async () => { + await createUser(`${randomSlug("user")}@example.com`); + + await expect(hasActivePasskeyUser()).resolves.toBe(false); + }); +}); diff --git a/app/server/modules/auth/helpers.ts b/app/server/modules/auth/helpers.ts index 4f440683..ad3d2c3d 100644 --- a/app/server/modules/auth/helpers.ts +++ b/app/server/modules/auth/helpers.ts @@ -1,5 +1,7 @@ +import { eq } from "drizzle-orm"; import { verifyPassword } from "better-auth/crypto"; import { db } from "~/server/db/db"; +import { passkey, usersTable } from "~/server/db/schema"; type PasswordVerificationBody = { userId: string; @@ -31,3 +33,14 @@ export const userHasCredentialPassword = async (userId: string) => { return Boolean(userAccount?.password); }; + +export const hasActivePasskeyUser = async () => { + const [user] = await db + .select({ id: usersTable.id }) + .from(passkey) + .innerJoin(usersTable, eq(passkey.userId, usersTable.id)) + .where(eq(usersTable.banned, false)) + .limit(1); + + return Boolean(user); +}; diff --git a/vite.config.ts b/vite.config.ts index dbe0572d..c8a33187 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,7 @@ import viteReact, { reactCompilerPreset } from "@vitejs/plugin-react"; import babel from "@rolldown/plugin-babel"; export default defineConfig({ + clearScreen: false, plugins: [ tanstackStart({ srcDirectory: "app", @@ -30,7 +31,7 @@ export default defineConfig({ server: { host: "0.0.0.0", port: 3000, - allowedHosts: [".ts.net"] + allowedHosts: [".ts.net"], }, fmt: { printWidth: 120,