"@zooly/auth-client" refers to two different things depending on where you're working, and it's important not to mix them up:
packages/auth/client — a real, installable npm workspace package (@zooly/auth-client). Framework-agnostic, no UI. Provides loginWithPopup() and the shared session helpers (fetchUser, logout, redirectToLogin). This is what you install into another app or package.apps/zooly-auth/src/client — the auth app's own internal UI (React components + Tailwind): LoginPage, LoginPopup, the individual forms, AuthContextProvider/useAuth, and the validateReturnTo/validatePopupOrigin utilities. It's importable only from inside apps/zooly-auth, via the @/src/client path alias — it is not an npm package and other apps cannot import it.If you're integrating login into another app, you want the first one. The rest of this page covers that package, then documents the auth app's internal UI for anyone working on apps/zooly-auth itself.
// package.json
"dependencies": {
"@zooly/auth-client": "file:../../packages/auth/client"
}
Also add "@zooly/auth-client" to transpilePackages in the consuming Next.js app's next.config.ts if the package bundling it (e.g. a Vite client mounted inside a Next.js app) doesn't already transpile it directly.
import { loginWithPopup, fetchUser, logout, redirectToLogin } from '@zooly/auth-client';
loginWithPopup() opens the auth app's /popup route in a popup window and resolves once login completes, the popup is closed, or it times out. See Popup Login (No-Redirect) for the full flow and design caveats (COOP, why iframes don't work, etc.).
import { loginWithPopup, redirectToLogin } from '@zooly/auth-client';
async function handleLoginClick() {
// Call synchronously inside the click handler, before any `await` —
// otherwise Safari and other browsers block the popup.
const result = await loginWithPopup({ authUrl: 'https://auth.zooly.ai' });
switch (result.status) {
case 'authenticated':
// result.user is available immediately.
break;
case 'popup_blocked':
redirectToLogin('https://auth.zooly.ai');
break;
case 'cancelled':
// User closed the popup, or it timed out (5 minutes by default).
break;
}
}
LoginWithPopupOptions:
authUrl: string — base URL of the auth app, e.g. https://auth.zooly.ai.width?, height? — popup size in pixels (default 480x720).timeoutMs? — give up after this long (default 5 minutes).pollIntervalMs? — how often to re-check /api/me as the COOP fallback (default 1000ms).Result: { status: "authenticated"; user: User } | { status: "cancelled" } | { status: "popup_blocked" }.
The same three helpers consolidate the near-identical fetchUser/logout implementations that used to be copy-pasted into each consumer:
import { fetchUser, logout, redirectToLogin } from '@zooly/auth-client';
const result = await fetchUser('https://auth.zooly.ai');
// { status: "authenticated"; user } | { status: "unauthenticated" } | { status: "error"; message }
await logout('https://auth.zooly.ai');
redirectToLogin('https://auth.zooly.ai'); // returnTo defaults to window.location.href
fetchUser(authUrl) — reads /api/me with credentials: "include". Distinguishes a 401 ("unauthenticated") from a network/CORS failure ("error") so callers can avoid redirect loops when the auth server is unreachable.logout(authUrl) — POSTs /api/auth/logout with credentials, clearing the session cookies for the current browser.redirectToLogin(authUrl, returnTo?) — sends the browser to ${authUrl}?returnTo=... (the redirect flow's entry point).Two requirements for fetchUser/logout to work cross-origin:
ALLOWED_DOMAINS_CORS env var, or the credentialed request fails CORS. Include your local dev origin too."error" result as a transient failure, not a logout — redirecting on both causes a redirect loop when the auth server is unreachable.packages/offers/client/src/lib/auth.ts is a working example of a consumer that delegates to these helpers while keeping its own User/AuthResult shape for its existing call sites.
Everything below lives in apps/zooly-auth/src/client and is imported via @/src/client from inside apps/zooly-auth only (e.g. from app/page.tsx or app/popup/page.tsx). It is not published as a package.
Wrap the app with AuthContextProvider to manage user authentication state:
import { AuthContextProvider } from '@/src/client';
function App() {
const fetchUser = async () => {
const response = await fetch('/api/me');
if (response.ok) return response.json();
return null;
};
return (
<AuthContextProvider fetchUser={fetchUser}>
{/* app */}
</AuthContextProvider>
);
}
useAuth() then exposes { user, isLoading, setUser, refreshUser } anywhere in the tree.
LoginPage (full-page, two-column) and LoginPopup (compact, single-column, sized for the /popup window) both wrap the same useLoginFlow() hook and LoginFlowSteps renderer, so the login/signup/confirm/forgot/reset state machine and every data-testid are identical between the two — only the surrounding layout and completion behavior differ. LoginPage calls onSuccess(returnTo); LoginPopup calls notifyOpenerAndClose() (post a message to the validated opener origin, then window.close()).
import { LoginPage } from '@/src/client';
function AuthPage() {
const handleLogin = async (email: string, password: string) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) throw new Error('Login failed');
};
return (
<LoginPage
returnTo={new URLSearchParams(window.location.search).get('returnTo')}
onLogin={handleLogin}
onSuccess={(returnTo) => { window.location.href = returnTo || '/'; }}
/>
);
}
Shared props (LoginFlowProps, extended by both LoginPageProps and LoginPopupProps):
returnTo?: string | null — validated redirect target after login (unused by LoginPopup, which never navigates).onLogin?, onSignUp?, onConfirmSignUp?, onResendCode?, onForgotPassword?, onResetPassword? — one handler per form submission; each throws to surface an error in the form.onSocialLogin?: (provider: "google" | "apple") => void — handler for the social buttons (LoginPopup supplies its own internally; LoginPage requires the page to provide one).onSuccess?: (returnTo?: string | null) => void — called after login/signup/confirm succeeds (LoginPage only — LoginPopup always closes instead).Individual, layout-neutral form components are also available for custom shells: LoginForm, PasswordForm, SignUpForm, ConfirmSignUpForm, ForgotPasswordForm, ResetPasswordForm. Each is a plain <form className="space-y-5"> with full-width controls, which is why the same set drops into both LoginPage and the narrower LoginPopup unchanged.
validateReturnTo(returnTo, allowedOrigin?) — validates and sanitizes a returnTo URL:
zooly.ai or any *.zooly.ai subdomain (they share the auth cookie domain), returned as an absolute URL.localhost / 127.0.0.1 for local development.javascript:, data:, and protocol-relative URLs like //evil.com.null for anything else.validatePopupOrigin(origin) — the equivalent check for a popup's postMessage target origin. Same allowlist as validateReturnTo, but always returns a bare origin (protocol + host, no path) since a postMessage target can't carry a path.
notifyOpenerAndClose(popupOrigin) — posts { type: "zooly-auth:success" } to window.opener at popupOrigin (which must already be validated), then calls window.close().
The auth app's UI uses shadcn/ui components with a custom Zooly theme:
#3D4551)#FAF8F5)#D97040)#FCE8E8)packages/auth/client — the installable session/popup package:
src/popup.ts — loginWithPopup()src/session.ts — fetchUser(), logout(), redirectToLogin()src/index.ts — public exportsapps/zooly-auth/src/client — the auth app's internal UI, imported via @/src/client:
components/auth/LoginPage.tsx — full-page login shellcomponents/auth/LoginPopup.tsx — compact popup login shellcomponents/auth/useLoginFlow.ts — shared view-state hook behind both shellscomponents/auth/LoginFlowSteps.tsx — shared per-view form renderercomponents/auth/LoginForm.tsx, PasswordForm.tsx, SignUpForm.tsx, ConfirmSignUpForm.tsx, ForgotPasswordForm.tsx, ResetPasswordForm.tsx — individual formscontext/AuthContext.tsx — authentication context providerhooks/useAuth.ts — authentication hookutils/returnTo.ts — returnTo validationutils/popupOrigin.ts — popup origin validation + notifyOpenerAndClose()