Qadam Roadmap
проектplans/archived/2026-03-28-integrated/2026-03-18-web-auth.md

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

ActionFileResponsibility
Createapps/web/src/proxy.tsRoute protection, server-side token refresh, role-based redirects
Modifyapps/web/src/shared/api/client.ts:65-69Skip /login redirect when /auth/me fails
Modifyapps/web/src/shared/api/api-types.tsAdd User type
Modifyapps/web/src/shared/api/queries/auth.queries.tsAdd useCurrentUser() hook, type authQueries.me()
Createapps/web/src/shared/api/mutations/auth.mutations.tsuseLogin(), useRegister(), useLogout()
Modifyapps/web/src/features/auth/ui/LoginForm.tsx:79,133-138Wire useLogin() mutation, error/loading states
Modifyapps/web/src/features/auth/ui/RegisterForm.tsxWire useRegister() mutation on step 2 submit
Modifyapps/web/src/widgets/header/ui/Header.tsxUncomment auth dropdown, wire useCurrentUser() + useLogout()
Modifyapps/web/src/app/me/layout.tsxRefactor to Server Component + client sidebar, prefetch user
Modifyapps/web/src/app/seller/layout.tsxRefactor 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 User type to api-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.ts with typed query and useCurrentUser hook

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 because useLogin() calls useSearchParams())

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 MeSidebar client 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 SellerSidebar client 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
  1. Open http://localhost:5002/me → should redirect to /login?callbackUrl=/me
  2. Open http://localhost:5002/seller → should redirect to /login?callbackUrl=/seller
  3. Open http://localhost:5002/ → should show page normally with "Войти" / "Регистрация" buttons
  • Step 3: Test login flow
  1. On /login, enter valid credentials, click "Войти"
  2. Should redirect to callbackUrl or default dashboard by role
  3. Header should show user dropdown with role label
  4. Refresh page — no flash, SSR provides user data
  • Step 4: Test role-based access
  1. As BUYER, visit /seller → should redirect to /me
  2. As BUYER, visit /admin → should redirect to /me
  • Step 5: Test logout
  1. Click "Выйти" in header dropdown
  2. Should redirect to /login
  3. Visit /me → should redirect to /login
  • Step 6: Test registration
  1. Go to /register, fill step 1 (role) and step 2 (account data)
  2. Click "Далее" — should create account and move to step 3
  3. Duplicate email/phone should show error message
  • Step 7: Test token refresh in proxy
  1. Log in, then wait 15+ minutes (or manually delete qadam_at cookie in devtools)
  2. Refresh a protected page
  3. Should NOT redirect to login — proxy refreshes token server-side
  4. Page loads with user data, no flash