Browser quickstart

Get a fresh single-page app from nothing to a signed-in user, using the published @timonier/browser package. Part 1 creates a project and a public app in your operator dashboard; Part 2 wires the SDK into a plain Vite SPA.

Part 1 — create a project and an app

1. Sign in to the dashboard

Open your platform's operator dashboard. Sign-up and sign-in share one screen — if you don't have an operator account yet, entering your email there creates one.

Enter your email and click Sign in, then enter the 6-digit code from the email.

The dashboard walks you straight into creating your first project.

2. Create a project

From Projects, create a new project: give it a display name and a slug, then confirm.

3. Create a public app

From the project's apps list, create a new app. Fill in:

  • Display name and Slug — anything you like.
  • Client typePublic. A browser SPA cannot keep a client secret, so it authenticates with PKCE alone.
  • Redirect URI 1http://localhost:5173/callback. Vite's dev server defaults to port 5173, so nothing later in this guide needs a port flag.
  • Signup enabled — leave this checked (it's checked by default). If you uncheck it, the sign-up step later in this guide is rejected by the platform.

Confirm, then save your credentials.

4. Get your four values

Open the app's Credentials screen. You'll need four values for Part 2:

  • The Client ID shown on the Credentials screen.
  • Your project slug.
  • Your app slug.
  • Your auth data-plane origin (the same host the dashboard's Integration tab derives a scheme for).

Part 2 — the SPA

1. Scaffold the app

npm create vite@latest app -- --template vanilla-ts

This scaffolds a plain TypeScript project in app/, no framework.

2. Install the SDK

cd app
echo "@timonier:registry=https://npm.registry.timonier.eu/" >> .npmrc
npm install @timonier/browser

3. Configure

cat > .env.local <<'EOF'
VITE_TIMONIER_BASE_URL=<your data-plane origin>
VITE_TIMONIER_PROJECT_SLUG=<your project slug>
VITE_TIMONIER_APP_SLUG=<your app slug>
VITE_TIMONIER_CLIENT_ID=<your app's Client ID>
EOF

Use the four values from Part 1, step 4.

This step's fence, and the src/main.ts fence in the next step, are extracted verbatim by the clean-room gate that guards this guide. If you edit either, keep the gate passing — it's the guide's own regression test, not a copy of it.

4. Write the app

Replace src/main.ts with the complete file below. It renders into the scaffolded index.html's existing #app div — nothing there needs editing.

import { createClient, UnauthenticatedError } from "@timonier/browser";

function requireEnv(name: string): string {
  const value = import.meta.env[name];
  if (!value) {
    throw new Error(`Missing required env var: ${name}`);
  }
  return value as string;
}

const baseUrl = requireEnv("VITE_TIMONIER_BASE_URL");
const projectSlug = requireEnv("VITE_TIMONIER_PROJECT_SLUG");
const appSlug = requireEnv("VITE_TIMONIER_APP_SLUG");

const client = createClient({
  baseUrl,
  projectSlug,
  appSlug,
  clientId: requireEnv("VITE_TIMONIER_CLIENT_ID"),
});

const app = document.querySelector<HTMLDivElement>("#app")!;

function renderSignedIn(email: string): void {
  app.innerHTML = `
    <p>Signed in as <span id="user-email"></span></p>
    <button id="clear-session">Clear local session</button>
  `;
  // textContent, not innerHTML: email is server data, rendered into someone
  // else's page by every reader who copies this guide verbatim.
  document.querySelector<HTMLSpanElement>("#user-email")!.textContent = email;
  document
    .querySelector<HTMLButtonElement>("#clear-session")!
    .addEventListener("click", () => {
      client.clearSession();
      renderSignedOut();
    });
}

function renderSignedOut(): void {
  app.innerHTML = `<button id="sign-in">Sign in</button>`;
  document
    .querySelector<HTMLButtonElement>("#sign-in")!
    .addEventListener("click", () => {
      client.signIn({ redirectUri: location.origin + "/callback" });
    });
}

async function refresh(): Promise<void> {
  try {
    const user = await client.getUser();
    renderSignedIn(user.email);
  } catch (e) {
    if (e instanceof UnauthenticatedError) {
      renderSignedOut();
    } else {
      throw e;
    }
  }
}

if (location.pathname === "/callback") {
  await client.handleCallback();
  history.replaceState(null, "", "/");
}
await refresh();

handleCallback() returns nothing — it stores the token internally — so both the callback path and every normal page load converge on the same refresh() call, which calls client.getUser() (minting a token from the session cookie if needed) to fetch the signed-in user's profile and renders accordingly. getUser() throws UnauthenticatedError exactly like getAccessToken() when there is no session.

Clear local session does exactly what it says: it drops the in-memory access token and clears transient sign-in state, but it does not end your session on the platform. Reload the page afterwards and you'll see Signed in as your email again — refresh() re-mints a token from the still-live server-side session cookie and re-fetches your profile. There is no sign-out in this guide; ending the platform session itself is a separate, not-yet-covered operation.

5. Run it

npm run dev

Visit http://localhost:5173. You should see a Sign in button.

6. Sign in

Click Sign in. You're taken to the platform's hosted sign-in screen. Enter your email, then the 6-digit code from your inbox. You're returned to http://localhost:5173/callback, which immediately redirects to / showing Signed in as your email.

7. Build and preview

npm run build
npm run preview -- --port 5173

Visit http://localhost:5173 again. The built, served app behaves the same as the dev server: Signed in as your email, because the session cookie from step 6 is still live.

API reference

This guide covers sign-in, callback handling, token retrieval, profile lookup, and local session clearing — the whole surface @timonier/browser exposes:

  • signIn({ redirectUri }) — starts the PKCE flow and navigates to the hosted sign-in screen.
  • handleCallback() — completes the flow on your redirect route. Returns nothing; it stores the token internally.
  • getAccessToken() — returns the current access token, minting one from the session cookie when needed. Throws UnauthenticatedError when there is no session.
  • getUser() — returns the signed-in user's profile ({ sub, email, name, emailVerified }) by calling GET /userinfo with the current access token. Caches nothing. Throws UnauthenticatedError when there is no session.
  • clearSession() — drops the in-memory token and transient sign-in state. Local only.

The package ships TypeScript declarations, so your editor has the full types once it is installed.