Przejdź do głównej zawartości

Autoryzacja klienta

Kompletny przewodnik po autoryzacji klienta w storefront: rejestracja, logowanie, zarządzanie sesją, odnawianie tokenów, odzyskiwanie hasła i ochrona stron.

Model bezpieczeństwa — SDK-BFF

Autoryzacja klienta opiera się na route handlerach BFF uruchomionych na domenie storefrontu (/api/auth/[action]). Przeglądarka nigdy nie komunikuje się z backendem bezpośrednio w celu auth — robi to warstwa BFF server-to-server. To zapewnia first-party httpOnly cookie na domenie sklepu, działające identycznie na subdomenach, custom domains i hostingu off-platform.

  1. Jeden plik routecreateStorefrontAuthRoute generuje 4 handlery: login / refresh / logout / whoami
  2. First-party cookie — BFF sadzi customerAccessToken (Path=/) i customerRefreshToken (Path=/api/auth) na domenie sklepu
  3. Refresh token nigdy w JS — czytany wyłącznie server-side przez BFF i wymieniany S2S z backendem
  4. Automatyczne odświeżanie<StorefrontProvider autoRefresh> uruchamia scheduler przed wygaśnięciem access tokena
  5. Reaktywna obsługa 401 — zapytania (queries) → silent refresh + retry; mutacje → bail + event session-expired
  6. Trwała sesja domyślnie — provider bez propsów initial* sam odtwarza sesję po twardym refreshu z czytelnego cookie session-expiry (client-side, layout zostaje statyczny); opcjonalny seed serwerowy getInitialAuth() usuwa placeholder pierwszego renderu kosztem dynamicznego renderowania

Szybki start — trzy kroki

1. Jeden plik route (cały auth surface)

// app/api/auth/[action]/route.ts
import { createStorefrontAuthRoute, trustedForwardedHostValidator } from '@doswiftly/storefront-sdk/react/server';

export const { GET, POST } = createStorefrontAuthRoute({
apiUrl: process.env.NEXT_PUBLIC_API_URL!, // https://api.doswiftly.pl
shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
isTrustedOrigin: trustedForwardedHostValidator, // default dla hostingu na DoSwiftly i Vercel
});
// Jeden plik obsługuje: POST /api/auth/login | refresh | logout i GET /api/auth/whoami

isTrustedOrigin: trustedForwardedHostValidator — przekaż gdy storefront stoi za reverse proxy (DoSwiftly hosting, Vercel), który przepisuje nagłówek Host. Bez tego w lokalnym pnpm dev (bez proxy) pomiń opcję lub isTrustedOrigin: null.

2. Root layout — provider bez kodu sesji

// app/layout.tsx — Server Component; nie czyta cookies, więc trasa może być statyczna (ISR)
import { StorefrontProvider } from '@doswiftly/storefront-sdk/react';

export default async function RootLayout({ children }) {
return (
<StorefrontProvider
config={{ apiUrl: process.env.NEXT_PUBLIC_API_URL!, shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG! }}
shopData={shopData}
// autoRefresh jest domyślnie włączony w przeglądarce — scheduler + reaktywny 401
>
{children}
</StorefrontProvider>
);
}

Trwała sesja działa bez żadnego seeda. Gdy nie przekażesz propsów initial*, provider tuż po hydracji odczytuje w przeglądarce czytelne cookie session-expiry (sam znacznik czasu — nigdy token), oznacza kupującego jako zalogowanego i natychmiast odnawia access token w tle przez trasę BFF. Pierwszy render kliencki jest identyczny z serwerowym (zero rozjazdu hydracji); do momentu odnowienia tożsamość „się ustala" — bramkuj UI konta przez useAuthReady(). Kompletny przepływ: przepis Logowanie i trwała sesja.

getInitialAuth() wymusza dynamiczne renderowanie

Seed serwerowy (getInitialAuth() + propsy initial*) usuwa nawet krótki placeholder pierwszego renderu — ale czyta cookies() w Server Component, co wyłącza statyczne renderowanie całego poddrzewa layoutu. Na storefroncie ze statycznym katalogiem to zła wymiana. Sięgnij po niego tylko, gdy layout i tak jest dynamiczny.

// app/layout.tsx — wariant z seedem serwerowym (layout staje się dynamiczny!)
import { getInitialAuth } from '@doswiftly/storefront-sdk/react/server';

const { isAuthenticated, accessToken, expiresAt } = await getInitialAuth();
// przekaż jako initialIsAuthenticated / initialAccessToken / initialExpiresAt

getInitialAuth() zwraca { isAuthenticated, accessToken, expiresAt }:

  • isAuthenticated: true gdy customerAccessToken lub session-expiry cookie jest obecne (długowieczny hint — zalogowany user powracający po wygaśnięciu krótkiego access tokena nie widzi flashu "wylogowany")
  • accessToken — surowy JWT z httpOnly cookie (seedowany do pamięci, nigdy do localStorage)
  • expiresAt — ISO-8601 z readable session-expiry cookie (dla schedulera przy cold starcie)

Przekazanie jakiegokolwiek propsa initial* (nawet false/null) wyłącza automatyczny odczyt cookie — jawny seed jest wtedy jedynym źródłem stanu początkowego.

3. Komponenty — zero kodu auth

'use client';
import { useAuth, useAuthStore, useSessionExpired } from '@doswiftly/storefront-sdk/react';
import { useRouter } from 'next/navigation';

export function AuthButtons() {
// Akcje pochodzą z useAuth; stan sesji czytamy ze store (useAuth go NIE zwraca).
const { logout, isLoggingOut } = useAuth();
const customer = useAuthStore((s) => s.customer);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const router = useRouter();

// Globalna reakcja na wygaśnięcie sesji (po nieudanym odświeżeniu)
useSessionExpired(() => router.replace('/auth/login'));

if (isAuthenticated && customer) {
return (
<div>
<span>Cześć, {customer.firstName ?? customer.email}</span>
<button onClick={() => logout()} disabled={isLoggingOut}>
Wyloguj się
</button>
</div>
);
}

return <a href="/auth/login">Zaloguj się</a>;
}

Refresh: AUTOMATYCZNY. autoRefresh w providerze:

  • proaktywnie odświeża przed wygaśnięciem (setTimeout(expiresAt − bufor));
  • reaktywnie po 401 na query → deduped refresh + retry;
  • po 401 na mutację → bail + event session-expired (mutacje nigdy nie są retry'owane).

Server-side seed tokenu (SSR, SSO redirect, env JWT)

Dla scenariuszy, w których storefront ma raw JWT klienta po stronie serwera zanim klient w przeglądarce wystartuje (odczyt wartości httpOnly cookie, callback SSO redirectu, magic link, env var dev-seed), <StorefrontProvider> przyjmuje opcjonalny prop initialAccessToken. Token trafia bezpośrednio do authStore.accessToken w factory store'a — authMiddleware dorzuca Authorization: Bearer ... od pierwszego requestu, bez round-tripa do /api/auth/whoami na pierwszym mountcie.

// app/layout.tsx
import { cookies } from 'next/headers';
import { StorefrontProvider, AUTH_COOKIE_NAME } from '@doswiftly/storefront-sdk';

export default async function RootLayout({ children }) {
const cookieStore = await cookies();
const initialAccessToken = cookieStore.get(AUTH_COOKIE_NAME)?.value ?? null;

return (
<StorefrontProvider
config={{ apiUrl, shopSlug }}
shopData={shop}
initialAccessToken={initialAccessToken}
>
{children}
</StorefrontProvider>
);
}

initialIsAuthenticated vs initialAccessToken — kiedy co

Sygnał serwerowyPropCo dostaje SDKRound-trip /api/auth/whoami
Wiem czy klient jest zalogowany (cookie obecne, nie znam wartości)initialIsAuthenticated: booleanBoolean flag — eliminuje flash „Sign In" w UI gatingWymagany dla pobrania customer profile + tokenu
Mam raw JWT po stronie serwera (cookie value, SSO param, env seed)initialAccessToken: string | nullWstrzyknięty do authStore.accessToken (in-memory)Niewymagany — middleware działa od pierwszego requestu

Gdy podasz initialAccessToken truthy, isAuthenticated startuje automatycznie jako true. Konsumer może nadpisać initialIsAuthenticated={false} w edge case'ach (opt-out flow, recovery banner).

Bez żadnego z tych propsów provider seeduje się sam z czytelnego cookie session-expiry w przeglądarce (trwała sesja przy statycznym layoucie — patrz „Szybki start" wyżej). Jawny props — którykolwiek — wyłącza ten fallback.

setAuth(null, token) — token bez profilu klienta

Sygnatura authStore.setAuth(customer, accessToken) akceptuje customer: CustomerInfo | null. Use case: konsumer dostał token z SSO callbacku / magic linka / dev-seedu, ale profil klienta fetch'uje osobno (np. dopiero w server-side getCustomer() po pierwszym requestcie). Zamiast brudnego useAuthStoreApi().setState({ accessToken }) użyj:

const setAuth = useAuthStore((s) => s.setAuth);
setAuth(null, ssoCallbackToken); // token w-memory; profil klienta doczytany osobno (np. server-side getCustomer() przy następnym renderze)

Bezpieczeństwo

Token NIGDY nie jest persistowany w localStorage — ochrona przed XSS. persist.partialize wyklucza pole accessToken z payloadu zapisywanego w przeglądarce: w localStorage trafiają wyłącznie dane klienta (customer) i flaga isAuthenticated. Każdy hard refresh czyści token z pamięci — server-side seed musi być wykonany ponownie z httpOnly cookie / cookie / env var w layout.tsx przy każdym requestcie.

Race condition stale customer w localStorage (user X) ↔ nowy initialAccessToken (user Y): konsumer odpowiedzialny za clearAuth() przed seedem nowego tokena (token rotation, SSO re-login). SDK nie dekoduje JWT — core SDK nie ma zewnętrznych zależności i działa w każdym środowisku JavaScript bez biblioteki JWT.

Dostępne hooki SDK

Hooki (Client Components)

HookImportOpis
useAuth@doswiftly/storefront-sdk/reactFacade akcji: login, logout, refreshToken + flagi stanu operacji (isLoggingIn/isLoggingOut/isRefreshingToken/isLoading/error)
useBffLogin@doswiftly/storefront-sdk/reactFocused: logowanie przez route BFF (POST /api/auth/login) — sadzi komplet first-party cookies (access + refresh + session-expiry) i seeduje store; symetryczne do useRegister. Zalecane dla trwałej sesji
useRegister@doswiftly/storefront-sdk/reactFocused: rejestracja przez route BFF (POST /api/auth/signup) — tworzy konto i od razu zakłada pełną sesję (komplet cookies, jak useBffLogin). Przy sklepie z ręcznym zatwierdzaniem kont wynik niesie accountStatus: 'PENDING_APPROVAL' + pendingApprovalMessage. Zalecane
useLogin@doswiftly/storefront-sdk/reactFocused: logowanie przez GraphQL (mutacja customerLogin) + aktualizacja store; bez refresh cookie. Opcjonalny callback onSetToken. Użyj, gdy nie potrzebujesz auto-odświeżania
useLogout@doswiftly/storefront-sdk/reactFocused: wylogowanie (mutacja customerLogout) + wyczyszczenie danych klienta z koszyka i ze store
useSessionExpired@doswiftly/storefront-sdk/reactSubskrypcja eventu session-expired
useAuthStore@doswiftly/storefront-sdk/reactStan sesji: { customer, isAuthenticated, expiresAt }

Helpery serwera

FunkcjaImportOpis
createStorefrontAuthRoute@doswiftly/storefront-sdk/react/serverGenerator 4 route handlerów BFF (login/refresh/logout/whoami)
getInitialAuth@doswiftly/storefront-sdk/react/serverCzyta first-party cookie → seeduje initialAuth dla providera
getStorefrontClient@doswiftly/storefront-sdk/react/serverServer-side GraphQL client (timeout 10 s, bez scheduler)
readCurrencyCookie@doswiftly/storefront-sdk/react/serverCzyta first-party cookie preferred-currency server-side (SSR)
readCartIdCookie@doswiftly/storefront-sdk/react/serverCzyta first-party cookie cart-id server-side dla SSR checkout/koszyka

Odświeżanie sesji — automatyczne

Odświeżanie tokenu jest w całości zarządzane przez StorefrontProvider autoRefresh — SDK nie udostępnia (ani nie wymaga) osobnej funkcji do ręcznego wywołania. Szczegóły: Odnawianie sesji.

Bramkowanie odczytów konta na zimnym starcie (useAuthReady)

Po twardym odświeżeniu krótkotrwały access cookie może już wygasnąć, podczas gdy długowieczny hint session-expiry wciąż wskazuje zalogowanego klienta. Sesja jest odnawiana natychmiast, ale zapytanie o dane konta wysłane w tym oknie wyprzedza odświeżenie — leci bez tokenu i dostaje odpowiedź gościa, przez co zalogowany klient widzi „wylogowany".

Bramkuj odczyty konta na useAuthReady(), żeby pierwszy odczyt zaczekał na odświeżenie:

import { useAuthReady } from '@doswiftly/storefront-sdk/react';

const authReady = useAuthReady(); // false tylko gdy „zalogowany, ale token jeszcze nie w pamięci"

const { data } = useQuery({
queryKey: ['account'],
queryFn: fetchAccount,
enabled: authReady, // nie pobieraj jako gość w trakcie odświeżania
});

useAuthReady() jest true dla gościa oraz dla zalogowanego klienta z tokenem w pamięci; false wyłącznie w trakcie ustalania tożsamości (useAuthSettling() to odwrotność). Połącz z useAuthHydrated(), gdy chcesz też zaczekać na rehydratację z localStorage.

Hooki szablonu (lokalne w scaffoldzie)

HookImportOpis
useCustomerLogin@/lib/graphql/hooksGraphQL mutation customerLogin (logowanie)
useCustomerLogout@/lib/graphql/hooksGraphQL mutation customerLogout
useCustomerSignup@/lib/graphql/hooksGraphQL mutation customerSignup (rejestracja)
useCustomerRequestPasswordReset@/lib/graphql/hooksGraphQL mutation — wysyłka emaila resetującego
useCustomerResetPassword@/lib/graphql/hooksGraphQL mutation — reset hasła z tokenem

Hooki szablonu są generowane lokalnie przez pnpm codegen i dostępne też jako helpery serwerowe z @/lib/graphql/server.

Typy GraphQL

Typy generowane ze schematu (SDK 15.0+)

Publiczne typy GraphQL eksportowane z @doswiftly/storefront-sdk (Customer, CustomerAccessToken, CustomerCreateInput itd.) są generowane ze schematu GraphQL przez pnpm codegen. Pola nullable i typy enum poniżej odzwierciedlają faktyczny kontrakt schematu.

CustomerAccessToken

interface CustomerAccessToken {
accessToken: string; // token JWT
expiresAt: string; // data wygasniecia (ISO 8601)
}

CustomerCreateInput

interface CustomerCreateInput {
email: string;
password: string;
firstName?: string;
lastName?: string;
phone?: string;
acceptsMarketing?: boolean; // true → stan SUBSCRIBED (single opt-in)
marketingOptInLevel?: MarketingOptInLevel; // enum: SINGLE_OPT_IN | CONFIRMED_OPT_IN | UNKNOWN
}

CustomerLoginInput

interface CustomerLoginInput {
email: string;
password: string;
}

Customer

interface Customer {
id: string;
email: string;
firstName?: string | null;
lastName?: string | null;
displayName: string; // non-nullable
phone?: string | null;
isEmailVerified: boolean;
emailMarketing: EmailMarketingState; // typed enum (poprzednio acceptsMarketing: boolean)
defaultAddress?: MailingAddress | null;
orderCount: string; // UnsignedInt64 — JSON-serializowany jako string
totalSpent: Money; // non-nullable
createdAt: string;
updatedAt: string;
}

Hook useAuth

Hook useAuth to wygodny facade łączący akcje auth w jednym miejscu (kompozycja useLogin + useLogout + useRefreshToken). Zwraca wyłącznie funkcje akcji i flagi stanu operacjistan sesji (customer, isAuthenticated, expiresAt) czytasz ze store przez useAuthStore.

const {
// Funkcje akcji
login, // (email, password) => Promise<LoginResult>
logout, // () => Promise<LogoutResult>
refreshToken, // () => Promise<TokenRefreshResult> — rzadko potrzebne (refresh jest automatyczny)

// Flagi stanu operacji
isLoggingIn, // boolean
isLoggingOut, // boolean
isRefreshingToken, // boolean
isLoading, // boolean — dowolna z powyższych operacji w toku
error, // string | null — ostatni nieoczekiwany błąd (sieć/serwer)
} = useAuth();

Stan sesji pochodzi ze store, nie z useAuth:

import { useAuthStore } from '@doswiftly/storefront-sdk/react';

const customer = useAuthStore((s) => s.customer); // CustomerInfo | null
const isAuthenticated = useAuthStore((s) => s.isAuthenticated); // boolean
Dwie drogi logowania
  • Pełna sesja (zalecane)useBffLogin posta do route BFF POST /api/auth/login. Sadzi komplet first-party cookies (access + refresh + session-expiry), więc automatyczne odświeżanie działa od razu, seeduje store i zwraca { success, userErrors } (symetrycznie do useRegister). Wzorzec w Formularzu logowania (oraz w „Szybkim starcie" wyżej).
  • useLogin / useAuth().login — uruchamia mutację customerLogin i zapisuje token w pamięci store; refresh cookie nie jest sadzony. Użyj, gdy nie potrzebujesz odświeżania w tle albo sam zarządzasz cookie przez callback onSetToken.

Odświeżanie sesji jest automatyczne (autoRefresh w StorefrontProvider) — nie musisz ręcznie wywoływać refreshToken ani żadnej innej funkcji odświeżania. Scheduler odnawia token przed wygaśnięciem, reaguje na 401 i odświeża po wybudzeniu karty. Pełny opis: Odnawianie sesji.

Focused hooki preferowane w nowym kodzie

useAuth to facade nad useLogin/useLogout. W nowym kodzie preferuj focused hooki — lepiej tree-shake'ują i izolują stany:

import { useLogin, useLogout } from '@doswiftly/storefront-sdk/react';

const { login, isLoggingIn } = useLogin();
const { logout } = useLogout();

Przyklady kodu

Formularz logowania

'use client';

import { useState } from 'react';
import { useBffLogin } from '@doswiftly/storefront-sdk/react';
import { useRouter } from 'next/navigation';

export function LoginForm() {
// Logowanie przez route BFF — sadzi komplet first-party cookies
// (access + refresh + session-expiry), więc auto-refresh działa od razu,
// i seeduje store, aby UI od razu pokazało zalogowanego klienta.
const { login, isLoggingIn } = useBffLogin();
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [formError, setFormError] = useState<string | null>(null);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);

const result = await login(email, password);

if (!result.success) {
// Backend zwraca przetłumaczony komunikat (np. błędne dane logowania).
setFormError(result.userErrors[0]?.message ?? 'Logowanie nie powiodło się');
return;
}

router.push('/account');
};

return (
<form onSubmit={handleSubmit} className="max-w-md mx-auto space-y-4">
<h1 className="text-2xl font-bold">Zaloguj sie</h1>

{formError && (
<div className="bg-destructive/10 text-destructive p-3 rounded">
{formError}
</div>
)}

<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full border rounded px-3 py-2"
placeholder="jan@example.com"
/>
</div>

<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Haslo
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full border rounded px-3 py-2"
placeholder="Twoje haslo"
/>
</div>

<button
type="submit"
disabled={isLoggingIn}
className="w-full bg-primary text-white py-3 rounded-lg disabled:opacity-50"
>
{isLoggingIn ? 'Logowanie...' : 'Zaloguj sie'}
</button>

<div className="text-center space-y-2">
<a href="/auth/forgot-password" className="text-sm text-primary hover:underline">
Zapomnialam/Zapomnialem hasla
</a>
<p className="text-sm text-muted-foreground">
Nie masz konta?{' '}
<a href="/auth/register" className="text-primary hover:underline">
Zarejestruj sie
</a>
</p>
</div>
</form>
);
}

Formularz rejestracji

Zalecana ścieżka: useRegister

Hook useRegister (trasa BFF POST /api/auth/signup) tworzy konto i od razu zakłada pełną sesję — bez osobnego logowania po rejestracji, którym poniższy przykład GraphQL musi się ratować. Przy sklepie z ręcznym zatwierdzaniem kont wynik niesie accountStatus: 'PENDING_APPROVAL' + pendingApprovalMessage (przetłumaczony komunikat sklepu — renderuj dosłownie). Kompletny przepływ z danymi firmowymi: przepis Rejestracja B2B, formularze i ceny po zalogowaniu.

'use client';

import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useExecute } from '@/lib/graphql/client';
import { CustomerSignupDocument, type CustomerSignupMutation } from '@/generated/graphql';
import { useAuthStore } from '@doswiftly/storefront-sdk/react';
import { useRouter } from 'next/navigation';

export function RegisterForm() {
const router = useRouter();
const execute = useExecute();
const setAuth = useAuthStore((s) => s.setAuth);

const [form, setForm] = useState({
email: '',
password: '',
firstName: '',
lastName: '',
});
const [formError, setFormError] = useState<string | null>(null);

const registerMutation = useMutation({
mutationFn: async () => {
return execute<CustomerSignupMutation>(
CustomerSignupDocument.toString(),
{
input: {
email: form.email,
password: form.password,
firstName: form.firstName,
lastName: form.lastName,
},
},
);
},
});

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);

try {
const result = await registerMutation.mutateAsync();
const { userErrors } = result.customerSignup;

if (userErrors?.length > 0) {
setFormError(userErrors[0].message);
return;
}

// Konto utworzone — zaloguj przez route BFF, aby założyć pełną sesję
// (komplet first-party cookies + automatyczne odświeżanie), potem przekieruj.
const loginRes = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ email: form.email, password: form.password }),
});

if (loginRes.ok) {
const { accessToken, expiresAt, customer } = await loginRes.json();
setAuth(customer ?? null, accessToken, expiresAt);
router.push('/account');
} else {
// Konto istnieje, ale auto-logowanie się nie powiodło — przejdź do logowania
router.push('/auth/login');
}
} catch {
setFormError('Rejestracja nie powiodła się. Spróbuj ponownie.');
}
};

return (
<form onSubmit={handleSubmit} className="max-w-md mx-auto space-y-4">
<h1 className="text-2xl font-bold">Utworz konto</h1>

{formError && (
<div className="bg-destructive/10 text-destructive p-3 rounded">
{formError}
</div>
)}

<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium mb-1">
Imie
</label>
<input
id="firstName"
type="text"
value={form.firstName}
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium mb-1">
Nazwisko
</label>
<input
id="lastName"
type="text"
value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
className="w-full border rounded px-3 py-2"
/>
</div>
</div>

<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
required
className="w-full border rounded px-3 py-2"
/>
</div>

<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Haslo
</label>
<input
id="password"
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
required
minLength={8}
className="w-full border rounded px-3 py-2"
/>
</div>

<button
type="submit"
disabled={registerMutation.isPending}
className="w-full bg-primary text-white py-3 rounded-lg disabled:opacity-50"
>
{registerMutation.isPending ? 'Rejestracja...' : 'Zarejestruj sie'}
</button>

<p className="text-sm text-center text-muted-foreground">
Masz juz konto?{' '}
<a href="/auth/login" className="text-primary hover:underline">
Zaloguj sie
</a>
</p>
</form>
);
}

Odzyskiwanie hasla

'use client';

import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useExecute } from '@/lib/graphql/client';
import { CustomerRequestPasswordResetDocument, type CustomerRequestPasswordResetMutation } from '@/generated/graphql';

export function ForgotPasswordForm() {
const execute = useExecute();
const [email, setEmail] = useState('');
const [sent, setSent] = useState(false);

const recoverMutation = useMutation({
mutationFn: async () => {
return execute<CustomerRequestPasswordResetMutation>(
CustomerRequestPasswordResetDocument.toString(),
{ email },
);
},
});

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

const result = await recoverMutation.mutateAsync();
const errors = result.customerRequestPasswordReset.userErrors;

if (errors?.length > 0) {
// Nie ujawniaj czy email istnieje -- zawsze pokazuj sukces
console.warn(errors);
}

// Zawsze pokazujemy komunikat o wyslaniu (bezpieczenstwo)
setSent(true);
};

if (sent) {
return (
<div className="max-w-md mx-auto text-center">
<h1 className="text-2xl font-bold mb-4">Sprawdz swoja skrzynke</h1>
<p className="text-muted-foreground">
Jesli konto z podanym adresem email istnieje, wyslalismy link do
resetowania hasla.
</p>
</div>
);
}

return (
<form onSubmit={handleSubmit} className="max-w-md mx-auto space-y-4">
<h1 className="text-2xl font-bold">Odzyskaj haslo</h1>
<p className="text-muted-foreground">
Podaj adres email powiazany z Twoim kontem. Wyslemy Ci link do
resetowania hasla.
</p>

<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full border rounded px-3 py-2"
/>
</div>

<button
type="submit"
disabled={recoverMutation.isPending}
className="w-full bg-primary text-white py-3 rounded-lg disabled:opacity-50"
>
{recoverMutation.isPending ? 'Wysylanie...' : 'Wyslij link'}
</button>

<p className="text-sm text-center">
<a href="/auth/login" className="text-primary hover:underline">
Powrot do logowania
</a>
</p>
</form>
);
}

Reset hasla (z tokenem z emaila)

'use client';

import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useExecute } from '@/lib/graphql/client';
import { CustomerResetPasswordDocument, type CustomerResetPasswordMutation } from '@/generated/graphql';
import { useRouter, useSearchParams } from 'next/navigation';

export function ResetPasswordForm() {
const router = useRouter();
const searchParams = useSearchParams();
const execute = useExecute();

const resetToken = searchParams.get('token') || '';

const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [formError, setFormError] = useState<string | null>(null);

const resetMutation = useMutation({
mutationFn: async () => {
return execute<CustomerResetPasswordMutation>(
CustomerResetPasswordDocument.toString(),
{ token: resetToken, newPassword: password },
);
},
});

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);

if (password !== confirmPassword) {
setFormError('Hasla nie sa identyczne');
return;
}

const result = await resetMutation.mutateAsync();
const { customerAccessToken, userErrors } = result.customerResetPassword;

if (userErrors?.length > 0) {
setFormError(userErrors[0].message);
return;
}

if (customerAccessToken) {
// Po sukcesie resetu hasła — przeładuj stronę lub zaloguj ponownie
// (sesja po reset jest nowa — BFF sadzi cookie przez /api/auth/login)
router.push('/auth/login?reason=password_reset');
}
};

return (
<form onSubmit={handleSubmit} className="max-w-md mx-auto space-y-4">
<h1 className="text-2xl font-bold">Ustaw nowe haslo</h1>

{formError && (
<div className="bg-destructive/10 text-destructive p-3 rounded">
{formError}
</div>
)}

<div>
<label className="block text-sm font-medium mb-1">Nowe haslo</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full border rounded px-3 py-2"
/>
</div>

<div>
<label className="block text-sm font-medium mb-1">Powtorz haslo</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="w-full border rounded px-3 py-2"
/>
</div>

<button
type="submit"
disabled={resetMutation.isPending}
className="w-full bg-primary text-white py-3 rounded-lg disabled:opacity-50"
>
{resetMutation.isPending ? 'Zapisywanie...' : 'Zapisz haslo'}
</button>
</form>
);
}

Weryfikacja adresu e-mail (opcjonalna własna strona)

Każdy mail kontowy (powitalny / weryfikacyjny) zawiera link potwierdzenia adresu. Domyślnie nie musisz nic budować — link prowadzi na wbudowaną stronę potwierdzenia serwowaną pod domeną sklepu i weryfikacja działa od razu.

Jeśli chcesz brandowaną stronę we własnym storefroncie: zbuduj ją hookiem useEmailVerification, a następnie w panelu administracyjnym wyłącz przełącznik Wbudowana strona weryfikacji e-maila (Ustawienia → Sklep → Konto klienta) i podaj ścieżkę swojej strony (np. /account/verify-email). Od tej chwili linki w mailach będą prowadzić na Twoją stronę z surowym tokenem w parametrze ?token=.

'use client';

// app/account/verify-email/page.tsx — strona publiczna (klient może otworzyć
// link na dowolnym urządzeniu, bez zalogowania).
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useEmailVerification } from '@doswiftly/storefront-sdk/react';

export default function VerifyEmailPage() {
const token = useSearchParams().get('token');
const { verifyEmail, resendVerificationEmail, isResending } = useEmailVerification();
const [state, setState] = useState<'working' | 'done' | 'expired' | 'failed'>('working');

useEffect(() => {
if (!token) {
setState('failed');
return;
}
verifyEmail(token).then((result) => {
if (result.success) setState('done'); // idempotentne — odświeżenie strony nadal pokaże sukces
else setState(result.userErrors[0]?.code === 'TOKEN_EXPIRED' ? 'expired' : 'failed');
});
}, [token, verifyEmail]);

if (state === 'working') return <p>Weryfikujemy adres…</p>;
if (state === 'done') return <p>Adres e-mail potwierdzony. Dziękujemy!</p>;
if (state === 'expired')
return (
<button onClick={() => resendVerificationEmail()} disabled={isResending}>
Link wygasł — wyślij nowy
</button>
);
return <p>Nie udało się rozpoznać linku. Otwórz najnowszą wiadomość e-mail.</p>;
}

Obie akcje zwracają { success, userErrors } (bez rzucania wyjątków przy odmowie backendu). Stabilne kody w userErrors[].code: TOKEN_EXPIRED (zaproponuj ponowną wysyłkę), TOKEN_INVALID, TOKEN_USED dla verifyEmail; ALREADY_VERIFIED oraz TOKEN_INVALID (brak sesji) dla resendVerificationEmail. Ponowna wysyłka nie przyjmuje argumentów — adresata wyznacza sesja zalogowanego klienta, więc nie da się nią sondować cudzych adresów. Po udanej weryfikacji odśwież dane klienta (pole isEmailVerified w typie Customer).

Uwaga: ukończony reset hasła (sekcja wyżej) także oznacza adres jako zweryfikowany — kliknięcie linku z maila dowodzi władania skrzynką.

Wzorzec chronionej strony

// app/account/layout.tsx
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';

export default async function AccountLayout({
children,
}: {
children: React.ReactNode;
}) {
const cookieStore = await cookies();
const token = cookieStore.get('customerAccessToken')?.value;

// Przekieruj niezalogowanych do logowania
if (!token) {
redirect('/auth/login?redirect=/account');
}

return <div className="max-w-4xl mx-auto py-8">{children}</div>;
}

Przycisk wylogowania

'use client';

import { useAuth } from '@/hooks/use-auth';

export function LogoutButton() {
const { logout, isLoggingOut } = useAuth();

return (
<button
onClick={() => logout()}
disabled={isLoggingOut}
className="text-sm text-muted-foreground hover:text-foreground"
>
{isLoggingOut ? 'Wylogowywanie...' : 'Wyloguj sie'}
</button>
);
}

Przechowywanie tokenów

Token klienta przechowywany jest w ciasteczkach httpOnly sadzonych przez route handlery BFF (createStorefrontAuthRoute). Storefront-developer nie operuje bezpośrednio na tokenach — zarządza nimi BFF.

CookiehttpOnlyPathOpis
customerAccessTokentak/Access token — Bearer-seed dla SSR i authMiddleware
customerRefreshTokentak/api/authRefresh token — czytany wyłącznie server-side przez BFF
session-expirynie/Czytelny znacznik wygaśnięcia dla schedulera

Nazwa access cookie to AUTH_COOKIE_NAME eksportowane z SDK:

import { AUTH_COOKIE_NAME } from '@doswiftly/storefront-sdk';
// 'customerAccessToken'

Legacy: createSetTokenHandler / createClearTokenHandler

Poprzedni model (sprzed SDK-BFF) używał osobnych route handlerów set-token i clear-token wołanych przez mutacje GraphQL. Te handlery są nadal dostępne dla wstecznej kompatybilności, ale nowe projekty powinny używać createStorefrontAuthRoute (jeden plik obsługuje wszystkie 4 akcje + automatyczną rotację).

// app/api/auth/set-token/route.ts — legacy (akceptowalne dla migracji)
import { createSetTokenHandler, trustedForwardedHostValidator } from '@doswiftly/storefront-sdk';
export const POST = createSetTokenHandler({ isTrustedOrigin: trustedForwardedHostValidator });

Automatyczne wstrzykiwanie w Server Components

W Server Components token jest automatycznie odczytywany z httpOnly cookie customerAccessToken i wstrzykiwany jako nagłówek Authorization: Bearer przez getStorefrontClient() i lokalne helpery szablonu (lib/graphql/server.ts). Zapytania w Server Components automatycznie uwierzytelniają klienta bez dodatkowego kodu.

SDK authMiddleware działa jako lazy getter () => store.getState().accessToken — token pobierany z in-memory store (seedowany przez getInitialAuth) przy każdym zapytaniu, nie przy inicjalizacji middleware.

Odnawianie sesji

Sesja jest odnawiana automatycznie przez StorefrontProvider autoRefresh. Nie musisz pisać własnego schedulera ani obsługiwać 401 ręcznie.

Dwa tryby odnowienia:

  • Proaktywny — scheduler setTimeout(expiresAt − bufor) uruchamia POST /api/auth/refresh zanim access token wygaśnie; po sukcesie reschedule'uje następne odnowienie.
  • Reaktywny — gdy zapytanie (query) zwróci 401, SDK jednorazowo odświeża sesję i ponawia zapytanie (deduped in-flight dla wielu równoległych żądań). Mutacje przy 401 → bail + event session-expired (mutacje nigdy nie są retry'owane — byłoby niebezpieczne).
Zimny start (twarde odświeżenie) nie rotuje sesji na każde wejście

Po twardym odświeżeniu (F5) access token nie jest w pamięci (nigdy nie jest zapisywany — ochrona przed XSS). SDK odzyskuje go wtedy przez idempotentne GET /api/auth/whoami — które nie rotuje refresh tokena i nie podlega limitowi odświeżeń — a po rotujący POST /api/auth/refresh sięga tylko gdy whoami potwierdzi, że access token faktycznie wygasł. Dzięki temu wielokrotne przeładowanie strony nie generuje serii rotacji ani błędu „zbyt wiele odświeżeń sesji" (429). Sam 429 jest też traktowany jako przejściowy — scheduler odczekuje okno limitu i ponawia, nigdy nie wylogowuje (refresh token wciąż żyje).

Brak schedulera w SSR/Edge

Scheduler działa wyłącznie w przeglądarce (useRef-based timer). W Server Components i Route Handlers nie jest uruchamiany — token tam czytany jest "as-is" z cookie. Jeśli token przy renderze SSR jest tuż przed wygaśnięciem, render może przejść jako anon; po hydration client robi refresh i re-fetch.

Scheduler obsługuje też powrót karty z uśpienia: nasłuchuje zdarzenia visibilitychange i po przywróceniu karty natychmiast odświeża token, jeśli wygasł w tle. Nie musisz pisać własnej obsługi — autoRefresh pokrywa odświeżanie proaktywne, reaktywne (po 401) oraz po wybudzeniu karty.

Globalna obsługa wygaśnięcia sesji

Gdy odświeżenie się nie powiedzie (wygasły refresh token lub błąd sieci), SDK emituje event session-expired:

'use client';
import { useSessionExpired } from '@doswiftly/storefront-sdk/react';
import { useRouter } from 'next/navigation';

export function SessionExpiredGuard() {
const router = useRouter();
useSessionExpired(() => router.replace('/auth/login?reason=session_expired'));
return null; // renderless component — montuj w root layout
}

Route handlers za reverse proxy

Jeśli storefront działa za reverse proxy (DoSwiftly hosting, Vercel, własny NGINX), proxy zazwyczaj przepisuje nagłówek Host na swój wewnętrzny hostname. Strict Origin host = Host zwróci wtedy 403 dla każdego logowania, wylogowania i hydracji. Każdy handler przyjmuje opcjonalny isTrustedOrigin predicate:

trustedForwardedHostValidator (rekomendowany)

Pozwala na request, gdy Origin host zgadza się z nagłówkiem X-Forwarded-Host (fallback X-Original-Host). Właściwa konfiguracja dla DoSwiftly hosting i Vercel — oba proxy ustawiają te nagłówki z prawdziwym customer-facing hostname na każdym inbound request.

import { createSetTokenHandler, trustedForwardedHostValidator } from '@doswiftly/storefront-sdk';
export const POST = createSetTokenHandler({ isTrustedOrigin: trustedForwardedHostValidator });

originAllowlistValidator(allowedOrigins)

Statyczna lista dozwolonych originów — gdy hostujesz jeden storefront na kilku hostnames (custom apex + subdomena platformy):

import { createSetTokenHandler, originAllowlistValidator } from '@doswiftly/storefront-sdk';
export const POST = createSetTokenHandler({
isTrustedOrigin: originAllowlistValidator([
'https://shop.example.com',
'https://example-shop.doswiftly.pl',
]),
});

Bez isTrustedOrigin (deployment bez proxy, lokalny pnpm dev) — pomiń opcję; strict Origin host = Host działa, bo Host przychodzi nietknięty.

wskazówka

doswiftly init scaffolduje route handlery z isTrustedOrigin: trustedForwardedHostValidator jako default — out-of-box działa za DoSwiftly hosting i Vercel.

Hydration po hard refresh

Po hard refresh React app dostaje czysty store (access token NIE jest w localStorage). Dzięki getInitialAuth() w root layout server seeduje accessToken i expiresAt już przy pierwszym renderze — bez round-tripu do /api/auth/whoami:

// app/layout.tsx (Server Component — patrz sekcja "Szybki start")
const { isAuthenticated, accessToken, expiresAt } = await getInitialAuth();

<StorefrontProvider
initialIsAuthenticated={isAuthenticated}
initialAccessToken={accessToken} // seed do pamięci (in-memory, nie localStorage)
initialExpiresAt={expiresAt} // dla schedulera proaktywnego odświeżania
...
>

Gdy potrzebujesz profilu klienta (customer) od razu — autoRefresh + scheduler wywołają whoami lub scheduler odświeży token i SDK zaaktualizuje store. Możesz też ręcznie zasubskrybować useAuthStore:

'use client';
import { useAuthStore } from '@doswiftly/storefront-sdk/react';

export function CustomerGreeting() {
const customer = useAuthStore((s) => s.customer);
return customer ? <span>Cześć, {customer.firstName ?? customer.email}</span> : null;
}

Bezpieczeństwo — co SDK robi za Ciebie

WektorMitygacja
XSS kradzież tokenaToken w HttpOnly cookie, niedostępny dla document.cookie ani localStorage
CSRFSameSite=Lax + walidacja Origin w route handlerach
Origin bypassStrict new URL(origin).host === host (nie includes)
Brak nagłówka Host403 (oba Origin i Host wymagane, chyba że isTrustedOrigin)
Malformed JSON400 (walidacja content-type + parsowania)
Pusty token400 (token: '' lub ' ' odrzucone)

Anti-patterns — czego NIE rób

  • Nie przechowuj accessToken w localStorage — XSS może go odczytać. SDK celowo nie persistuje tokena w Zustand.
  • Nie wołaj setAuth(customer, token) bez BFF cookie sync — token w RAM bez httpOnly cookie = brak SSR + flash „Sign In" przy hard refresh.
  • Nie dodawaj nagłówka Authorization w client-side fetch do własnego API — Bearer leci tylko w server→backend hop; browser→/api/* używa cookie automatycznie.
  • Nie pisz własnego schedulera odświeżaniaautoRefresh w StorefrontProvider zarządza tym kompletnie.
  • Nie wywołuj refresh na mutacji po 401 — mutacje po wygaśnięciu sesji kończą się session-expired eventem, nie retry. Podwójne wykonanie mutacji (np. płatność) byłoby niebezpieczne.
  • Nie czytaj customerRefreshToken cookie z JS — jest httpOnly i celowo niedostępny dla JavaScriptu. Refresh tokena jest czytany wyłącznie server-side przez BFF.