Skip to content

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>
);
}

Fetches the current authenticated user. Caches for 5 minutes.

const { user, isLoading, isError } = snap.useUser();
// user is AuthUser | null — null when logged out

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 AuthUser

To redirect somewhere other than homePath:

login({ email, password, redirectTo: "/onboarding" });

Auth redirect targets are restricted to the current application origin. Cross-origin redirectTo values are ignored.

Clears tokens and cache, navigates to loginPath.

const { mutate: logout } = snap.useLogout();
logout(); // clears all cached data and navigates to loginPath
logout({ redirectTo: "/goodbye" }); // custom redirect

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 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>
);
}

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)} />;
}

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>
);
}
HookPurpose
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)
const url = snap.getOAuthUrl("google");
window.location.href = url;
// URL is built from auth.providers config (clientId, scopes, callbackPath)

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>;
}
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;
}}
/>;
const { mutate: unlink } = snap.useOAuthUnlink();
unlink("google"); // removes the linked OAuth account
const { mutate: getOptions } = snap.useWebAuthnRegisterOptions();
const { mutate: register } = snap.useWebAuthnRegister();
// Step 1: Get registration challenge from server
getOptions(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,
});
},
});

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>}
</>
);
}
const { credentials, isLoading } = snap.useWebAuthnCredentials();
const { mutate: remove } = snap.useWebAuthnRemoveCredential();
// List credentials
credentials?.map((cred) => (
<div key={cred.id}>
{cred.name} <ButtonBase label="Remove" onClick={() => remove(cred.id)} />
</div>
));

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>
);
}
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>
);
}
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>
);
}
HookPurposeParameters
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 sessionsession ID string

For TanStack Router, Snapshot provides route guard functions:

import { createFileRoute } from "@tanstack/react-router";
// Protected route — redirects unauthenticated users to loginPath
export const Route = createFileRoute("/dashboard")({
beforeLoad: snap.protectedBeforeLoad,
component: Dashboard,
});
// Guest route — redirects authenticated users to homePath
export const Route = createFileRoute("/login")({
beforeLoad: snap.guestBeforeLoad,
component: LoginPage,
});
const formatted = snap.formatAuthError(error);
// Returns a user-friendly error message based on the error type