Authentication
import { createSnapshot } from "@lastshotlabs/snapshot";import { InputField, ButtonBase, CardBase, ColumnBase, AlertBase } from "@lastshotlabs/snapshot/ui";import { useState, useEffect } from "react";
const snap = createSnapshot({ apiUrl: "/api", loginPath: "/login", homePath: "/",});
function LoginPage() { const { mutate: login, isPending, error, reset } = snap.useLogin(); const [email, setEmail] = useState(""); const [password, setPassword] = useState("");
return ( <CardBase title="Sign in"> <form onSubmit={(e) => { e.preventDefault(); reset(); login({ email, password }); }}> <ColumnBase gap="md"> <InputField label="Email" type="email" value={email} onChange={setEmail} required /> <InputField label="Password" type="password" value={password} onChange={setPassword} required /> {error && <AlertBase severity="error">{snap.formatAuthError(error)}</AlertBase>} <ButtonBase label={isPending ? "Signing in..." : "Sign in"} type="submit" disabled={isPending} fullWidth /> </ColumnBase> </form> </CardBase> );}Core auth hooks
Section titled “Core auth hooks”useUser
Section titled “useUser”Fetches the current authenticated user. Caches for 5 minutes.
const { user, isLoading, isError } = snap.useUser();// user is AuthUser | null — null when logged outuseLogin
Section titled “useLogin”Posts credentials to /auth/login. Returns a TanStack Query mutation.
const { mutate: login, isPending, isError } = snap.useLogin();
login({ email, password });// On success: stores tokens, caches user, navigates to homePath// If MFA required: returns MfaChallenge instead of AuthUserTo redirect somewhere other than homePath:
login({ email, password, redirectTo: "/onboarding" });useLogout
Section titled “useLogout”Clears tokens and cache, navigates to loginPath.
const { mutate: logout } = snap.useLogout();logout(); // clears all cached data and navigates to loginPathlogout({ redirectTo: "/goodbye" }); // custom redirectuseRegister
Section titled “useRegister”Posts to /auth/register. On success, stores tokens and navigates to homePath.
function RegisterForm() { const { mutate: register, isPending, error } = snap.useRegister(); const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState("");
const mismatch = confirm.length > 0 && confirm !== password;
return ( <CardBase title="Create account"> <form onSubmit={(e) => { e.preventDefault(); if (!mismatch) register({ name, email, password }); }}> <ColumnBase gap="md"> <InputField label="Full name" value={name} onChange={setName} required /> <InputField label="Email" type="email" value={email} onChange={setEmail} required /> <InputField label="Password" type="password" value={password} onChange={setPassword} required /> <InputField label="Confirm password" type="password" value={confirm} onChange={setConfirm} required errorText={mismatch ? "Passwords don't match" : undefined} /> {error && <AlertBase severity="error">{snap.formatAuthError(error)}</AlertBase>} <ButtonBase label={isPending ? "Creating..." : "Create account"} type="submit" disabled={isPending || mismatch} fullWidth /> </ColumnBase> </form> </CardBase> );}useForgotPassword and useResetPassword
Section titled “useForgotPassword and useResetPassword”useForgotPassword sends a reset link. useResetPassword completes the reset using the token from that link.
function ForgotPasswordPage() { const { mutate: forgot, isSuccess, isPending, error } = snap.useForgotPassword(); const [email, setEmail] = useState("");
if (isSuccess) { return ( <CardBase title="Check your email"> <p>We sent a reset link to <strong>{email}</strong>. It expires in 1 hour.</p> </CardBase> ); }
return ( <CardBase title="Reset password"> <form onSubmit={(e) => { e.preventDefault(); forgot({ email }); }}> <ColumnBase gap="md"> <InputField label="Email" type="email" value={email} onChange={setEmail} required /> {error && <AlertBase severity="error">{snap.formatAuthError(error)}</AlertBase>} <ButtonBase label={isPending ? "Sending..." : "Send reset link"} type="submit" disabled={isPending} fullWidth /> </ColumnBase> </form> </CardBase> );}Mount a reset page at the URL in your reset emails (e.g. /auth/reset?token=...):
function ResetPasswordPage() { const { mutate: resetPassword, isPending, isSuccess, error } = snap.useResetPassword(); const token = new URLSearchParams(window.location.search).get("token") ?? ""; const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const mismatch = confirm.length > 0 && confirm !== password;
if (isSuccess) { return ( <CardBase title="Password reset"> <AlertBase severity="success">Your password has been reset.</AlertBase> <ButtonBase label="Sign in" onClick={() => (window.location.href = "/login")} fullWidth /> </CardBase> ); }
return ( <CardBase title="Set new password"> <form onSubmit={(e) => { e.preventDefault(); if (!mismatch) resetPassword({ token, password }); }}> <ColumnBase gap="md"> <InputField label="New password" type="password" value={password} onChange={setPassword} required /> <InputField label="Confirm password" type="password" value={confirm} onChange={setConfirm} required errorText={mismatch ? "Passwords don't match" : undefined} /> {error && <AlertBase severity="error">{snap.formatAuthError(error)}</AlertBase>} <ButtonBase label={isPending ? "Resetting..." : "Reset password"} type="submit" disabled={isPending || mismatch} fullWidth /> </ColumnBase> </form> </CardBase> );}MFA (Multi-Factor Authentication)
Section titled “MFA (Multi-Factor Authentication)”When useLogin() returns an MFA challenge instead of a user, use isMfaChallenge to detect it and useMfaVerify to complete login.
import { isMfaChallenge } from "@lastshotlabs/snapshot";
function LoginWithMfa() { const { mutate: login } = snap.useLogin(); const { mutate: verify } = snap.useMfaVerify(); const challenge = snap.usePendingMfaChallenge(); const [code, setCode] = useState("");
if (challenge) { return ( <form onSubmit={(e) => { e.preventDefault(); verify({ code, method: challenge.mfaMethods[0] }); }}> <InputField label="Verification code" value={code} onChange={setCode} /> <ButtonBase label="Verify" type="submit" /> </form> ); }
return <LoginForm onSubmit={(creds) => login(creds)} />;}MFA setup flow
Section titled “MFA setup flow”Enable TOTP (authenticator app) for a user in their settings:
function MfaSetup() { const { mutate: startSetup, data: setupData, isPending: starting } = snap.useMfaSetup(); const { mutate: verifySetup, isPending: verifying, error } = snap.useMfaVerifySetup(); const [code, setCode] = useState("");
// Step 1: Start setup — returns QR code URI and backup codes if (!setupData) { return ( <ButtonBase label={starting ? "Setting up..." : "Enable two-factor authentication"} onClick={() => startSetup()} disabled={starting} /> ); }
// Step 2: User scans QR code and enters first code to confirm return ( <ColumnBase gap="md"> <p>Scan this QR code with your authenticator app:</p> <img src={setupData.qrCodeUrl} alt="TOTP QR code" style={{ width: 200, height: 200 }} /> <p style={{ fontSize: "0.875rem", color: "var(--sn-color-muted-foreground)" }}> Or enter this key manually: <code>{setupData.secret}</code> </p> <form onSubmit={(e) => { e.preventDefault(); verifySetup({ code }); }}> <InputField label="Enter the 6-digit code" value={code} onChange={setCode} maxLength={6} /> {error && <AlertBase severity="error">{snap.formatAuthError(error)}</AlertBase>} <ButtonBase label={verifying ? "Verifying..." : "Confirm"} type="submit" disabled={verifying} /> </form> <details> <summary>Backup codes (save these somewhere safe)</summary> <pre>{setupData.recoveryCodes?.join("\n")}</pre> </details> </ColumnBase> );}All MFA hooks
Section titled “All MFA hooks”| Hook | Purpose |
|---|---|
useMfaSetup() | Start TOTP setup, get QR code and backup codes |
useMfaVerifySetup() | Confirm TOTP setup with first code |
useMfaDisable() | Disable MFA (requires password) |
useMfaRecoveryCodes() | Regenerate recovery codes (requires password) |
useMfaMethods() | Query enabled MFA methods |
useMfaResend() | Resend OTP code |
useMfaEmailOtpEnable() | Enable email OTP method |
useMfaEmailOtpVerifySetup() | Confirm email OTP setup |
useMfaEmailOtpDisable() | Disable email OTP (requires password) |
Redirect to OAuth provider
Section titled “Redirect to OAuth provider”const url = snap.getOAuthUrl("google");window.location.href = url;// URL is built from auth.providers config (clientId, scopes, callbackPath)Exchange OAuth code for session
Section titled “Exchange OAuth code for session”After the provider redirects back with a code parameter, mount a callback page that exchanges it for a session:
function OAuthCallback() { const { mutate: exchange, error, isPending } = snap.useOAuthExchange(); const params = new URLSearchParams(window.location.search); const code = params.get("code"); const provider = params.get("provider") ?? "google";
useEffect(() => { if (code) exchange({ code, provider }); }, [code]);
if (error) { return ( <CardBase title="Sign-in failed"> <AlertBase severity="error">{snap.formatAuthError(error)}</AlertBase> <ButtonBase label="Try again" onClick={() => (window.location.href = "/login")} /> </CardBase> ); }
return <p>{isPending ? "Completing sign-in..." : "Redirecting..."}</p>;}Built-in OAuth buttons
Section titled “Built-in OAuth buttons”import { OAuthButtonsBase } from "@lastshotlabs/snapshot/ui";
<OAuthButtonsBase providers={[ { name: "google", label: "Continue with Google", url: snap.getOAuthUrl("google") }, { name: "github", label: "Continue with GitHub", url: snap.getOAuthUrl("github") }, { name: "discord", label: "Continue with Discord", url: snap.getOAuthUrl("discord") }, ]} onProviderClick={(url) => { window.location.href = url; }}/>Unlink an OAuth provider
Section titled “Unlink an OAuth provider”const { mutate: unlink } = snap.useOAuthUnlink();unlink("google"); // removes the linked OAuth accountPasskeys (WebAuthn)
Section titled “Passkeys (WebAuthn)”Register a passkey
Section titled “Register a passkey”const { mutate: getOptions } = snap.useWebAuthnRegisterOptions();const { mutate: register } = snap.useWebAuthnRegister();
// Step 1: Get registration challenge from servergetOptions(undefined, { onSuccess: async (options) => { // Step 2: Create credential with browser API const credential = await navigator.credentials.create({ publicKey: options }); // Step 3: Send credential to server register({ id: credential.id, rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))), response: { /* attestation response */ }, type: credential.type, }); },});Login with passkey
Section titled “Login with passkey”The WebAuthn browser API can throw if the user cancels or the device doesn’t support it. Always wrap in try-catch:
import { PasskeyButtonBase } from "@lastshotlabs/snapshot/ui";
function PasskeyLogin() { const { mutate: getLoginOptions } = snap.usePasskeyLoginOptions(); const { mutate: passkeyLogin } = snap.usePasskeyLogin(); const [error, setError] = useState<string | null>(null);
return ( <> <PasskeyButtonBase label="Sign in with passkey" onClick={() => { setError(null); getLoginOptions({}, { onSuccess: async (response) => { try { const assertion = await navigator.credentials.get({ publicKey: response.options as any, }); if (assertion) { passkeyLogin({ id: assertion.id, rawId: btoa(String.fromCharCode(...new Uint8Array((assertion as any).rawId))), response: { authenticatorData: btoa(String.fromCharCode( ...new Uint8Array((assertion as any).response.authenticatorData))), clientDataJSON: btoa(String.fromCharCode( ...new Uint8Array((assertion as any).response.clientDataJSON))), signature: btoa(String.fromCharCode( ...new Uint8Array((assertion as any).response.signature))), }, }); } } catch (err) { if ((err as Error).name !== "NotAllowedError") { setError("Passkey authentication failed. Try another sign-in method."); } } }, onError: () => setError("Could not start passkey login."), }); }} /> {error && <AlertBase severity="error">{error}</AlertBase>} </> );}Manage passkeys
Section titled “Manage passkeys”const { credentials, isLoading } = snap.useWebAuthnCredentials();const { mutate: remove } = snap.useWebAuthnRemoveCredential();
// List credentialscredentials?.map((cred) => ( <div key={cred.id}> {cred.name} <ButtonBase label="Remove" onClick={() => remove(cred.id)} /> </div>));Email verification
Section titled “Email verification”Mount a verification page at the URL in your verification emails (e.g. /auth/verify?token=...):
function VerifyEmailPage() { const { mutate: verify, isPending, isSuccess, error } = snap.useVerifyEmail(); const token = new URLSearchParams(window.location.search).get("token") ?? ""; const { mutate: resend, isPending: resending, isSuccess: resent } = snap.useResendVerification(); const { user } = snap.useUser();
useEffect(() => { if (token) verify({ token }); }, [token]);
if (isSuccess) { return ( <CardBase title="Email verified"> <AlertBase severity="success">Your email has been verified.</AlertBase> <ButtonBase label="Go to dashboard" onClick={() => (window.location.href = "/")} fullWidth /> </CardBase> ); }
return ( <CardBase title="Verify your email"> {isPending && <p>Verifying...</p>} {error && ( <ColumnBase gap="md"> <AlertBase severity="error">{snap.formatAuthError(error)}</AlertBase> {resent ? <AlertBase severity="success">Verification email sent</AlertBase> : <ButtonBase label={resending ? "Sending..." : "Resend verification email"} variant="outline" onClick={() => resend({ email: user?.email ?? "" })} disabled={resending} fullWidth /> } </ColumnBase> )} </CardBase> );}Account management
Section titled “Account management”Change password
Section titled “Change password”function ChangePassword() { const { mutate: setPassword, isPending, isSuccess, error, reset } = snap.useSetPassword(); const [current, setCurrent] = useState(""); const [newPwd, setNewPwd] = useState("");
return ( <form onSubmit={(e) => { e.preventDefault(); setPassword({ password: newPwd, currentPassword: current }, { onSuccess: () => { setCurrent(""); setNewPwd(""); }, }); }}> <ColumnBase gap="md"> <InputField label="Current password" type="password" value={current} onChange={(v) => { setCurrent(v); reset(); }} /> <InputField label="New password" type="password" value={newPwd} onChange={setNewPwd} /> {isSuccess && <AlertBase severity="success">Password updated</AlertBase>} {error && <AlertBase severity="error">{snap.formatAuthError(error)}</AlertBase>} <ButtonBase label={isPending ? "Updating..." : "Update password"} type="submit" disabled={isPending || !current || !newPwd} /> </ColumnBase> </form> );}Session management
Section titled “Session management”function ActiveSessions() { const { sessions, isLoading } = snap.useSessions(); const { mutate: revoke } = snap.useRevokeSession();
if (isLoading) return <p>Loading sessions...</p>;
return ( <ColumnBase gap="sm"> {sessions?.map((s) => ( <RowBase key={s.id} justify="between" align="center"> <span>{s.device} — {s.ip} {s.current && "(current)"}</span> {!s.current && ( <ButtonBase label="Revoke" variant="destructive" size="sm" onClick={() => revoke(s.id)} /> )} </RowBase> ))} </ColumnBase> );}All account hooks
Section titled “All account hooks”| Hook | Purpose | Parameters |
|---|---|---|
useResetPassword() | Reset password with token | { token, password } |
useVerifyEmail() | Verify email address | { token } |
useResendVerification() | Resend verification email | { email } |
useSetPassword() | Change password | { password, currentPassword? } |
useDeleteAccount() | Delete account | { password } |
useCancelDeletion() | Cancel pending deletion | (none) |
useRefreshToken() | Refresh access token | { refreshToken? } |
useSessions() | List active sessions | (query hook) |
useRevokeSession() | Revoke a session | session ID string |
Route guards
Section titled “Route guards”For TanStack Router, Snapshot provides route guard functions:
import { createFileRoute } from "@tanstack/react-router";
// Protected route — redirects unauthenticated users to loginPathexport const Route = createFileRoute("/dashboard")({ beforeLoad: snap.protectedBeforeLoad, component: Dashboard,});
// Guest route — redirects authenticated users to homePathexport const Route = createFileRoute("/login")({ beforeLoad: snap.guestBeforeLoad, component: LoginPage,});Auth error formatting
Section titled “Auth error formatting”const formatted = snap.formatAuthError(error);// Returns a user-friendly error message based on the error typeNext steps
Section titled “Next steps”- Forms and Validation — build forms with Snapshot’s 18 field components
- Layout and Navigation — add navigation with auth-aware user menus
- Login Page recipe — complete login page with all auth methods