# Stylist Login Minimal Closed Loop 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:** Build a real stylist email/password login flow that issues a JWT, protects the first backend business route, and preserves the existing frontend session + redirect behavior. **Architecture:** Keep the current frontend contract unchanged and implement a single access-token flow in the NestJS backend with `@nestjs/jwt` and `bcryptjs`. Preserve the shared `{ ok, data, error }` envelope, protect `customers` as the first authenticated backend route, and add focused backend/frontend regression tests around login, `401`, and redirect behavior. **Tech Stack:** NestJS 11, Prisma, Zod config, Jest, Supertest, React 19, React Router 7, Vitest, Testing Library ## Global Constraints - 以最小闭环为目标,优先打通主路径,不提前引入 refresh token 体系。 - 继续对齐现有前后端契约,减少无效重构。 - 未实现的能力显式不做,不用占位假数据冒充完成。 - 认证职责集中在 `auth` 模块和通用鉴权层,不把 token 解析散落到各业务模块。 - 安全语义保持基础可信,避免泄露“邮箱不存在”或“密码错误”的区分信息。 - 不实现顾客侧登录或注册。 - 不实现忘记密码、重置密码、短信验证码、邮件验证码。 - 不实现 refresh token、续期机制、多设备会话管理。 - 不实现服务端 session 黑名单或踢出其他设备。 - 不在本 case 中扩展复杂风控,如登录失败次数限制、账户锁定、设备指纹识别。 - 当前目录 `/Users/yuchen/Documents/hairstylist` 不是 Git 仓库;执行时不要编造 commit 结果。 --- ## File Structure - `backend/package.json`: add JWT and password-hash dependencies used by the auth module. - `backend/src/common/filters/http-exception.filter.ts`: preserve business `code` values when an exception response object provides one. - `backend/src/modules/auth/auth.module.ts`: wire `JwtModule` and auth providers. - `backend/src/modules/auth/auth.service.ts`: query `HairstylistUser`, compare password, sign JWT, and return the existing `LoginResultDto`. - `backend/src/modules/auth/auth.service.spec.ts`: focused unit tests for login success and invalid credentials. - `backend/src/modules/auth/guards/jwt-auth.guard.ts`: verify the JWT from either raw `Authorization: ` or `Authorization: Bearer `, then attach a typed user object to the request. - `backend/src/modules/customers/customers.controller.ts`: make the first business route authenticated. - `backend/test/auth.e2e-spec.ts`: integration tests for protected customers access. - `frontend/src/pages/public/__tests__/login-page.test.tsx`: keep the existing error-region regression and add a success-path redirect assertion. - `frontend/src/services/http/__tests__/client.test.ts`: verify the client adds the stored auth header and clears session on `401`. - `frontend/src/shared/__tests__/RequireAuth.test.tsx`: verify redirect to the locale login page when no token exists. ## Task 1: Implement Backend Login Service And Business Error Codes **Files:** - Modify: `backend/package.json` - Modify: `backend/src/common/filters/http-exception.filter.ts` - Modify: `backend/src/modules/auth/auth.module.ts` - Modify: `backend/src/modules/auth/auth.service.ts` - Test: `backend/src/modules/auth/auth.service.spec.ts` **Interfaces:** - Consumes: `PrismaService.hairstylistUser.findFirst(args): Promise<{ id: bigint; username: string; email: string; password: string; isDeleted: number } | null>` - Consumes: `JwtService.signAsync(payload: { sub: number; email: string }): Promise` - Produces: `AuthService.login(input: StylistLoginDto): Promise` - Produces: exception payloads with `{ code: string; message: string }` that the global filter returns unchanged - [ ] **Step 1: Add auth dependencies and write the failing backend login unit test** ```json // backend/package.json { "dependencies": { "@nestjs/jwt": "^11.0.0", "bcryptjs": "^2.4.3" } } ``` ```ts // backend/src/modules/auth/auth.service.spec.ts import { UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import { hash } from 'bcryptjs'; import { PrismaService } from '../../prisma/prisma.service'; import { AuthService } from './auth.service'; describe('AuthService', () => { let service: AuthService; let prisma: { hairstylistUser: { findFirst: jest.Mock; }; }; let jwtService: { signAsync: jest.Mock; }; beforeEach(async () => { prisma = { hairstylistUser: { findFirst: jest.fn(), }, }; jwtService = { signAsync: jest.fn(), }; const moduleRef = await Test.createTestingModule({ providers: [ AuthService, { provide: PrismaService, useValue: prisma, }, { provide: JwtService, useValue: jwtService, }, ], }).compile(); service = moduleRef.get(AuthService); }); it('returns the login payload when credentials are valid', async () => { prisma.hairstylistUser.findFirst.mockResolvedValue({ id: BigInt(1), username: 'Ogawa', email: 'stylist@example.com', password: await hash('Passw0rd!', 10), isDeleted: 0, }); jwtService.signAsync.mockResolvedValue('signed.jwt.token'); await expect( service.login({ email: 'stylist@example.com', password: 'Passw0rd!', remember: false, }), ).resolves.toEqual({ token: 'signed.jwt.token', tokenName: 'Authorization', stylistId: 1, name: 'Ogawa', email: 'stylist@example.com', }); }); it('throws AUTH_INVALID_CREDENTIALS when the password does not match', async () => { prisma.hairstylistUser.findFirst.mockResolvedValue({ id: BigInt(1), username: 'Ogawa', email: 'stylist@example.com', password: await hash('Passw0rd!', 10), isDeleted: 0, }); await expect( service.login({ email: 'stylist@example.com', password: 'wrong-password', remember: false, }), ).rejects.toMatchObject({ response: { code: 'AUTH_INVALID_CREDENTIALS', message: '邮箱或密码错误', }, }); }); }); ``` - [ ] **Step 2: Run the auth service test to confirm it fails before implementation** Run: ```bash npm test -- src/modules/auth/auth.service.spec.ts --runInBand ``` Expected: ```text FAIL src/modules/auth/auth.service.spec.ts ● AuthService › returns the login payload when credentials are valid Stylist login is not implemented yet ``` - [ ] **Step 3: Implement dependency wiring, business-code pass-through, and real login logic** ```ts // backend/src/common/filters/http-exception.filter.ts import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, } from '@nestjs/common'; import { Response } from 'express'; @Catch() export class HttpExceptionFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost): void { const response = host.switchToHttp().getResponse(); if (exception instanceof HttpException) { const status = exception.getStatus(); const payload = exception.getResponse(); const message = typeof payload === 'object' && payload !== null && 'message' in payload ? Array.isArray(payload.message) ? payload.message.join(', ') : String(payload.message) : exception.message; const code = typeof payload === 'object' && payload !== null && 'code' in payload && typeof payload.code === 'string' ? payload.code : `HTTP_${status}`; response.status(status).json({ ok: false, data: null, error: { code, message, }, }); return; } response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ ok: false, data: null, error: { code: 'INTERNAL_SERVER_ERROR', message: 'Internal server error', }, }); } } ``` ```ts // backend/src/modules/auth/auth.module.ts import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; @Module({ imports: [ JwtModule.registerAsync({ inject: [ConfigService], useFactory: (configService: ConfigService) => ({ secret: configService.get('JWT_SECRET'), signOptions: { expiresIn: configService.get('JWT_EXPIRES_IN') ?? '7d', }, }), }), ], controllers: [AuthController], providers: [AuthService, JwtAuthGuard], exports: [AuthService, JwtAuthGuard, JwtModule], }) export class AuthModule {} ``` ```ts // backend/src/modules/auth/auth.service.ts import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { compare } from 'bcryptjs'; import { PrismaService } from '../../prisma/prisma.service'; import { LoginResultDto } from './dto/login-result.dto'; import { StylistLoginDto } from './dto/stylist-login.dto'; @Injectable() export class AuthService { constructor( private readonly prisma: PrismaService, private readonly jwtService: JwtService, ) {} async login(input: StylistLoginDto): Promise { const stylist = await this.prisma.hairstylistUser.findFirst({ where: { email: input.email, isDeleted: 0, }, }); if (!stylist) { throw new UnauthorizedException({ code: 'AUTH_INVALID_CREDENTIALS', message: '邮箱或密码错误', }); } const passwordMatched = await compare(input.password, stylist.password); if (!passwordMatched) { throw new UnauthorizedException({ code: 'AUTH_INVALID_CREDENTIALS', message: '邮箱或密码错误', }); } const token = await this.jwtService.signAsync({ sub: Number(stylist.id), email: stylist.email, }); return { token, tokenName: 'Authorization', stylistId: Number(stylist.id), name: stylist.username, email: stylist.email, }; } async logout(): Promise<{ success: true }> { return { success: true }; } } ``` - [ ] **Step 4: Re-run the auth service test and the backend linter** Run: ```bash npm test -- src/modules/auth/auth.service.spec.ts --runInBand npm run lint ``` Expected: ```text PASS src/modules/auth/auth.service.spec.ts ✔ No ESLint warnings or errors ``` - [ ] **Step 5: Record the non-commit checkpoint** Run: ```bash git rev-parse --is-inside-work-tree ``` Expected: ```text fatal: not a git repository (or any of the parent directories): .git ``` Do not create a fake commit message; just continue to the next task. ## Task 2: Verify JWTs In The Guard And Protect The First Backend Route **Files:** - Modify: `backend/src/modules/auth/guards/jwt-auth.guard.ts` - Modify: `backend/src/modules/customers/customers.controller.ts` - Test: `backend/test/auth.e2e-spec.ts` **Interfaces:** - Consumes: `JwtService.verifyAsync<{ sub: number | string; email: string }>(token): Promise<{ sub: number | string; email: string }>` - Produces: request user shape `{ stylistId: number; email: string }` - Produces: protected `GET /api/v1/customers` behavior returning `401` without a token and `200` with a valid token - [ ] **Step 1: Write failing integration tests for protected customers access** ```ts // backend/test/auth.e2e-spec.ts import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import * as request from 'supertest'; import { AppModule } from '../src/app.module'; import { configureApp } from '../src/app.setup'; describe('Auth (e2e)', () => { let app: INestApplication; let jwtService: JwtService; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleRef.createNestApplication(); configureApp(app); await app.init(); jwtService = app.get(JwtService); }); afterAll(async () => { await app.close(); }); it('/api/v1/customers (GET) rejects unauthenticated requests', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/customers') .expect(401); expect(response.body).toEqual({ ok: false, data: null, error: { code: 'AUTH_UNAUTHORIZED', message: '未登录或登录已失效', }, }); }); it('/api/v1/customers (GET) accepts a valid signed token', async () => { const token = await jwtService.signAsync({ sub: 1, email: 'stylist@example.com', }); const response = await request(app.getHttpServer()) .get('/api/v1/customers') .set('Authorization', token) .expect(200); expect(response.body).toEqual({ ok: true, data: { items: [], total: 0, }, error: null, }); }); }); ``` - [ ] **Step 2: Run the new backend integration test and confirm it fails** Run: ```bash npm run test:e2e -- auth.e2e-spec.ts ``` Expected: ```text FAIL test/auth.e2e-spec.ts ● Auth (e2e) › /api/v1/customers (GET) rejects unauthenticated requests expected 401 "Unauthorized", got 200 "OK" ``` - [ ] **Step 3: Implement JWT verification in the guard and apply it to customers** ```ts // backend/src/modules/auth/guards/jwt-auth.guard.ts import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; type AuthenticatedRequest = { headers?: { authorization?: string }; user?: { stylistId: number; email: string }; }; @Injectable() export class JwtAuthGuard implements CanActivate { constructor(private readonly jwtService: JwtService) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const token = this.extractToken(request.headers?.authorization); if (!token) { throw new UnauthorizedException({ code: 'AUTH_UNAUTHORIZED', message: '未登录或登录已失效', }); } try { const payload = await this.jwtService.verifyAsync<{ sub: number | string; email: string; }>(token); request.user = { stylistId: Number(payload.sub), email: payload.email, }; return true; } catch { throw new UnauthorizedException({ code: 'AUTH_UNAUTHORIZED', message: '未登录或登录已失效', }); } } private extractToken(header?: string): string | null { if (!header) return null; return header.startsWith('Bearer ') ? header.slice(7) : header; } } ``` ```ts // backend/src/modules/customers/customers.controller.ts import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards, } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CustomerUpsertDto } from './dto/customer-upsert.dto'; import { ListCustomersQueryDto } from './dto/list-customers-query.dto'; import { CustomersService } from './customers.service'; @UseGuards(JwtAuthGuard) @Controller('customers') export class CustomersController { constructor(private readonly customersService: CustomersService) {} @Get() list(@Query() query: ListCustomersQueryDto) { return this.customersService.list(query); } @Get(':id') getById(@Param('id') id: string) { return this.customersService.getById(id); } @Post() create(@Body() body: CustomerUpsertDto) { return this.customersService.create(body); } @Patch(':id') update(@Param('id') id: string, @Body() body: CustomerUpsertDto) { return this.customersService.update(id, body); } } ``` - [ ] **Step 4: Run the targeted backend integration test and the full backend test suite** Run: ```bash npm run test:e2e -- auth.e2e-spec.ts npm test -- --runInBand ``` Expected: ```text PASS test/auth.e2e-spec.ts PASS src/modules/auth/auth.service.spec.ts ``` - [ ] **Step 5: Record the non-commit checkpoint** Run: ```bash git rev-parse --is-inside-work-tree ``` Expected: ```text fatal: not a git repository (or any of the parent directories): .git ``` Do not invent a commit; continue to the frontend task. ## Task 3: Lock In Frontend Session, Redirect, And 401 Regression Coverage **Files:** - Modify: `frontend/src/pages/public/__tests__/login-page.test.tsx` - Modify: `frontend/src/services/http/__tests__/client.test.ts` - Test: `frontend/src/shared/__tests__/RequireAuth.test.tsx` **Interfaces:** - Consumes: `stylistLogin(input: { email: string; password: string; remember?: boolean }): Promise` - Consumes: `setSession(token: string, tokenName?: string): void` - Consumes: `clearSession(): void` - Produces: proof that login success redirects to `/:locale/dashboard` - Produces: proof that `request()` removes the session on `401` - Produces: proof that `RequireAuth` redirects to `/:locale/login` when no token exists - [ ] **Step 1: Add the failing frontend regression tests** ```tsx // frontend/src/pages/public/__tests__/login-page.test.tsx import { HelmetProvider } from 'react-helmet-async' import { MemoryRouter, Route, Routes } from 'react-router-dom' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import '../../../i18n' import { LoginPage } from '../LoginPage' const stylistLoginMock = vi.fn() vi.mock('../../../services/api/auth', () => ({ stylistLogin: (...args: unknown[]) => stylistLoginMock(...args), })) describe('LoginPage', () => { it('keeps a persistent feedback region and shows the login error inside it', async () => { stylistLoginMock.mockRejectedValueOnce(new Error('邮箱或密码不正确')) render( } /> , ) expect(screen.getByRole('status')).toBeInTheDocument() fireEvent.change(screen.getByLabelText(/邮箱|email/i), { target: { value: 'wrong@example.com' }, }) fireEvent.change(screen.getByLabelText(/密码|password/i), { target: { value: 'wrong-password' }, }) fireEvent.submit(screen.getByRole('button', { name: /登录|log in/i }).closest('form')!) await waitFor(() => { expect(screen.getByRole('alert')).toHaveTextContent('邮箱或密码不正确') }) }) it('navigates to the dashboard after a successful login', async () => { stylistLoginMock.mockResolvedValueOnce({ token: 'signed.jwt.token', tokenName: 'Authorization', stylistId: 1, name: 'Ogawa', email: 'stylist@example.com', }) render( } /> dashboard screen} /> , ) fireEvent.change(screen.getByLabelText(/邮箱|email/i), { target: { value: 'stylist@example.com' }, }) fireEvent.change(screen.getByLabelText(/密码|password/i), { target: { value: 'Passw0rd!' }, }) fireEvent.submit(screen.getByRole('button', { name: /登录|log in/i }).closest('form')!) await waitFor(() => { expect(screen.getByText('dashboard screen')).toBeInTheDocument() }) }) }) ``` ```ts // frontend/src/services/http/__tests__/client.test.ts import { beforeEach, describe, expect, it, vi } from 'vitest' import { API_BASE, http } from '../client' import { clearSession, getToken, getTokenName, setSession } from '../session' describe('http client', () => { beforeEach(() => { vi.mocked(globalThis.fetch).mockReset() clearSession() }) it('uses the Vite proxy path as the default API base', () => { expect(API_BASE).toBe('/api/v1') }) it('sends the stored token with the stored header name', async () => { setSession('signed.jwt.token', 'Authorization') vi.mocked(globalThis.fetch).mockResolvedValueOnce( new Response(JSON.stringify({ ok: true, data: { ok: true }, error: null }), { status: 200, headers: { 'content-type': 'application/json' }, }), ) await http.get('/customers') expect(globalThis.fetch).toHaveBeenCalledWith('/api/v1/customers', { method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: 'signed.jwt.token', }, }) }) it('clears the session after a 401 response', async () => { setSession('expired.jwt.token', 'Authorization') vi.mocked(globalThis.fetch).mockResolvedValueOnce( new Response( JSON.stringify({ ok: false, data: null, error: { code: 'AUTH_UNAUTHORIZED', message: '未登录或登录已失效' }, }), { status: 401, headers: { 'content-type': 'application/json' }, }, ), ) await expect(http.get('/customers')).rejects.toMatchObject({ code: 'AUTH_UNAUTHORIZED', message: '未登录或登录已失效', status: 401, }) expect(getToken()).toBeNull() expect(getTokenName()).toBe('Authorization') }) }) ``` ```tsx // frontend/src/shared/__tests__/RequireAuth.test.tsx import React from 'react' import { MemoryRouter, Route, Routes } from 'react-router-dom' import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { setSession } from '../../services/http/session' import { RequireAuth } from '../RequireAuth' describe('RequireAuth', () => { it('redirects to the locale login page when there is no token', () => { render(
dashboard screen
} /> login screen} />
, ) expect(screen.getByText('login screen')).toBeInTheDocument() }) it('renders children when a token exists', () => { setSession('signed.jwt.token', 'Authorization') render(
dashboard screen
} />
, ) expect(screen.getByText('dashboard screen')).toBeInTheDocument() }) }) ``` - [ ] **Step 2: Run the frontend tests to confirm they fail before any production-code changes** Run: ```bash npm test -- src/pages/public/__tests__/login-page.test.tsx src/services/http/__tests__/client.test.ts src/shared/__tests__/RequireAuth.test.tsx ``` Expected: ```text FAIL src/services/http/__tests__/client.test.ts × sends the stored token with the stored header name Expected fetch to have been called with the Authorization header ``` - [ ] **Step 3: Make the minimal frontend code adjustments only if the new tests expose a real bug** ```ts // frontend/src/services/http/client.ts async function request(path: string, options: RequestInit = {}): Promise { const headers: Record = { ...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...((options.headers as Record) || {}), } const token = getToken() if (token) { headers[getTokenName()] = token } const response = await fetch(buildUrl(path), { ...options, headers }) if (response.status === 401) { clearSession() } const contentType = response.headers.get('content-type') || '' if (!contentType.includes('application/json')) { if (!response.ok) { throw new ApiError('HTTP_ERROR', `请求失败: ${response.status}`, response.status) } return (await response.text()) as unknown as T } const body = (await response.json()) as ApiEnvelope if (!response.ok || !body.ok) { const err = body.error throw new ApiError(err?.code || 'HTTP_ERROR', err?.message || `请求失败: ${response.status}`, response.status) } return body.data } ``` ```tsx // frontend/src/pages/public/LoginPage.tsx const handleSubmit = async (event: React.FormEvent) => { event.preventDefault() if (!email || !password || submitting) return setError('') setSubmitting(true) try { await stylistLogin({ email, password, remember }) navigate(path('/dashboard')) } catch (err) { setError(err instanceof Error ? err.message : '登录失败') } finally { setSubmitting(false) } } ``` If the tests already pass without touching production code, do not edit the implementation just to create churn. - [ ] **Step 4: Re-run the targeted frontend tests, then typecheck and lint** Run: ```bash npm test -- src/pages/public/__tests__/login-page.test.tsx src/services/http/__tests__/client.test.ts src/shared/__tests__/RequireAuth.test.tsx npm run typecheck npm run lint ``` Expected: ```text PASS src/pages/public/__tests__/login-page.test.tsx PASS src/services/http/__tests__/client.test.ts PASS src/shared/__tests__/RequireAuth.test.tsx ``` - [ ] **Step 5: Record the non-commit checkpoint** Run: ```bash git rev-parse --is-inside-work-tree ``` Expected: ```text fatal: not a git repository (or any of the parent directories): .git ``` Do not fabricate a commit; move to final verification. ## Task 4: End-To-End Verification And Manual Runtime Smoke **Files:** - Modify: `backend/.env` only if `JWT_SECRET` or `JWT_EXPIRES_IN` are missing - Modify: `backend/README.md` only if a short “how to create a bcrypt login password” note is needed for local verification **Interfaces:** - Consumes: real `DATABASE_URL`, `JWT_SECRET`, and a `hairstylist_user` row with a bcrypt-hashed password - Produces: evidence that the backend can log in a real stylist and that the frontend can enter `/zh/dashboard` - [ ] **Step 1: Verify the local auth prerequisites** Run: ```bash cd /Users/yuchen/Documents/hairstylist/backend npm run prisma:validate ``` Expected: ```text The schema at prisma/schema.prisma is valid ``` Then check that `.env` contains a non-placeholder `JWT_SECRET`. If it still equals `replace-me`, set a real local secret before starting the server. - [ ] **Step 2: Start the backend and confirm the auth routes boot** Run: ```bash cd /Users/yuchen/Documents/hairstylist/backend npm run start:dev ``` Expected: ```text Nest application successfully started ``` - [ ] **Step 3: Exercise the real login route with a known bcrypt-backed stylist account** Run: ```bash curl -i http://127.0.0.1:3000/api/v1/auth/stylist/login \ -H 'Content-Type: application/json' \ -d '{"email":"stylist@example.com","password":"Passw0rd!","remember":false}' ``` Expected: ```text HTTP/1.1 200 OK {"ok":true,"data":{"token":"...","tokenName":"Authorization","stylistId":1,"name":"Ogawa","email":"stylist@example.com"},"error":null} ``` If the database account is not yet bcrypt-backed, generate the hash with: ```bash node -e "const { hashSync } = require('bcryptjs'); console.log(hashSync('Passw0rd!', 10))" ``` and update the test row manually before retrying the login request. - [ ] **Step 4: Verify the first protected backend route with the returned token** Run: ```bash curl -i http://127.0.0.1:3000/api/v1/customers \ -H "Authorization: " ``` Expected: ```text HTTP/1.1 200 OK {"ok":true,"data":{"items":[],"total":0},"error":null} ``` Also verify the unauthenticated case: ```bash curl -i http://127.0.0.1:3000/api/v1/customers ``` Expected: ```text HTTP/1.1 401 Unauthorized {"ok":false,"data":null,"error":{"code":"AUTH_UNAUTHORIZED","message":"未登录或登录已失效"}} ``` - [ ] **Step 5: Smoke-test the frontend login flow in the browser** Run: ```bash cd /Users/yuchen/Documents/hairstylist/frontend npm run dev ``` Manual check: - Open `/zh/login` - Submit the same real stylist credentials - Confirm navigation to `/zh/dashboard` - Refresh `/zh/dashboard` and confirm the page stays authenticated - Delete or corrupt the token in DevTools and confirm the next protected request sends the user back to `/zh/login` ## Self-Review - **Spec coverage:** Task 1 covers real login, standard response shape, and invalid-credentials behavior. Task 2 covers JWT verification, protected route behavior, and `401` semantics. Task 3 covers frontend submit, redirect, session storage, and `401` cleanup regressions. Task 4 covers real local verification with a bcrypt-backed account. - **Placeholder scan:** The plan contains no `TODO`, `TBD`, “implement later”, or “add validation” placeholders; every task has concrete files, code snippets, and commands. - **Type consistency:** `AuthService.login()` returns `LoginResultDto`; JWT payload is consistently `{ sub, email }`; guard-attached request user is consistently `{ stylistId, email }`; frontend tests consistently expect `token`, `tokenName`, `stylistId`, `name`, and `email`.