Client Integration

Using the auth client package in your app

Overview

"@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.

Installation

// 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" }.

Session Helpers

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:

  • The calling origin must be listed in the auth server's ALLOWED_DOMAINS_CORS env var, or the credentialed request fails CORS. Include your local dev origin too.
  • Treat a "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.

Auth App Internal UI

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.

Authentication Context

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 / LoginPopup

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).

Form Components

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.

Utilities

validateReturnTo(returnTo, allowedOrigin?) — validates and sanitizes a returnTo URL:

  • Allows relative paths and same-origin URLs, returned as a relative path.
  • Allows cross-origin URLs on zooly.ai or any *.zooly.ai subdomain (they share the auth cookie domain), returned as an absolute URL.
  • Allows localhost / 127.0.0.1 for local development.
  • Rejects javascript:, data:, and protocol-relative URLs like //evil.com.
  • Returns 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().

Theme and Styling

The auth app's UI uses shadcn/ui components with a custom Zooly theme:

  • Colors: Defined via CSS variables
    • Primary: Zooly slate (#3D4551)
    • Secondary: Zooly cream (#FAF8F5)
    • Accent: Zooly coral (#D97040)
    • Logo circle: Zooly peach (#FCE8E8)
  • Components: Button, Input, Label, Card, Separator (shadcn/ui components)
  • Responsive: Mobile-first design with Tailwind CSS breakpoints

File Structure

packages/auth/client — the installable session/popup package:

  • src/popup.tsloginWithPopup()
  • src/session.tsfetchUser(), logout(), redirectToLogin()
  • src/index.ts — public exports

apps/zooly-auth/src/client — the auth app's internal UI, imported via @/src/client:

  • components/auth/LoginPage.tsx — full-page login shell
  • components/auth/LoginPopup.tsx — compact popup login shell
  • components/auth/useLoginFlow.ts — shared view-state hook behind both shells
  • components/auth/LoginFlowSteps.tsx — shared per-view form renderer
  • components/auth/LoginForm.tsx, PasswordForm.tsx, SignUpForm.tsx, ConfirmSignUpForm.tsx, ForgotPasswordForm.tsx, ResetPasswordForm.tsx — individual forms
  • context/AuthContext.tsx — authentication context provider
  • hooks/useAuth.ts — authentication hook
  • utils/returnTo.tsreturnTo validation
  • utils/popupOrigin.ts — popup origin validation + notifyOpenerAndClose()