Web Authentication Implementation Plan
Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев
Web Authentication Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement frontend authentication for the Next.js 16 web app — route protection via proxy.ts, auth mutations, user state via React Query, and wiring existing form UI.
Architecture: proxy.ts acts as a gate — checks cookie presence, refreshes tokens server-side, enforces role-based routing. Client-side uses React Query (useCurrentUser()) as the single source of auth state, hydrated from SSR. Axios interceptor is patched to not redirect on /auth/me failures.
Tech Stack: Next.js 16 (proxy.ts), React Query 5, axios, TypeScript
Spec: docs/superpowers/specs/2026-03-18-web-auth-design.md
File Map
| Action | File | Responsibility |
|---|---|---|
| Create | apps/web/src/proxy.ts | Route protection, server-side token refresh, role-based redirects |
| Modify | apps/web/src/shared/api/client.ts:65-69 | Skip /login redirect when /auth/me fails |
| Modify | apps/web/src/shared/api/api-types.ts | Add User type |
| Modify | apps/web/src/shared/api/queries/auth.queries.ts | Add useCurrentUser() hook, type authQueries.me() |
| Create | apps/web/src/shared/api/mutations/auth.mutations.ts | useLogin(), useRegister(), useLogout() |
| Modify | apps/web/src/features/auth/ui/LoginForm.tsx:79,133-138 | Wire useLogin() mutation, error/loading states |
| Modify | apps/web/src/features/auth/ui/RegisterForm.tsx | Wire useRegister() mutation on step 2 submit |
| Modify | apps/web/src/widgets/header/ui/Header.tsx | Uncomment auth dropdown, wire useCurrentUser() + useLogout() |
| Modify | apps/web/src/app/me/layout.tsx | Refactor to Server Component + client sidebar, prefetch user |
| Modify | apps/web/src/app/seller/layout.tsx | Refactor to Server Component + client sidebar, prefetch user |
Task 1: Add User type and fix axios interceptor
Files:
-
Modify:
apps/web/src/shared/api/api-types.ts -
Modify:
apps/web/src/shared/api/client.ts:65-69 -
Step 1: Add
Usertype toapi-types.ts
Append to end of file:
export type AccountType = 'BUYER' | 'SELLER' | 'SELLER_STAFF' | 'ADMIN';
export type AccountStatus = 'ACTIVE' | 'SUSPENDED' | 'DELETED';
export interface User {
id: string;
email: string;
phone: string;
type: AccountType;
status: AccountStatus;
createdAt: string;
}
export interface AuthResponse {
user: User;
}
export interface LoginData {
login: string;
password: string;
}
export interface RegisterData {
email: string;
phone: string;
password: string;
type: 'BUYER' | 'SELLER';
}
- Step 2: Fix axios interceptor to skip redirect on
/auth/me
In apps/web/src/shared/api/client.ts, replace lines 65-69:
// Before:
} catch (refreshError) {
processQueue(refreshError);
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
return Promise.reject(toApiError(error));
// After:
} catch (refreshError) {
processQueue(refreshError);
const isAuthMeRequest = originalRequest.url?.includes('/auth/me');
if (typeof window !== 'undefined' && !isAuthMeRequest) {
window.location.href = '/login';
}
return Promise.reject(toApiError(error));
- Step 3: Verify build
Run: cd apps/web && npx next build --no-lint 2>&1 | tail -5
Expected: Build succeeds (types are additive, interceptor change is minimal)
- Step 4: Commit
git add apps/web/src/shared/api/api-types.ts apps/web/src/shared/api/client.ts
git commit -m "feat(web): add User type and fix interceptor redirect for /auth/me"
Task 2: Add useCurrentUser() hook and type auth queries
Files:
-
Modify:
apps/web/src/shared/api/queries/auth.queries.ts -
Step 1: Update
auth.queries.tswith typed query anduseCurrentUserhook
Replace entire file:
import { queryOptions, useQuery } from '@tanstack/react-query';
import { apiClient } from '../client';
import { authKeys } from '../keys/auth.keys';
import type { User } from '../api-types';
export const authQueries = {
me: () =>
queryOptions({
queryKey: authKeys.me(),
queryFn: () => apiClient.get<User>('/auth/me').then((r) => r.data),
retry: false,
staleTime: 5 * 60 * 1000,
}),
};
export function useCurrentUser() {
const { data, isLoading } = useQuery(authQueries.me());
return {
user: data ?? null,
isLoading,
isAuthenticated: !!data,
};
}
- Step 2: Verify build
Run: cd apps/web && npx next build --no-lint 2>&1 | tail -5
Expected: Build succeeds
- Step 3: Commit
git add apps/web/src/shared/api/queries/auth.queries.ts
git commit -m "feat(web): add useCurrentUser hook with typed auth query"
Task 3: Create auth mutations
Files:
-
Create:
apps/web/src/shared/api/mutations/auth.mutations.ts -
Step 1: Create
auth.mutations.ts
Follow the pattern from leads.mutations.ts:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter, useSearchParams } from 'next/navigation';
import { apiClient } from '../client';
import { authKeys } from '../keys/auth.keys';
import type { AuthResponse, LoginData, RegisterData } from '../api-types';
const DEFAULT_ROUTES: Record<string, string> = {
BUYER: '/me',
SELLER: '/seller',
SELLER_STAFF: '/seller',
ADMIN: '/admin',
};
function getDefaultRoute(userType: string): string {
return DEFAULT_ROUTES[userType] || '/me';
}
function isValidCallbackUrl(url: string | null): url is string {
return !!url && url.startsWith('/') && !url.startsWith('//');
}
export function useLogin() {
const queryClient = useQueryClient();
const router = useRouter();
const searchParams = useSearchParams();
return useMutation({
mutationFn: (data: LoginData) =>
apiClient.post<AuthResponse>('/auth/login', data).then((r) => r.data),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: authKeys.all() });
const callbackUrl = searchParams.get('callbackUrl');
router.push(
isValidCallbackUrl(callbackUrl)
? callbackUrl
: getDefaultRoute(data.user.type),
);
},
});
}
export function useRegister() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: RegisterData) =>
apiClient.post<AuthResponse>('/auth/register', data).then((r) => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: authKeys.all() });
},
});
}
export function useLogout() {
const queryClient = useQueryClient();
const router = useRouter();
return useMutation({
mutationFn: () => apiClient.post('/auth/logout').then((r) => r.data),
onSuccess: () => {
queryClient.clear();
router.push('/login');
},
});
}
- Step 2: Verify build
Run: cd apps/web && npx next build --no-lint 2>&1 | tail -5
Expected: Build succeeds
- Step 3: Commit
git add apps/web/src/shared/api/mutations/auth.mutations.ts
git commit -m "feat(web): add auth mutations (login, register, logout)"
Task 4: Create proxy.ts for route protection
Files:
-
Create:
apps/web/src/proxy.ts -
Step 1: Create
proxy.ts
import { NextRequest, NextResponse } from 'next/server';
const API_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'http://localhost:5001/api/v1';
const PUBLIC_PATHS = ['/', '/login', '/register', '/catalog'];
const AUTH_PAGES = ['/login', '/register'];
const PROTECTED_PREFIXES = ['/me', '/seller', '/admin'];
const DEFAULT_ROUTES: Record<string, string> = {
BUYER: '/me',
SELLER: '/seller',
SELLER_STAFF: '/seller',
ADMIN: '/admin',
};
const ROLE_ACCESS: Record<string, string[]> = {
BUYER: ['/me'],
SELLER: ['/me', '/seller'],
SELLER_STAFF: ['/me', '/seller'],
ADMIN: ['/me', '/seller', '/admin'],
};
function decodeJwtPayload(token: string): { sub: string; type: string } | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
return payload;
} catch {
return null;
}
}
function isProtectedPath(pathname: string): boolean {
return PROTECTED_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(prefix + '/'),
);
}
function isAuthPage(pathname: string): boolean {
return AUTH_PAGES.includes(pathname);
}
function hasRoleAccess(userType: string, pathname: string): boolean {
const allowedPrefixes = ROLE_ACCESS[userType];
if (!allowedPrefixes) return false;
return allowedPrefixes.some(
(prefix) => pathname === prefix || pathname.startsWith(prefix + '/'),
);
}
async function tryRefreshToken(
refreshToken: string,
): Promise<{ cookies: string[] } | null> {
try {
const res = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: `qadam_rt=${refreshToken}`,
},
body: '{}',
});
if (!res.ok) return null;
const setCookies = res.headers.getSetCookie();
if (!setCookies.length) return null;
return { cookies: setCookies };
} catch {
return null;
}
}
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const accessToken = request.cookies.get('qadam_at')?.value;
const refreshToken = request.cookies.get('qadam_rt')?.value;
// Skip non-page requests (assets, API, etc.)
if (
pathname.startsWith('/api/') ||
pathname.startsWith('/_next/') ||
pathname.includes('.')
) {
return NextResponse.next();
}
// Auth pages: redirect authenticated users to their dashboard
if (isAuthPage(pathname) && accessToken) {
const payload = decodeJwtPayload(accessToken);
const defaultRoute = payload
? DEFAULT_ROUTES[payload.type] || '/me'
: '/me';
return NextResponse.redirect(new URL(defaultRoute, request.url));
}
// Public paths: no auth check needed
if (!isProtectedPath(pathname)) {
return NextResponse.next();
}
// Protected path: need authentication
let currentAccessToken = accessToken;
// No access token — try refresh
if (!currentAccessToken && refreshToken) {
const result = await tryRefreshToken(refreshToken);
if (result) {
// Extract new access token from Set-Cookie headers
const response = NextResponse.next();
for (const cookie of result.cookies) {
response.headers.append('Set-Cookie', cookie);
// Parse access token from cookie for role check
const match = cookie.match(/qadam_at=([^;]+)/);
if (match) {
currentAccessToken = match[1];
}
}
// Check role access with refreshed token
if (currentAccessToken) {
const payload = decodeJwtPayload(currentAccessToken);
if (payload && !hasRoleAccess(payload.type, pathname)) {
const redirectUrl = DEFAULT_ROUTES[payload.type] || '/me';
const redirectResponse = NextResponse.redirect(
new URL(redirectUrl, request.url),
);
for (const cookie of result.cookies) {
redirectResponse.headers.append('Set-Cookie', cookie);
}
return redirectResponse;
}
}
return response;
}
}
// No tokens at all — redirect to login
if (!currentAccessToken) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
// Has access token — check role access
const payload = decodeJwtPayload(currentAccessToken);
if (payload && !hasRoleAccess(payload.type, pathname)) {
const redirectUrl = DEFAULT_ROUTES[payload.type] || '/me';
return NextResponse.redirect(new URL(redirectUrl, request.url));
}
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Match all request paths except:
* - _next/static (static files)
* - _next/image (image optimization)
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
*/
'/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
],
};
- Step 2: Verify build
Run: cd apps/web && npx next build --no-lint 2>&1 | tail -10
Expected: Build succeeds. Proxy is detected by Next.js 16.
- Step 3: Commit
git add apps/web/src/proxy.ts
git commit -m "feat(web): add proxy.ts for route protection and server-side token refresh"
Task 5: Wire LoginForm to useLogin() mutation
Files:
- Modify:
apps/web/src/features/auth/ui/LoginForm.tsx - Modify:
apps/web/src/app/(auth)/login/page.tsx(if<LoginForm>is not wrapped in<Suspense>— required becauseuseLogin()callsuseSearchParams())
Note: useSearchParams() requires the component to be inside a <Suspense> boundary. Check apps/web/src/app/(auth)/login/page.tsx — if <LoginForm> is not already wrapped in <Suspense>, add it.
- Step 1: Add mutation, error state, and loading state
Add imports at top of file (after existing imports):
import { useLogin } from '@/shared/api/mutations/auth.mutations';
Add after the existing useState declarations (after line 28):
const login = useLogin();
- Step 2: Replace form onSubmit
Replace line 79:
// Before:
<form onSubmit={(e) => e.preventDefault()} className="space-y-4">
// After:
<form
onSubmit={(e) => {
e.preventDefault();
const loginValue = tab === 'phone' ? phone : email;
if (!loginValue || !password) return;
login.mutate({ login: loginValue, password });
}}
className="space-y-4"
>
- Step 3: Add error display and loading state to submit button
Replace the submit button (lines 133-138):
// Before:
<button
type="submit"
className="w-full py-3.5 bg-[#1DB57A] text-white text-sm font-bold rounded-xl hover:bg-[#18a36e] transition-colors"
>
Войти
</button>
// After:
{login.error && (
<div className="rounded-xl bg-red-50 px-4 py-3 text-sm text-red-600">
{'errorCode' in login.error && login.error.errorCode === 'INVALID_CREDENTIALS'
? 'Неверный логин или пароль'
: 'Произошла ошибка. Попробуйте ещё раз'}
</div>
)}
<button
type="submit"
disabled={login.isPending}
className="w-full py-3.5 bg-[#1DB57A] text-white text-sm font-bold rounded-xl hover:bg-[#18a36e] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{login.isPending ? 'Вход...' : 'Войти'}
</button>
- Step 4: Verify build
Run: cd apps/web && npx next build --no-lint 2>&1 | tail -5
Expected: Build succeeds
- Step 5: Commit
git add apps/web/src/features/auth/ui/LoginForm.tsx
git commit -m "feat(web): wire LoginForm to useLogin mutation"
Task 6: Wire RegisterForm to useRegister() mutation
Files:
-
Modify:
apps/web/src/features/auth/ui/RegisterForm.tsx -
Step 1: Add mutation import and hook
Add import after existing imports:
import { useRegister } from '@/shared/api/mutations/auth.mutations';
Add after existing useState declarations (inside RegisterForm function):
const register = useRegister();
- Step 2: Replace step 2 form onSubmit (line 132)
Replace line 132:
// Before:
<form onSubmit={(e) => { e.preventDefault(); setStep(3); }} className="space-y-4">
// After:
<form
onSubmit={(e) => {
e.preventDefault();
const accountType = role === 'buyer' ? 'BUYER' : 'SELLER';
register.mutate(
{ email, phone, password, type: accountType },
{ onSuccess: () => setStep(3) },
);
}}
className="space-y-4"
>
Note: useRegister() mutation only invalidates queries. The form controls navigation (step 3) via local onSuccess callback passed to mutate().
- Step 3: Add error display and loading state to step 2 submit button (lines 201-206)
Replace:
// Before:
<button
type="submit"
className="w-full py-3.5 bg-[#1DB57A] text-white text-sm font-bold rounded-xl hover:bg-[#18a36e] transition-colors"
>
Создать аккаунт
</button>
// After:
{'errorCode' in (register.error ?? {}) && (register.error as any).errorCode === 'DUPLICATE'
? (
<div className="rounded-xl bg-red-50 px-4 py-3 text-sm text-red-600">
Аккаунт с таким email или телефоном уже существует
</div>
)
: register.error && (
<div className="rounded-xl bg-red-50 px-4 py-3 text-sm text-red-600">
Произошла ошибка. Попробуйте ещё раз
</div>
)
}
<button
type="submit"
disabled={register.isPending}
className="w-full py-3.5 bg-[#1DB57A] text-white text-sm font-bold rounded-xl hover:bg-[#18a36e] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{register.isPending ? 'Создание...' : 'Создать аккаунт'}
</button>
- Step 4: Verify build
Run: cd apps/web && npx next build --no-lint 2>&1 | tail -5
Expected: Build succeeds
- Step 5: Commit
git add apps/web/src/features/auth/ui/RegisterForm.tsx
git commit -m "feat(web): wire RegisterForm to useRegister mutation"
Task 7: Wire Header auth state
Files:
-
Modify:
apps/web/src/widgets/header/ui/Header.tsx -
Step 1: Add imports and hook
Replace imports section and add hooks:
'use client';
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { useTransition } from 'react';
import { LogIn, LogOut, User, ChevronDown, LayoutDashboard, Settings } from 'lucide-react';
import { useLocale } from 'next-intl';
import { useRouter } from 'next/navigation';
import { cn } from '@/shared/lib/utils';
import { setLocale } from '@/shared/i18n/actions';
import { useCurrentUser } from '@/shared/api/queries/auth.queries';
import { useLogout } from '@/shared/api/mutations/auth.mutations';
- Step 2: Add role maps and hooks inside component
Uncomment and update the role/cabinet maps. Add hooks inside Header():
const ROLE_LABELS: Record<string, string> = {
BUYER: 'Покупатель',
SELLER: 'Продавец',
SELLER_STAFF: 'Сотрудник',
ADMIN: 'Админ',
};
const CABINET_ROUTES: Record<string, string> = {
BUYER: '/me',
SELLER: '/seller',
SELLER_STAFF: '/seller',
ADMIN: '/admin',
};
Inside the Header function, add:
const { user, isAuthenticated } = useCurrentUser();
const logout = useLogout();
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const cabinetRoute = user ? CABINET_ROUTES[user.type] || '/me' : '/me';
- Step 3: Replace auth buttons section
Replace lines 82-162 (the static login/register buttons) with the conditional auth UI:
<div className="flex items-center gap-2">
{isAuthenticated && user ? (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex items-center gap-2 px-3 py-2 bg-white rounded-xl cursor-pointer"
>
<div className="w-7 h-7 rounded-full bg-[#1DB57A] flex items-center justify-center">
<User className="w-3.5 h-3.5 text-white" />
</div>
<span className="hidden md:block text-sm font-medium text-gray-700">
{ROLE_LABELS[user.type] || user.type}
</span>
<ChevronDown className="w-3.5 h-3.5 text-gray-400" />
</button>
{dropdownOpen && (
<div className="absolute right-0 top-full mt-1.5 w-48 bg-white rounded-xl border border-gray-100 shadow-lg py-1.5 z-50">
<Link
href={cabinetRoute}
onClick={() => setDropdownOpen(false)}
className="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<LayoutDashboard className="w-4 h-4 text-gray-400" />
Личный кабинет
</Link>
<Link
href={`${cabinetRoute}/profile`}
onClick={() => setDropdownOpen(false)}
className="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<Settings className="w-4 h-4 text-gray-400" />
Настройки
</Link>
<div className="h-px bg-gray-100 mx-3 my-1" />
<button
onClick={() => {
logout.mutate();
setDropdownOpen(false);
}}
className="flex items-center gap-2.5 px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors w-full text-left cursor-pointer"
>
<LogOut className="w-4 h-4" />
Выйти
</button>
</div>
)}
</div>
) : (
<>
<Link
href="/login"
className="hidden md:flex items-center gap-1.5 px-3 py-2 bg-white rounded-xl text-sm font-medium text-black"
>
<LogIn className="w-4 h-4" />
Войти
</Link>
<Link
href="/register"
className="flex items-center gap-1.5 px-3 py-2 bg-black text-white rounded-xl text-sm font-semibold hover:bg-black/90 transition-colors"
>
<User className="w-4 h-4" />
<span className="hidden md:inline">Регистрация</span>
</Link>
</>
)}
</div>
- Step 4: Verify build
Run: cd apps/web && npx next build --no-lint 2>&1 | tail -5
Expected: Build succeeds
- Step 5: Commit
git add apps/web/src/widgets/header/ui/Header.tsx
git commit -m "feat(web): wire Header auth state with useCurrentUser and useLogout"
Task 8: Refactor protected layouts to Server Components with SSR prefetch
Files:
-
Modify:
apps/web/src/app/me/layout.tsx -
Modify:
apps/web/src/app/seller/layout.tsx -
Step 1: Refactor
/me/layout.tsx
Replace entire file. Extract sidebar navigation to a client component inline, make layout a Server Component:
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '@/shared/lib/get-query-client';
import { serverFetch } from '@/shared/api/server';
import { authKeys } from '@/shared/api/keys/auth.keys';
import { Header } from '@/widgets/header';
import type { User } from '@/shared/api/api-types';
import { MeSidebar } from './MeSidebar';
export default async function MeLayout({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
await queryClient.prefetchQuery({
queryKey: authKeys.me(),
queryFn: () => serverFetch<User>('/auth/me'),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<div className="min-h-screen bg-[#F9FAFB]">
<Header />
<div className="mx-auto max-w-7xl px-4 py-6 md:py-8">
<div className="flex flex-col gap-6 md:flex-row">
<MeSidebar />
<div className="min-w-0 flex-1">{children}</div>
</div>
</div>
</div>
</HydrationBoundary>
);
}
- Step 2: Create
MeSidebarclient component
Create apps/web/src/app/me/MeSidebar.tsx:
'use client';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { LayoutDashboard, User, FileText, Star } from 'lucide-react';
const NAV_ITEMS = [
{ href: '/me', label: 'Обзор', icon: LayoutDashboard, exact: true },
{ href: '/me/profile', label: 'Профиль', icon: User },
{ href: '/me/leads', label: 'Мои заявки', icon: FileText },
{ href: '/me/reviews', label: 'Мои отзывы', icon: Star },
];
export function MeSidebar() {
const pathname = usePathname();
return (
<aside className="w-full shrink-0 md:w-56">
<nav className="rounded-2xl border border-gray-100 bg-white p-2 md:sticky md:top-20">
{NAV_ITEMS.map((item) => {
const isActive = item.exact
? pathname === item.href
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
isActive
? 'bg-[#E8F5E9] text-[#1DB57A]'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</nav>
</aside>
);
}
- Step 3: Refactor
/seller/layout.tsx
Same pattern. Replace entire file:
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '@/shared/lib/get-query-client';
import { serverFetch } from '@/shared/api/server';
import { authKeys } from '@/shared/api/keys/auth.keys';
import { Header } from '@/widgets/header';
import type { User } from '@/shared/api/api-types';
import { SellerSidebar } from './SellerSidebar';
export default async function SellerLayout({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
await queryClient.prefetchQuery({
queryKey: authKeys.me(),
queryFn: () => serverFetch<User>('/auth/me'),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<div className="min-h-screen bg-[#F9FAFB]">
<Header />
<div className="mx-auto max-w-[1280px] px-4 py-6 md:py-8">
<div className="flex flex-col gap-6 md:flex-row">
<SellerSidebar />
<div className="min-w-0 flex-1">{children}</div>
</div>
</div>
</div>
</HydrationBoundary>
);
}
- Step 4: Create
SellerSidebarclient component
Create apps/web/src/app/seller/SellerSidebar.tsx:
'use client';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { LayoutDashboard, User, BookOpen, FileText, Users } from 'lucide-react';
const NAV_ITEMS = [
{ href: '/seller', label: 'Обзор', icon: LayoutDashboard, exact: true },
{ href: '/seller/profile', label: 'О школе', icon: User },
{ href: '/seller/items', label: 'Мои курсы', icon: BookOpen },
{ href: '/seller/leads', label: 'Заявки', icon: FileText },
{ href: '/seller/staff', label: 'Сотрудники', icon: Users },
];
export function SellerSidebar() {
const pathname = usePathname();
return (
<aside className="w-full shrink-0 md:w-56">
<nav className="rounded-2xl border border-gray-100 bg-white p-2 md:sticky md:top-20">
{NAV_ITEMS.map((item) => {
const isActive = item.exact
? pathname === item.href
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
isActive
? 'bg-[#E8F5E9] text-[#1DB57A]'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</nav>
</aside>
);
}
- Step 5: Verify build
Run: cd apps/web && npx next build --no-lint 2>&1 | tail -10
Expected: Build succeeds
- Step 6: Commit
git add apps/web/src/app/me/layout.tsx apps/web/src/app/me/MeSidebar.tsx apps/web/src/app/seller/layout.tsx apps/web/src/app/seller/SellerSidebar.tsx
git commit -m "feat(web): refactor me/seller layouts to Server Components with SSR user prefetch"
Task 9: Manual integration test
- Step 1: Start API and web dev servers
Run in separate terminals:
cd apps/api && pnpm dev
cd apps/web && pnpm dev
- Step 2: Test unauthenticated flow
- Open
http://localhost:5002/me→ should redirect to/login?callbackUrl=/me - Open
http://localhost:5002/seller→ should redirect to/login?callbackUrl=/seller - Open
http://localhost:5002/→ should show page normally with "Войти" / "Регистрация" buttons
- Step 3: Test login flow
- On
/login, enter valid credentials, click "Войти" - Should redirect to callbackUrl or default dashboard by role
- Header should show user dropdown with role label
- Refresh page — no flash, SSR provides user data
- Step 4: Test role-based access
- As BUYER, visit
/seller→ should redirect to/me - As BUYER, visit
/admin→ should redirect to/me
- Step 5: Test logout
- Click "Выйти" in header dropdown
- Should redirect to
/login - Visit
/me→ should redirect to/login
- Step 6: Test registration
- Go to
/register, fill step 1 (role) and step 2 (account data) - Click "Далее" — should create account and move to step 3
- Duplicate email/phone should show error message
- Step 7: Test token refresh in proxy
- Log in, then wait 15+ minutes (or manually delete
qadam_atcookie in devtools) - Refresh a protected page
- Should NOT redirect to login — proxy refreshes token server-side
- Page loads with user data, no flash