2026-06-28-stylist-login-minimal-closed-loop.md 29 KB

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: <token> or Authorization: Bearer <token>, 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<string>
  • Produces: AuthService.login(input: StylistLoginDto): Promise<LoginResultDto>
  • 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

    // backend/package.json
    {
    "dependencies": {
    "@nestjs/jwt": "^11.0.0",
    "bcryptjs": "^2.4.3"
    }
    }
    
    // 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<UnauthorizedException>({
      response: {
        code: 'AUTH_INVALID_CREDENTIALS',
        message: '邮箱或密码错误',
      },
    });
    });
    });
    
  • [ ] Step 2: Run the auth service test to confirm it fails before implementation

Run:

npm test -- src/modules/auth/auth.service.spec.ts --runInBand

Expected:

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

    // 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<Response>();
    
    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',
      },
    });
    }
    }
    
    // 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<string>('JWT_SECRET'),
        signOptions: {
          expiresIn: configService.get<string>('JWT_EXPIRES_IN') ?? '7d',
        },
      }),
    }),
    ],
    controllers: [AuthController],
    providers: [AuthService, JwtAuthGuard],
    exports: [AuthService, JwtAuthGuard, JwtModule],
    })
    export class AuthModule {}
    
    // 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<LoginResultDto> {
    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:

npm test -- src/modules/auth/auth.service.spec.ts --runInBand
npm run lint

Expected:

PASS src/modules/auth/auth.service.spec.ts
✔ No ESLint warnings or errors
  • Step 5: Record the non-commit checkpoint

Run:

git rev-parse --is-inside-work-tree

Expected:

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

    // 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:

npm run test:e2e -- auth.e2e-spec.ts

Expected:

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

    // 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<boolean> {
    const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
    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;
    }
    }
    
    // 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:

npm run test:e2e -- auth.e2e-spec.ts
npm test -- --runInBand

Expected:

PASS test/auth.e2e-spec.ts
PASS src/modules/auth/auth.service.spec.ts
  • Step 5: Record the non-commit checkpoint

Run:

git rev-parse --is-inside-work-tree

Expected:

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<LoginResult>
  • 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

    // 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(
      <HelmetProvider>
        <MemoryRouter initialEntries={['/zh/login']}>
          <Routes>
            <Route path="/:locale/login" element={<LoginPage />} />
          </Routes>
        </MemoryRouter>
      </HelmetProvider>,
    )
    
    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(
      <HelmetProvider>
        <MemoryRouter initialEntries={['/zh/login']}>
          <Routes>
            <Route path="/:locale/login" element={<LoginPage />} />
            <Route path="/:locale/dashboard" element={<div>dashboard screen</div>} />
          </Routes>
        </MemoryRouter>
      </HelmetProvider>,
    )
    
    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()
    })
    })
    })
    
    // 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')
    })
    })
    
    // 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(
      <MemoryRouter initialEntries={['/zh/dashboard']}>
        <Routes>
          <Route
            path="/:locale/dashboard"
            element={
              <RequireAuth>
                <div>dashboard screen</div>
              </RequireAuth>
            }
          />
          <Route path="/:locale/login" element={<div>login screen</div>} />
        </Routes>
      </MemoryRouter>,
    )
    
    expect(screen.getByText('login screen')).toBeInTheDocument()
    })
    
    it('renders children when a token exists', () => {
    setSession('signed.jwt.token', 'Authorization')
    
    render(
      <MemoryRouter initialEntries={['/zh/dashboard']}>
        <Routes>
          <Route
            path="/:locale/dashboard"
            element={
              <RequireAuth>
                <div>dashboard screen</div>
              </RequireAuth>
            }
          />
        </Routes>
      </MemoryRouter>,
    )
    
    expect(screen.getByText('dashboard screen')).toBeInTheDocument()
    })
    })
    
  • [ ] Step 2: Run the frontend tests to confirm they fail before any production-code changes

Run:

npm test -- src/pages/public/__tests__/login-page.test.tsx src/services/http/__tests__/client.test.ts src/shared/__tests__/RequireAuth.test.tsx

Expected:

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

    // frontend/src/services/http/client.ts
    async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
    const headers: Record<string, string> = {
    ...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
    ...((options.headers as Record<string, string>) || {}),
    }
    
    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<T>
    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
    }
    
    // frontend/src/pages/public/LoginPage.tsx
    const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    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:

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:

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:

git rev-parse --is-inside-work-tree

Expected:

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:

cd /Users/yuchen/Documents/hairstylist/backend
npm run prisma:validate

Expected:

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:

cd /Users/yuchen/Documents/hairstylist/backend
npm run start:dev

Expected:

Nest application successfully started
  • Step 3: Exercise the real login route with a known bcrypt-backed stylist account

Run:

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:

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:

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:

curl -i http://127.0.0.1:3000/api/v1/customers \
  -H "Authorization: <paste-login-token-here>"

Expected:

HTTP/1.1 200 OK
{"ok":true,"data":{"items":[],"total":0},"error":null}

Also verify the unauthenticated case:

curl -i http://127.0.0.1:3000/api/v1/customers

Expected:

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:

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.