Skip to content

Auth Feature Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Implement full authentication flow — login, registration, email verification, password reset — with middleware, plugin, and Nuxt UI forms.

Architecture: Session-based auth with PHP backend. CSRF tokens via cookie. Auth state in Pinia store hydrated by plugin on app init. Middleware guards routes. Pages use Nuxt UI forms with Zod validation. Errors mapped via useApiError composable.

Tech Stack: Nuxt 4, Vue 3.5, Pinia, Nuxt UI v3, Zod, @nuxtjs/i18n


Task 1: Update User Entity Schema

Files:

  • Modify: app/entities/user/model/user.schema.ts

Step 1: Update user schema to match backend API

Replace the entire file content:

ts
import { z } from 'zod'

export const accountTypeSchema = z.enum(['personal', 'business'])

export const userSchema = z.object({
  id: z.number().int().positive(),
  email: z.string().email(),
  display_name: z.string(),
  phone: z.string().nullable(),
  account_type: accountTypeSchema,
  email_verified: z.boolean(),
  avatar_url: z.string().nullable(),
  city_id: z.number().nullable(),
  district_id: z.number().nullable(),
  metro_station_id: z.number().nullable(),
  rating: z.string(),
  reviews_count: z.number().int(),
  products_count: z.number().int(),
  is_active: z.boolean(),
  is_admin: z.boolean(),
  created_at: z.string(),
})

export const businessProfileSchema = z.object({
  id: z.number().int().positive(),
  company_name: z.string(),
  inn: z.string().nullable(),
  address: z.string().nullable(),
  website: z.string().nullable(),
  working_hours: z.string().nullable(),
  is_verified: z.boolean(),
})

export type User = z.infer<typeof userSchema>
export type AccountType = z.infer<typeof accountTypeSchema>
export type BusinessProfile = z.infer<typeof businessProfileSchema>

Key changes from old schema:

  • email_verified_at: z.string().nullable()email_verified: z.boolean()
  • phone_verified_at removed (not in backend API response)
  • display_name changed from nullable() to required string()
  • Added: city_id, district_id, metro_station_id, is_active, is_admin
  • rating changed from z.number() to z.string() (backend returns "0.00")

Step 2: Verify lint passes

Run: npm run lint -- --no-fix app/entities/user/model/user.schema.ts Expected: No errors

Step 3: Commit

bash
git add app/entities/user/model/user.schema.ts
git commit -m "fix(entities): update user schema to match backend API response"

Task 2: Update Auth Store

Files:

  • Modify: app/stores/auth.ts

Step 1: Rewrite auth store with actions

Replace entire file:

ts
import { defineStore } from 'pinia'

import type { User } from '~/entities/user/model/user.schema'
import type { ApiItemResponse } from '~/shared/api/types'

import { useApiClient } from '~/shared/api/client'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null as User | null,
    loading: false,
  }),

  getters: {
    isAuthenticated: (state) => state.user !== null,
    isEmailVerified: (state) => state.user?.email_verified ?? false,
    isAdmin: (state) => state.user?.is_admin ?? false,
  },

  actions: {
    setUser(user: User | null) {
      this.user = user
    },

    clearUser() {
      this.user = null
    },

    async fetchUser() {
      const api = useApiClient()
      try {
        const response = await api.get<ApiItemResponse<User>>('/auth/me')
        this.user = response.data
      }
      catch {
        this.user = null
      }
    },

    async login(email: string, password: string) {
      const api = useApiClient()
      const response = await api.post<ApiItemResponse<User>>('/auth/login', { email, password })
      this.user = response.data
    },

    async register(email: string, password: string, displayName: string) {
      const api = useApiClient()
      const response = await api.post<ApiItemResponse<User>>('/auth/register', {
        email,
        password,
        display_name: displayName,
      })
      this.user = response.data
    },

    async logout() {
      const api = useApiClient()
      await api.post('/auth/logout')
      this.user = null
    },
  },
})

Note: Store uses useApiClient() directly (not useApi()) — store lives in app layer and doesn't need the redirect-on-401 wrapper (it handles auth itself).

Step 2: Verify lint passes

Run: npm run lint -- --no-fix app/stores/auth.ts Expected: No errors

Step 3: Commit

bash
git add app/stores/auth.ts
git commit -m "feat(auth): add login, register, logout, fetchUser actions to auth store"

Task 3: Create Auth Validation Schemas

Files:

  • Create: app/shared/schemas/auth.ts

Step 1: Create Zod schemas

ts
import { z } from 'zod'

const passwordSchema = z
  .string()
  .min(8, 'Минимум 8 символов')
  .max(128, 'Максимум 128 символов')
  .regex(/[A-Z]/, 'Минимум одна заглавная буква')
  .regex(/[a-z]/, 'Минимум одна строчная буква')
  .regex(/[0-9]/, 'Минимум одна цифра')
  .regex(/[^a-zA-Z0-9]/, 'Минимум один спецсимвол')
  .refine(
    (val) => !/(.)\1{2,}/.test(val),
    'Не допускается 3+ одинаковых символа подряд',
  )

export const loginSchema = z.object({
  email: z.string().email('Некорректный email'),
  password: z.string().min(1, 'Введите пароль'),
})

export const registerSchema = z
  .object({
    email: z.string().email('Некорректный email'),
    password: passwordSchema,
    password_confirmation: z.string().min(1, 'Повторите пароль'),
    display_name: z
      .string()
      .min(1, 'Введите имя')
      .max(100, 'Максимум 100 символов'),
  })
  .refine((data) => data.password === data.password_confirmation, {
    message: 'Пароли не совпадают',
    path: ['password_confirmation'],
  })

export const forgotPasswordSchema = z.object({
  email: z.string().email('Некорректный email'),
})

export const resetPasswordSchema = z.object({
  token: z.string().min(1),
  password: passwordSchema,
})

export const verifyEmailSchema = z.object({
  code: z
    .string()
    .length(6, 'Код должен содержать 6 цифр')
    .regex(/^\d+$/, 'Только цифры'),
})

export type LoginForm = z.infer<typeof loginSchema>
export type RegisterForm = z.infer<typeof registerSchema>
export type ForgotPasswordForm = z.infer<typeof forgotPasswordSchema>
export type ResetPasswordForm = z.infer<typeof resetPasswordSchema>
export type VerifyEmailForm = z.infer<typeof verifyEmailSchema>

Step 2: Add shared/schemas to auto-imports in nuxt.config.ts

In nuxt.config.ts, add 'shared/schemas' to the imports.dirs array:

ts
imports: {
  dirs: [
    'shared/ui',
    'shared/api',
    'shared/schemas',  // ← ADD THIS LINE
    'shared/lib',
    'shared/config',
    // ... rest stays the same
  ],
},

Step 3: Verify lint passes

Run: npm run lint -- --no-fix app/shared/schemas/auth.ts Expected: No errors

Step 4: Commit

bash
git add app/shared/schemas/auth.ts nuxt.config.ts
git commit -m "feat(auth): add Zod validation schemas for auth forms"

Task 4: Create useApiError Composable

Files:

  • Create: app/features/auth/composables/useApiError.ts
  • Remove: app/features/auth/api/.gitkeep, app/features/auth/model/.gitkeep, app/features/auth/ui/.gitkeep (replace with actual code)

Step 1: Create composable

ts
import type { ApiError } from '~/shared/api/types'

interface FetchErrorLike {
  response?: { status?: number; headers?: { get(name: string): string | null } }
  data?: ApiError
}

export function useApiError() {
  const globalError = ref<string | null>(null)
  const fieldErrors = ref<Record<string, string>>({})

  function handleError(err: unknown) {
    globalError.value = null
    fieldErrors.value = {}

    const fetchError = err as FetchErrorLike
    const apiError = fetchError.data?.error

    if (!apiError) {
      globalError.value = 'Ошибка соединения. Попробуйте ещё раз.'
      return
    }

    if (fetchError.response?.status === 422 && apiError.details) {
      for (const [field, messages] of Object.entries(apiError.details)) {
        fieldErrors.value[field] = messages[0]
      }
      return
    }

    if (fetchError.response?.status === 429) {
      const retryAfter = fetchError.response.headers?.get('Retry-After')
      const seconds = retryAfter ? parseInt(retryAfter) : 60
      globalError.value = `Слишком много попыток. Попробуйте через ${Math.ceil(seconds / 60)} мин.`
      return
    }

    globalError.value = apiError.message
  }

  function clearErrors() {
    globalError.value = null
    fieldErrors.value = {}
  }

  return { globalError, fieldErrors, handleError, clearErrors }
}

Step 2: Add features/*/composables to auto-imports in nuxt.config.ts

In nuxt.config.ts, add 'features/*/composables' to the imports.dirs array:

ts
imports: {
  dirs: [
    'shared/ui',
    'shared/api',
    'shared/schemas',
    'shared/lib',
    'shared/config',
    'entities/*/ui',
    'entities/*/model',
    'entities/*/api',
    'features/*/ui',
    'features/*/model',
    'features/*/api',
    'features/*/composables',  // ← ADD THIS LINE
    'widgets/*/ui',
  ],
},

Step 3: Clean up .gitkeep files

Delete:

  • app/features/auth/api/.gitkeep
  • app/features/auth/model/.gitkeep
  • app/features/auth/ui/.gitkeep

Step 4: Verify lint passes

Run: npm run lint -- --no-fix app/features/auth/composables/useApiError.ts Expected: No errors

Step 5: Commit

bash
git add app/features/auth/composables/useApiError.ts nuxt.config.ts
git rm app/features/auth/api/.gitkeep app/features/auth/model/.gitkeep app/features/auth/ui/.gitkeep
git commit -m "feat(auth): add useApiError composable for form error handling"

Task 5: Create Auth Middleware

Files:

  • Create: app/middleware/auth.ts
  • Create: app/middleware/guest.ts

Step 1: Create auth middleware

ts
export default defineNuxtRouteMiddleware(async () => {
  const authStore = useAuthStore()

  if (!authStore.isAuthenticated) {
    await authStore.fetchUser()
  }

  if (!authStore.isAuthenticated) {
    return navigateTo('/auth/login')
  }
})

Step 2: Create guest middleware

ts
export default defineNuxtRouteMiddleware(async () => {
  const authStore = useAuthStore()

  if (!authStore.isAuthenticated) {
    await authStore.fetchUser()
  }

  if (authStore.isAuthenticated) {
    return navigateTo('/cabinet')
  }
})

Step 3: Verify lint passes

Run: npm run lint -- --no-fix app/middleware/ Expected: No errors

Step 4: Commit

bash
git add app/middleware/auth.ts app/middleware/guest.ts
git commit -m "feat(auth): add auth and guest route middleware"

Task 6: Create Auth Plugin

Files:

  • Create: app/plugins/auth.ts

Step 1: Create plugin

ts
export default defineNuxtPlugin(async () => {
  const authStore = useAuthStore()

  if (import.meta.server || !authStore.isAuthenticated) {
    await authStore.fetchUser()
  }
})

Step 2: Verify lint passes

Run: npm run lint -- --no-fix app/plugins/auth.ts Expected: No errors

Step 3: Commit

bash
git add app/plugins/auth.ts
git commit -m "feat(auth): add auth plugin to hydrate user on app init"

Task 7: Update i18n Locale

Files:

  • Modify: i18n/locales/ru.json

Step 1: Add auth-related i18n keys

Add/update the auth section:

json
{
  "common": {
    "appName": "Partizap",
    "search": "Найти запчасть",
    "login": "Войти",
    "register": "Регистрация",
    "logout": "Выйти",
    "favorites": "Избранное",
    "addListing": "Продать запчасть",
    "allRegions": "Все регионы",
    "loadMore": "Загрузить ещё",
    "save": "Сохранить",
    "cancel": "Отмена",
    "delete": "Удалить",
    "edit": "Редактировать",
    "back": "Назад",
    "close": "Закрыть",
    "or": "или",
    "comingSoon": "Скоро"
  },
  "auth": {
    "email": "Email",
    "emailPlaceholder": "email@example.com",
    "password": "Пароль",
    "passwordPlaceholder": "Минимум 8 символов",
    "confirmPassword": "Повторите пароль",
    "displayName": "Имя",
    "displayNamePlaceholder": "Как вас зовут",
    "phone": "Мобильный телефон",
    "phonePlaceholder": "+7 (___) ___-__-__",
    "rememberMe": "Запомнить меня",
    "forgotPassword": "Забыли пароль?",
    "noAccount": "Нет аккаунта?",
    "hasAccount": "Уже есть аккаунт?",
    "loginAction": "Войти",
    "registerAction": "Зарегистрироваться",
    "loginViaSms": "Войти по SMS",
    "loginTitle": "Вход",
    "registerTitle": "Регистрация",
    "verifyEmailTitle": "Подтверждение Email",
    "verifyEmailDescription": "Введите 6-значный код, отправленный на",
    "verifyAction": "Подтвердить",
    "resendCode": "Отправить повторно",
    "resendIn": "Отправить через {seconds}с",
    "forgotPasswordTitle": "Восстановление пароля",
    "forgotPasswordAction": "Отправить ссылку",
    "forgotPasswordSent": "Если этот email зарегистрирован, ссылка для сброса отправлена.",
    "resetPasswordTitle": "Новый пароль",
    "resetPasswordAction": "Сбросить пароль",
    "resetPasswordSuccess": "Пароль успешно изменён. Теперь вы можете войти.",
    "backToLogin": "Вернуться ко входу",
    "verificationCode": "Код подтверждения",
    "newPassword": "Новый пароль",
    "passwordHint": "Заглавная, строчная, цифра, спецсимвол"
  },
  "product": {
    "price": "Цена",
    "condition": "Состояние",
    "showPhone": "Показать телефон",
    "addToFavorites": "В избранное",
    "removeFromFavorites": "Убрать из избранного",
    "views": "Просмотры",
    "published": "Опубликовано"
  },
  "catalog": {
    "filters": "Фильтры",
    "sort": "Сортировка",
    "sortByDate": "По дате",
    "sortByPriceAsc": "Сначала дешёвые",
    "sortByPriceDesc": "Сначала дорогие",
    "resetFilters": "Сбросить фильтры",
    "noResults": "Ничего не найдено"
  },
  "ymm": {
    "make": "Марка",
    "model": "Модель",
    "generation": "Поколение",
    "year": "Год",
    "selectMake": "Выберите марку",
    "selectModel": "Выберите модель",
    "selectGeneration": "Выберите поколение"
  }
}

Step 2: Commit

bash
git add i18n/locales/ru.json
git commit -m "feat(i18n): add auth-related Russian locale keys"

Task 8: Update Default Layout (auth-aware header)

Files:

  • Modify: app/layouts/default.vue

Step 1: Make header show user state

html
<script setup lang="ts">
const authStore = useAuthStore()
const { t } = useI18n()

async function logout() {
  await authStore.logout()
  await navigateTo('/auth/login')
}
</script>

<template>
  <div class="min-h-screen flex flex-col">
    <header class="border-b border-gray-200 dark:border-gray-800">
      <div class="container mx-auto px-4 py-3 flex items-center justify-between">
        <NuxtLink to="/">
          <SharedAppLogo />
        </NuxtLink>
        <nav class="flex items-center gap-4">
          <UButton :label="t('common.addListing')" to="/cabinet/products/new" variant="solid" />
          <template v-if="authStore.isAuthenticated">
            <UButton :label="t('common.favorites')" to="/cabinet/favorites" variant="ghost" />
            <UButton variant="ghost" to="/cabinet">
              &#123;&#123; authStore.user?.display_name }}
            </UButton>
            <UButton :label="t('common.logout')" variant="ghost" @click="logout" />
          </template>
          <UButton v-else :label="t('common.login')" to="/auth/login" variant="ghost" />
        </nav>
      </div>
    </header>
    <main class="flex-1">
      <slot />
    </main>
    <footer class="border-t border-gray-200 dark:border-gray-800 py-8">
      <div class="container mx-auto px-4 text-center text-sm text-gray-500">
        &copy; &#123;&#123; new Date().getFullYear() }} Partizap
      </div>
    </footer>
  </div>
</template>

Step 2: Update useApi composable redirect path

In app/composables/useApi.ts, change redirect from /auth to /auth/login:

ts
onUnauthorized: async () => {
  authStore.clear()
  await navigateTo('/auth/login')
},

Also rename authStore.clear()authStore.clearUser() to match new store API.

Step 3: Verify lint passes

Run: npm run lint -- --no-fix app/layouts/default.vue app/composables/useApi.ts Expected: No errors

Step 4: Commit

bash
git add app/layouts/default.vue app/composables/useApi.ts
git commit -m "feat(layout): add auth-aware header with user menu and logout"

Task 9: Create Login Page

Files:

  • Remove: app/pages/auth/index.vue
  • Create: app/pages/auth/login.vue

Step 1: Delete old stub and create login page

Delete app/pages/auth/index.vue.

Create app/pages/auth/login.vue:

html
<script setup lang="ts">
import { loginSchema } from '~/shared/schemas/auth'

definePageMeta({
  middleware: 'guest',
  layout: 'default',
})

useSeoMeta({
  title: 'Вход | Partizap',
  robots: 'noindex, nofollow',
})

const { t } = useI18n()
const authStore = useAuthStore()
const { globalError, handleError, clearErrors } = useApiError()
const loading = ref(false)

const state = reactive({
  email: '',
  password: '',
})

async function onSubmit() {
  clearErrors()
  loading.value = true
  try {
    await authStore.login(state.email, state.password)
    await navigateTo('/cabinet')
  }
  catch (err) {
    handleError(err)
  }
  finally {
    loading.value = false
  }
}
</script>

<template>
  <div class="container mx-auto px-4 py-16 max-w-sm">
    <h1 class="text-2xl font-bold mb-6">
      &#123;&#123; t('auth.loginTitle') }}
    </h1>

    <UAlert
      v-if="globalError"
      color="error"
      :title="globalError"
      class="mb-4"
    />

    <UForm :schema="loginSchema" :state="state" class="space-y-4" @submit="onSubmit">
      <UFormField :label="t('auth.email')" name="email">
        <UInput v-model="state.email" type="email" :placeholder="t('auth.emailPlaceholder')" />
      </UFormField>

      <UFormField :label="t('auth.password')" name="password">
        <UInput v-model="state.password" type="password" />
      </UFormField>

      <div class="flex items-center justify-between">
        <UCheckbox :label="t('auth.rememberMe')" disabled />
        <NuxtLink to="/auth/forgot-password" class="text-sm text-primary">
          &#123;&#123; t('auth.forgotPassword') }}
        </NuxtLink>
      </div>

      <UButton type="submit" block :loading="loading">
        &#123;&#123; t('auth.loginAction') }}
      </UButton>
    </UForm>

    <div class="my-6 flex items-center gap-3">
      <div class="flex-1 border-t border-gray-200 dark:border-gray-700" />
      <span class="text-sm text-gray-500">&#123;&#123; t('common.or') }}</span>
      <div class="flex-1 border-t border-gray-200 dark:border-gray-700" />
    </div>

    <UTooltip :text="t('common.comingSoon')">
      <UButton variant="outline" block disabled>
        &#123;&#123; t('auth.loginViaSms') }}
      </UButton>
    </UTooltip>

    <p class="mt-6 text-sm text-center">
      &#123;&#123; t('auth.noAccount') }}
      <NuxtLink to="/auth/register" class="text-primary">
        &#123;&#123; t('common.register') }}
      </NuxtLink>
    </p>
  </div>
</template>

Step 2: Verify lint passes

Run: npm run lint -- --no-fix app/pages/auth/login.vue Expected: No errors

Step 3: Commit

bash
git rm app/pages/auth/index.vue
git add app/pages/auth/login.vue
git commit -m "feat(auth): add login page with email/password form"

Task 10: Create Registration Page

Files:

  • Create: app/pages/auth/register.vue

Step 1: Create registration page

html
<script setup lang="ts">
import { registerSchema } from '~/shared/schemas/auth'

definePageMeta({
  middleware: 'guest',
  layout: 'default',
})

useSeoMeta({
  title: 'Регистрация | Partizap',
  robots: 'noindex, nofollow',
})

const { t } = useI18n()
const authStore = useAuthStore()
const { globalError, handleError, clearErrors } = useApiError()
const loading = ref(false)

const state = reactive({
  display_name: '',
  email: '',
  password: '',
  password_confirmation: '',
})

async function onSubmit() {
  clearErrors()
  loading.value = true
  try {
    await authStore.register(state.email, state.password, state.display_name)
    await navigateTo('/auth/verify-email')
  }
  catch (err) {
    handleError(err)
  }
  finally {
    loading.value = false
  }
}
</script>

<template>
  <div class="container mx-auto px-4 py-16 max-w-sm">
    <h1 class="text-2xl font-bold mb-6">
      &#123;&#123; t('auth.registerTitle') }}
    </h1>

    <UAlert
      v-if="globalError"
      color="error"
      :title="globalError"
      class="mb-4"
    />

    <UForm :schema="registerSchema" :state="state" class="space-y-4" @submit="onSubmit">
      <UFormField :label="t('auth.displayName')" name="display_name">
        <UInput v-model="state.display_name" :placeholder="t('auth.displayNamePlaceholder')" />
      </UFormField>

      <UTooltip :text="t('common.comingSoon')">
        <UFormField :label="t('auth.phone')" name="phone">
          <UInput :placeholder="t('auth.phonePlaceholder')" disabled />
        </UFormField>
      </UTooltip>

      <UFormField :label="t('auth.email')" name="email">
        <UInput v-model="state.email" type="email" :placeholder="t('auth.emailPlaceholder')" />
      </UFormField>

      <UFormField :label="t('auth.password')" name="password" :hint="t('auth.passwordHint')">
        <UInput v-model="state.password" type="password" :placeholder="t('auth.passwordPlaceholder')" />
      </UFormField>

      <UFormField :label="t('auth.confirmPassword')" name="password_confirmation">
        <UInput v-model="state.password_confirmation" type="password" />
      </UFormField>

      <UButton type="submit" block :loading="loading">
        &#123;&#123; t('auth.registerAction') }}
      </UButton>
    </UForm>

    <p class="mt-6 text-sm text-center">
      &#123;&#123; t('auth.hasAccount') }}
      <NuxtLink to="/auth/login" class="text-primary">
        &#123;&#123; t('common.login') }}
      </NuxtLink>
    </p>
  </div>
</template>

Step 2: Verify lint passes

Run: npm run lint -- --no-fix app/pages/auth/register.vue Expected: No errors

Step 3: Commit

bash
git add app/pages/auth/register.vue
git commit -m "feat(auth): add registration page with password confirmation"

Task 11: Create Email Verification Page

Files:

  • Create: app/pages/auth/verify-email.vue

Step 1: Create verify-email page

html
<script setup lang="ts">
import { verifyEmailSchema } from '~/shared/schemas/auth'

definePageMeta({
  middleware: 'auth',
  layout: 'default',
})

useSeoMeta({
  title: 'Подтверждение Email | Partizap',
  robots: 'noindex, nofollow',
})

const { t } = useI18n()
const authStore = useAuthStore()
const api = useApiClient()
const { globalError, handleError, clearErrors } = useApiError()
const loading = ref(false)
const resending = ref(false)
const resendCooldown = ref(0)
let cooldownInterval: ReturnType<typeof setInterval>

const state = reactive({
  code: '',
})

if (authStore.isEmailVerified) {
  navigateTo('/cabinet')
}

async function onSubmit() {
  clearErrors()
  loading.value = true
  try {
    await api.post('/auth/verify-email', { code: state.code })
    await authStore.fetchUser()
    await navigateTo('/cabinet')
  }
  catch (err) {
    handleError(err)
  }
  finally {
    loading.value = false
  }
}

async function resendCode() {
  clearErrors()
  resending.value = true
  try {
    await api.post('/auth/resend-verification')
    resendCooldown.value = 60
    cooldownInterval = setInterval(() => {
      resendCooldown.value--
      if (resendCooldown.value <= 0) clearInterval(cooldownInterval)
    }, 1000)
  }
  catch (err) {
    handleError(err)
  }
  finally {
    resending.value = false
  }
}

onUnmounted(() => clearInterval(cooldownInterval))
</script>

<template>
  <div class="container mx-auto px-4 py-16 max-w-sm">
    <h1 class="text-2xl font-bold mb-2">
      &#123;&#123; t('auth.verifyEmailTitle') }}
    </h1>
    <p class="text-gray-500 mb-6">
      &#123;&#123; t('auth.verifyEmailDescription') }}
      <strong>&#123;&#123; authStore.user?.email }}</strong>
    </p>

    <UAlert
      v-if="globalError"
      color="error"
      :title="globalError"
      class="mb-4"
    />

    <UForm :schema="verifyEmailSchema" :state="state" class="space-y-4" @submit="onSubmit">
      <UFormField :label="t('auth.verificationCode')" name="code">
        <UInput
          v-model="state.code"
          placeholder="123456"
          maxlength="6"
          inputmode="numeric"
        />
      </UFormField>

      <UButton type="submit" block :loading="loading">
        &#123;&#123; t('auth.verifyAction') }}
      </UButton>
    </UForm>

    <div class="mt-4 text-center">
      <UButton
        variant="ghost"
        :disabled="resendCooldown > 0"
        :loading="resending"
        @click="resendCode"
      >
        &#123;&#123; resendCooldown > 0 ? t('auth.resendIn', { seconds: resendCooldown }) : t('auth.resendCode') }}
      </UButton>
    </div>
  </div>
</template>

Step 2: Verify lint passes

Run: npm run lint -- --no-fix app/pages/auth/verify-email.vue Expected: No errors

Step 3: Commit

bash
git add app/pages/auth/verify-email.vue
git commit -m "feat(auth): add email verification page with resend cooldown"

Task 12: Create Forgot Password Page

Files:

  • Create: app/pages/auth/forgot-password.vue

Step 1: Create forgot-password page

html
<script setup lang="ts">
import { forgotPasswordSchema } from '~/shared/schemas/auth'

definePageMeta({
  middleware: 'guest',
  layout: 'default',
})

useSeoMeta({
  title: 'Восстановление пароля | Partizap',
  robots: 'noindex, nofollow',
})

const { t } = useI18n()
const api = useApiClient()
const { globalError, handleError, clearErrors } = useApiError()
const loading = ref(false)
const sent = ref(false)

const state = reactive({
  email: '',
})

async function onSubmit() {
  clearErrors()
  loading.value = true
  try {
    await api.post('/auth/forgot-password', { email: state.email })
    sent.value = true
  }
  catch (err) {
    handleError(err)
  }
  finally {
    loading.value = false
  }
}
</script>

<template>
  <div class="container mx-auto px-4 py-16 max-w-sm">
    <h1 class="text-2xl font-bold mb-6">
      &#123;&#123; t('auth.forgotPasswordTitle') }}
    </h1>

    <template v-if="sent">
      <UAlert
        color="success"
        :title="t('auth.forgotPasswordSent')"
        class="mb-4"
      />
      <NuxtLink to="/auth/login" class="text-primary text-sm">
        &#123;&#123; t('auth.backToLogin') }}
      </NuxtLink>
    </template>

    <template v-else>
      <UAlert
        v-if="globalError"
        color="error"
        :title="globalError"
        class="mb-4"
      />

      <UForm :schema="forgotPasswordSchema" :state="state" class="space-y-4" @submit="onSubmit">
        <UFormField :label="t('auth.email')" name="email">
          <UInput v-model="state.email" type="email" :placeholder="t('auth.emailPlaceholder')" />
        </UFormField>

        <UButton type="submit" block :loading="loading">
          &#123;&#123; t('auth.forgotPasswordAction') }}
        </UButton>
      </UForm>
    </template>
  </div>
</template>

Step 2: Verify lint passes

Run: npm run lint -- --no-fix app/pages/auth/forgot-password.vue Expected: No errors

Step 3: Commit

bash
git add app/pages/auth/forgot-password.vue
git commit -m "feat(auth): add forgot password page"

Task 13: Create Reset Password Page

Files:

  • Create: app/pages/auth/reset-password.vue

Step 1: Create reset-password page

html
<script setup lang="ts">
import { resetPasswordSchema } from '~/shared/schemas/auth'

definePageMeta({
  middleware: 'guest',
  layout: 'default',
})

useSeoMeta({
  title: 'Новый пароль | Partizap',
  robots: 'noindex, nofollow',
})

const { t } = useI18n()
const route = useRoute()
const api = useApiClient()
const { globalError, handleError, clearErrors } = useApiError()
const loading = ref(false)
const success = ref(false)

const token = route.query.token as string

if (!token) {
  navigateTo('/auth/forgot-password')
}

const state = reactive({
  token,
  password: '',
})

async function onSubmit() {
  clearErrors()
  loading.value = true
  try {
    await api.post('/auth/reset-password', {
      token: state.token,
      password: state.password,
    })
    success.value = true
  }
  catch (err) {
    handleError(err)
  }
  finally {
    loading.value = false
  }
}
</script>

<template>
  <div class="container mx-auto px-4 py-16 max-w-sm">
    <h1 class="text-2xl font-bold mb-6">
      &#123;&#123; t('auth.resetPasswordTitle') }}
    </h1>

    <template v-if="success">
      <UAlert
        color="success"
        :title="t('auth.resetPasswordSuccess')"
        class="mb-4"
      />
      <NuxtLink to="/auth/login" class="text-primary text-sm">
        &#123;&#123; t('auth.backToLogin') }}
      </NuxtLink>
    </template>

    <template v-else>
      <UAlert
        v-if="globalError"
        color="error"
        :title="globalError"
        class="mb-4"
      />

      <UForm :schema="resetPasswordSchema" :state="state" class="space-y-4" @submit="onSubmit">
        <UFormField
          :label="t('auth.newPassword')"
          name="password"
          :hint="t('auth.passwordHint')"
        >
          <UInput v-model="state.password" type="password" />
        </UFormField>

        <UButton type="submit" block :loading="loading">
          &#123;&#123; t('auth.resetPasswordAction') }}
        </UButton>
      </UForm>
    </template>
  </div>
</template>

Step 2: Verify lint passes

Run: npm run lint -- --no-fix app/pages/auth/reset-password.vue Expected: No errors

Step 3: Commit

bash
git add app/pages/auth/reset-password.vue
git commit -m "feat(auth): add reset password page"

Task 14: Update Routing and Final Cleanup

Files:

  • Modify: app/layouts/default.vue (already done in Task 8)
  • Modify: nuxt.config.ts — update apiBase for dev server

Step 1: Update nuxt.config.ts apiBase

Change apiBase default to /api (same-origin proxy):

ts
runtimeConfig: {
  public: {
    apiBase: '/api',
  },
},

For local dev without nginx, set NUXT_PUBLIC_API_BASE=http://localhost:8000 in .env.

Step 2: Create .env file for local development

Create .env:

NUXT_PUBLIC_API_BASE=http://localhost:8000

Step 3: Verify .env is in .gitignore

Check .gitignore contains .env. If not, add it.

Step 4: Full lint check

Run: npm run lint Expected: No errors

Step 5: Commit

bash
git add nuxt.config.ts .env
git commit -m "chore: update apiBase to /api, add .env for local dev"

Task 15: Update CLAUDE.md and Verify

Files:

  • Modify: CLAUDE.md

Step 1: Update CLAUDE.md progress and structure

Update the Project Structure section to reflect new files. Update the Routing table. Mark auth task as complete in Progress.

Step 2: Run full verification

Run: npm run lint && npm run typecheck Expected: No errors

Step 3: Final commit

bash
git add CLAUDE.md
git commit -m "docs: update CLAUDE.md with auth feature documentation"