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.
- Jeden plik route —
createStorefrontAuthRoutegeneruje 4 handlery:login/refresh/logout/whoami - First-party cookie — BFF sadzi
customerAccessToken(Path=/) icustomerRefreshToken(Path=/api/auth) na domenie sklepu - Refresh token nigdy w JS — czytany wyłącznie server-side przez BFF i wymieniany S2S z backendem
- Automatyczne odświeżanie —
<StorefrontProvider autoRefresh>uruchamia scheduler przed wygaśnięciem access tokena - Reaktywna obsługa 401 — zapytania (queries) → silent refresh + retry; mutacje → bail + event
session-expired - Trwała sesja domyślnie — provider bez propsów
initial*sam odtwarza sesję po twardym refreshu z czytelnego cookiesession-expiry(client-side, layout zostaje statyczny); opcjonalny seed serwerowygetInitialAuth()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 renderowanieSeed 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: truegdycustomerAccessTokenlubsession-expirycookie 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 readablesession-expirycookie (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ł serwerowy | Prop | Co dostaje SDK | Round-trip /api/auth/whoami |
|---|---|---|---|
| Wiem czy klient jest zalogowany (cookie obecne, nie znam wartości) | initialIsAuthenticated: boolean | Boolean flag — eliminuje flash „Sign In" w UI gating | Wymagany dla pobrania customer profile + tokenu |
| Mam raw JWT po stronie serwera (cookie value, SSO param, env seed) | initialAccessToken: string | null | Wstrzyknię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)
| Hook | Import | Opis |
|---|---|---|
useAuth | @doswiftly/storefront-sdk/react | Facade akcji: login, logout, refreshToken + flagi stanu operacji (isLoggingIn/isLoggingOut/isRefreshingToken/isLoading/error) |
useBffLogin | @doswiftly/storefront-sdk/react | Focused: 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/react | Focused: 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/react | Focused: 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/react | Focused: wylogowanie (mutacja customerLogout) + wyczyszczenie danych klienta z koszyka i ze store |
useSessionExpired | @doswiftly/storefront-sdk/react | Subskrypcja eventu session-expired |
useAuthStore | @doswiftly/storefront-sdk/react | Stan sesji: { customer, isAuthenticated, expiresAt } |
Helpery serwera
| Funkcja | Import | Opis |
|---|---|---|
createStorefrontAuthRoute | @doswiftly/storefront-sdk/react/server | Generator 4 route handlerów BFF (login/refresh/logout/whoami) |
getInitialAuth | @doswiftly/storefront-sdk/react/server | Czyta first-party cookie → seeduje initialAuth dla providera |
getStorefrontClient | @doswiftly/storefront-sdk/react/server | Server-side GraphQL client (timeout 10 s, bez scheduler) |
readCurrencyCookie | @doswiftly/storefront-sdk/react/server | Czyta first-party cookie preferred-currency server-side (SSR) |
readCartIdCookie | @doswiftly/storefront-sdk/react/server | Czyta 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)
| Hook | Import | Opis |
|---|---|---|
useCustomerLogin | @/lib/graphql/hooks | GraphQL mutation customerLogin (logowanie) |
useCustomerLogout | @/lib/graphql/hooks | GraphQL mutation customerLogout |
useCustomerSignup | @/lib/graphql/hooks | GraphQL mutation customerSignup (rejestracja) |
useCustomerRequestPasswordReset | @/lib/graphql/hooks | GraphQL mutation — wysyłka emaila resetującego |
useCustomerResetPassword | @/lib/graphql/hooks | GraphQL 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
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 operacji — stan 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
- Pełna sesja (zalecane) —
useBffLoginposta do route BFFPOST /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 douseRegister). Wzorzec w Formularzu logowania (oraz w „Szybkim starcie" wyżej). useLogin/useAuth().login— uruchamia mutacjęcustomerLogini 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 callbackonSetToken.
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.
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
useRegisterHook 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
Cookie (first-party na domenie storefrontu)
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.
| Cookie | httpOnly | Path | Opis |
|---|---|---|---|
customerAccessToken | tak | / | Access token — Bearer-seed dla SSR i authMiddleware |
customerRefreshToken | tak | /api/auth | Refresh token — czytany wyłącznie server-side przez BFF |
session-expiry | nie | / | 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)uruchamiaPOST /api/auth/refreshzanim 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).
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).
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.
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
| Wektor | Mitygacja |
|---|---|
| XSS kradzież tokena | Token w HttpOnly cookie, niedostępny dla document.cookie ani localStorage |
| CSRF | SameSite=Lax + walidacja Origin w route handlerach |
| Origin bypass | Strict new URL(origin).host === host (nie includes) |
Brak nagłówka Host | 403 (oba Origin i Host wymagane, chyba że isTrustedOrigin) |
| Malformed JSON | 400 (walidacja content-type + parsowania) |
| Pusty token | 400 (token: '' lub ' ' odrzucone) |
Anti-patterns — czego NIE rób
- ❌ Nie przechowuj
accessTokenwlocalStorage— 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
Authorizationw 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żania —
autoRefreshwStorefrontProviderzarządza tym kompletnie. - ❌ Nie wywołuj refresh na mutacji po 401 — mutacje po wygaśnięciu sesji kończą się
session-expiredeventem, nie retry. Podwójne wykonanie mutacji (np. płatność) byłoby niebezpieczne. - ❌ Nie czytaj
customerRefreshTokencookie z JS — jesthttpOnlyi celowo niedostępny dla JavaScriptu. Refresh tokena jest czytany wyłącznie server-side przez BFF.