Compare commits
6 Commits
46d98ab11e
...
3ab999d0b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ab999d0b2 | |||
| cc61967e3b | |||
| dc39e3e4e5 | |||
| 2d435143ab | |||
| 06a3a4da75 | |||
| a5b89ddb1d |
@@ -47,6 +47,10 @@ AUTH_REGISTER_IP_LIMIT=5
|
||||
AUTH_REGISTER_EMAIL_LIMIT=3
|
||||
# Maximum refresh attempts per IP inside the window; refresh tokens have no trusted email before validation.
|
||||
AUTH_REFRESH_IP_LIMIT=30
|
||||
# 密码修改在同一限流窗口内允许的每 IP 最大次数。
|
||||
AUTH_CHANGE_PASSWORD_IP_LIMIT=10
|
||||
# 密码修改在同一限流窗口内允许的每已认证用户最大次数。
|
||||
AUTH_CHANGE_PASSWORD_USER_LIMIT=5
|
||||
|
||||
# Comma-separated trusted reverse-proxy IPs/CIDRs. Keep empty for direct local access; never set to true.
|
||||
TRUSTED_PROXY_CIDRS=
|
||||
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
Inject,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
@@ -19,10 +21,13 @@ import {
|
||||
} from '@nestjs/swagger';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
changePasswordRequestSchema,
|
||||
loginRequestSchema,
|
||||
registerRequestSchema,
|
||||
updateCurrentUserRequestSchema,
|
||||
type Workspace,
|
||||
} from '@atlas/contracts';
|
||||
import { z } from 'zod';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { Public } from '../common/public.decorator.js';
|
||||
import type { AuthenticatedRequest } from './auth.guard.js';
|
||||
@@ -157,6 +162,70 @@ export class AuthController {
|
||||
data: await this.authService.me(request.auth.userId, workspace),
|
||||
};
|
||||
}
|
||||
|
||||
@Patch('me')
|
||||
@ApiBearerAuth('access-token')
|
||||
@ApiOperation({ summary: '按版本更新当前账户的显示名称或头像素材引用' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
example: {
|
||||
display_name: 'Joey',
|
||||
avatar_asset_id: 'b2b592e8-96b0-4d2b-afc4-2c6462f2065b',
|
||||
},
|
||||
},
|
||||
})
|
||||
public async updateProfile(
|
||||
@Body() body: unknown,
|
||||
@Headers('if-match') ifMatch: string | undefined,
|
||||
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return (
|
||||
await this.authService.updateProfile(
|
||||
workspace,
|
||||
request.auth.userId,
|
||||
requireIdempotencyKey(idempotencyKey),
|
||||
parseRevision(ifMatch),
|
||||
parse(updateCurrentUserRequestSchema, body),
|
||||
)
|
||||
).body;
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@HttpCode(200)
|
||||
@ApiBearerAuth('access-token')
|
||||
@ApiOperation({ summary: '修改密码、撤销旧会话并轮换当前会话' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
example: {
|
||||
old_password: 'old-strong-password',
|
||||
password: 'new-strong-password',
|
||||
password_confirmation: 'new-strong-password',
|
||||
},
|
||||
},
|
||||
})
|
||||
public async changePassword(
|
||||
@Body() body: unknown,
|
||||
@Req() request: AuthenticatedRequest & HttpRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
const result = await this.authService.changePassword(
|
||||
workspace,
|
||||
request.auth.userId,
|
||||
request.auth.sessionId,
|
||||
parse(changePasswordRequestSchema, body),
|
||||
metadata(request),
|
||||
);
|
||||
setRefreshCookie(response, result.refreshToken);
|
||||
return {
|
||||
data: {
|
||||
access_token: result.payload.access_token,
|
||||
access_token_expires_in: result.payload.access_token_expires_in,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parse<
|
||||
@@ -224,3 +293,31 @@ function readCookie(header: string | undefined, name: string) {
|
||||
.find((part) => part.startsWith(`${name}=`))
|
||||
?.slice(name.length + 1);
|
||||
}
|
||||
function requireIdempotencyKey(value: string | undefined): string {
|
||||
if (!value) {
|
||||
throw new ApiError(
|
||||
400,
|
||||
'IDEMPOTENCY_KEY_REQUIRED',
|
||||
'请求缺少 Idempotency-Key',
|
||||
);
|
||||
}
|
||||
if (z.uuid().safeParse(value).success) return value;
|
||||
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||
{
|
||||
field: 'Idempotency-Key',
|
||||
code: 'INVALID_FIELD',
|
||||
message: 'Idempotency-Key 必须是 UUID',
|
||||
},
|
||||
]);
|
||||
}
|
||||
function parseRevision(value: string | undefined): number {
|
||||
const match = value?.match(/^"([1-9]\d*)"$/);
|
||||
if (!match) {
|
||||
throw new ApiError(
|
||||
400,
|
||||
'IF_MATCH_REQUIRED',
|
||||
'请求必须携带形如 "1" 的 If-Match',
|
||||
);
|
||||
}
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import { AuthController } from './auth.controller.js';
|
||||
import { AccessTokenGuard } from './auth.guard.js';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { WorkspaceContextGuard } from '../workspace/workspace-context.guard.js';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
IdempotencyService,
|
||||
AccessTokenGuard,
|
||||
WorkspaceContextGuard,
|
||||
{ provide: APP_GUARD, useExisting: AccessTokenGuard },
|
||||
|
||||
@@ -2,12 +2,15 @@ import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import type {
|
||||
AuthSessionResponse,
|
||||
AuthUser,
|
||||
ChangePasswordRequest,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
UpdateCurrentUserRequest,
|
||||
Workspace,
|
||||
} from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
import { AuthRateLimiter } from '../security/auth-rate-limiter.service.js';
|
||||
@@ -24,8 +27,10 @@ type UserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
avatar_asset_id: string | null;
|
||||
email_verified_at: Date | null;
|
||||
created_at: Date;
|
||||
revision: number;
|
||||
status: 'active' | 'disabled' | 'pending_review';
|
||||
password_hash: string;
|
||||
};
|
||||
@@ -42,6 +47,8 @@ export class AuthService {
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
@Inject(AuthRateLimiter)
|
||||
private readonly rateLimiter: AuthRateLimiter,
|
||||
@Inject(IdempotencyService)
|
||||
private readonly idempotency: IdempotencyService,
|
||||
) {}
|
||||
|
||||
public async register(input: RegisterRequest, metadata: RequestMetadata) {
|
||||
@@ -105,7 +112,7 @@ export class AuthService {
|
||||
// 限流与 citext 查询使用同一规范化邮箱,避免大小写绕过限流桶。
|
||||
await this.rateLimiter.consume('login', { ip: metadata.ip, email });
|
||||
const result = await this.database.query<UserRow>(
|
||||
'SELECT id, email, display_name, email_verified_at, created_at, status, password_hash FROM atlas.users WHERE email = $1',
|
||||
'SELECT id, email, display_name, avatar_asset_id, email_verified_at, created_at, revision, status, password_hash FROM atlas.users WHERE email = $1',
|
||||
[email],
|
||||
);
|
||||
const user = result.rows[0];
|
||||
@@ -143,7 +150,7 @@ export class AuthService {
|
||||
const sessions = await transaction.query<
|
||||
UserRow & { session_id: string }
|
||||
>(
|
||||
`SELECT u.id, u.email, u.display_name, u.email_verified_at, u.created_at, u.status, u.password_hash, s.id AS session_id
|
||||
`SELECT u.id, u.email, u.display_name, u.avatar_asset_id, u.email_verified_at, u.created_at, u.revision, u.status, u.password_hash, s.id AS session_id
|
||||
FROM atlas.user_sessions s JOIN atlas.users u ON u.id = s.user_id
|
||||
WHERE s.refresh_token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now() FOR UPDATE`,
|
||||
[tokenHash(refreshToken)],
|
||||
@@ -194,7 +201,7 @@ export class AuthService {
|
||||
public async me(userId: string, workspace: Workspace) {
|
||||
// workspace 由全局守卫解析,避免控制器或服务各自选择租户造成边界不一致。
|
||||
const users = await this.database.query<UserRow>(
|
||||
'SELECT id, email, display_name, email_verified_at, created_at, status, password_hash FROM atlas.users WHERE id = $1',
|
||||
'SELECT id, email, display_name, avatar_asset_id, email_verified_at, created_at, revision, status, password_hash FROM atlas.users WHERE id = $1',
|
||||
[userId],
|
||||
);
|
||||
const user = users.rows[0];
|
||||
@@ -223,6 +230,139 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
public async updateProfile(
|
||||
workspace: Workspace,
|
||||
userId: string,
|
||||
idempotencyKey: string,
|
||||
revision: number,
|
||||
input: UpdateCurrentUserRequest,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
userId,
|
||||
method: 'PATCH',
|
||||
path: '/api/auth/me',
|
||||
key: idempotencyKey,
|
||||
request: { if_match: revision, ...input },
|
||||
},
|
||||
async (transaction) => {
|
||||
const user = await findUserInTransaction(
|
||||
transaction,
|
||||
userId,
|
||||
true,
|
||||
);
|
||||
if (user.revision !== revision) {
|
||||
throw versionConflict(user.revision);
|
||||
}
|
||||
if (input.avatar_asset_id) {
|
||||
// 头像只能引用当前工作空间内已完成入库的素材,不能接受任意对象路径或 URL。
|
||||
const asset = await transaction.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.media_assets WHERE id = $1`,
|
||||
[input.avatar_asset_id],
|
||||
);
|
||||
if (!asset.rows[0]) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'AVATAR_ASSET_UNAVAILABLE',
|
||||
'头像素材不存在或不属于当前工作空间',
|
||||
);
|
||||
}
|
||||
}
|
||||
const updated = await transaction.query<UserRow>(
|
||||
`UPDATE atlas.users
|
||||
SET display_name = CASE WHEN $1 THEN $2 ELSE display_name END,
|
||||
avatar_asset_id = CASE WHEN $3 THEN $4::uuid ELSE avatar_asset_id END,
|
||||
revision = revision + 1, updated_at = now()
|
||||
WHERE id = $5
|
||||
RETURNING id, email, display_name, avatar_asset_id, email_verified_at,
|
||||
created_at, revision, status, password_hash`,
|
||||
[
|
||||
Object.hasOwn(input, 'display_name'),
|
||||
input.display_name ?? null,
|
||||
Object.hasOwn(input, 'avatar_asset_id'),
|
||||
input.avatar_asset_id ?? null,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
return {
|
||||
status: 200,
|
||||
body: { data: publicUser(updated.rows[0]) },
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async changePassword(
|
||||
workspace: Workspace,
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
input: ChangePasswordRequest,
|
||||
metadata: RequestMetadata,
|
||||
) {
|
||||
if (input.password !== input.password_confirmation) {
|
||||
throw validationError(
|
||||
'password_confirmation',
|
||||
'PASSWORD_CONFIRMATION_MISMATCH',
|
||||
'两次输入的密码不一致',
|
||||
);
|
||||
}
|
||||
if (input.password.length < this.config.AUTH_PASSWORD_MIN_LENGTH) {
|
||||
throw validationError(
|
||||
'password',
|
||||
'PASSWORD_TOO_SHORT',
|
||||
`密码至少需要 ${this.config.AUTH_PASSWORD_MIN_LENGTH} 个字符`,
|
||||
);
|
||||
}
|
||||
// 修改密码按已认证用户和来源 IP 双维度限流,避免已泄露会话被用于高频猜测旧密码。
|
||||
await this.rateLimiter.consume('change_password', {
|
||||
ip: metadata.ip,
|
||||
userId,
|
||||
});
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const session = await transaction.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.user_sessions
|
||||
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > now()
|
||||
FOR UPDATE`,
|
||||
[sessionId, userId],
|
||||
);
|
||||
if (!session.rows[0]) throw new UnauthorizedException();
|
||||
const user = await findUserInTransaction(transaction, userId, true);
|
||||
if (user.status !== 'active') {
|
||||
throw new ApiError(403, 'ACCOUNT_DISABLED', '账户已停用');
|
||||
}
|
||||
if (
|
||||
!(await verifyPassword(input.old_password, user.password_hash))
|
||||
) {
|
||||
throw validationError(
|
||||
'old_password',
|
||||
'CURRENT_PASSWORD_INVALID',
|
||||
'当前密码错误',
|
||||
);
|
||||
}
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
const updated = await transaction.query<UserRow>(
|
||||
`UPDATE atlas.users SET password_hash = $1, updated_at = now()
|
||||
WHERE id = $2
|
||||
RETURNING id, email, display_name, avatar_asset_id, email_verified_at,
|
||||
created_at, revision, status, password_hash`,
|
||||
[passwordHash, userId],
|
||||
);
|
||||
// 先撤销所有旧 session,再创建新 session;旧 access token 会立即失效。
|
||||
await transaction.query(
|
||||
`UPDATE atlas.user_sessions SET revoked_at = now()
|
||||
WHERE user_id = $1 AND revoked_at IS NULL`,
|
||||
[userId],
|
||||
);
|
||||
return this.createSessionResponse(
|
||||
transaction,
|
||||
updated.rows[0],
|
||||
workspace,
|
||||
metadata,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async findWorkspace(userId: string) {
|
||||
const result = await this.database.query<WorkspaceRow>(
|
||||
`SELECT w.id, w.name, m.role FROM atlas.workspace_memberships m JOIN atlas.workspaces w ON w.id = m.workspace_id WHERE m.user_id = $1 AND w.status = 'active' ORDER BY m.joined_at LIMIT 1`,
|
||||
@@ -287,14 +427,40 @@ function isUniqueViolation(error: unknown): error is { code: string } {
|
||||
(error as { code: string }).code === '23505'
|
||||
);
|
||||
}
|
||||
async function findUserInTransaction(
|
||||
transaction: DatabaseTransaction,
|
||||
userId: string,
|
||||
lock: boolean,
|
||||
): Promise<UserRow> {
|
||||
const result = await transaction.query<UserRow>(
|
||||
`SELECT id, email, display_name, avatar_asset_id, email_verified_at, created_at,
|
||||
revision, status, password_hash
|
||||
FROM atlas.users WHERE id = $1${lock ? ' FOR UPDATE' : ''}`,
|
||||
[userId],
|
||||
);
|
||||
if (!result.rows[0]) throw new UnauthorizedException();
|
||||
return result.rows[0];
|
||||
}
|
||||
function versionConflict(currentRevision: number) {
|
||||
return new ApiError(409, 'VERSION_CONFLICT', '账户资料已被其他操作更新', [
|
||||
{
|
||||
field: 'If-Match',
|
||||
code: 'VERSION_CONFLICT',
|
||||
message: `当前版本为 ${currentRevision}`,
|
||||
current_revision: currentRevision,
|
||||
},
|
||||
]);
|
||||
}
|
||||
function publicUser(user: UserRow): AuthUser {
|
||||
// 白名单映射防止 password_hash 等内部字段随查询结果泄露给客户端。
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
display_name: user.display_name,
|
||||
avatar_asset_id: user.avatar_asset_id,
|
||||
email_verified_at: user.email_verified_at?.toISOString() ?? null,
|
||||
created_at: user.created_at.toISOString(),
|
||||
revision: user.revision,
|
||||
};
|
||||
}
|
||||
async function insertUser(
|
||||
@@ -305,7 +471,7 @@ async function insertUser(
|
||||
) {
|
||||
// slug 使用用户 UUID,显示名称变化不会影响个人工作空间的稳定标识。
|
||||
const result = await transaction.query<UserRow>(
|
||||
'INSERT INTO atlas.users (email, password_hash, display_name) VALUES ($1, $2, $3) RETURNING id, email, display_name, email_verified_at, created_at, status, password_hash',
|
||||
'INSERT INTO atlas.users (email, password_hash, display_name) VALUES ($1, $2, $3) RETURNING id, email, display_name, avatar_asset_id, email_verified_at, created_at, revision, status, password_hash',
|
||||
[email, passwordHash, displayName],
|
||||
);
|
||||
return result.rows[0];
|
||||
|
||||
@@ -70,6 +70,18 @@ const environmentSchema = z.object({
|
||||
.min(1)
|
||||
.max(10_000)
|
||||
.default(30),
|
||||
AUTH_CHANGE_PASSWORD_IP_LIMIT: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1_000)
|
||||
.default(10),
|
||||
AUTH_CHANGE_PASSWORD_USER_LIMIT: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1_000)
|
||||
.default(5),
|
||||
TRUSTED_PROXY_CIDRS: z.string().default(''),
|
||||
SWAGGER_ENABLED: z.enum(['true', 'false']).default('true'),
|
||||
LOG_LEVEL: z.enum(['error', 'warn', 'log', 'debug']).default('log'),
|
||||
|
||||
@@ -9,8 +9,8 @@ import { Redis } from 'ioredis';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||
|
||||
type AuthAction = 'login' | 'register' | 'refresh';
|
||||
type Limit = { ip: number; email?: number };
|
||||
type AuthAction = 'login' | 'register' | 'refresh' | 'change_password';
|
||||
type Limit = { ip: number; email?: number; user?: number };
|
||||
|
||||
@Injectable()
|
||||
export class AuthRateLimiter implements OnModuleInit, OnModuleDestroy {
|
||||
@@ -32,7 +32,7 @@ export class AuthRateLimiter implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
public async consume(
|
||||
action: AuthAction,
|
||||
identifiers: { ip: string | null; email?: string },
|
||||
identifiers: { ip: string | null; email?: string; userId?: string },
|
||||
): Promise<void> {
|
||||
const limit = this.limitFor(action);
|
||||
await this.consumeBucket(
|
||||
@@ -49,6 +49,14 @@ export class AuthRateLimiter implements OnModuleInit, OnModuleDestroy {
|
||||
limit.email,
|
||||
);
|
||||
}
|
||||
if (identifiers.userId && limit.user) {
|
||||
await this.consumeBucket(
|
||||
action,
|
||||
'user',
|
||||
identifiers.userId,
|
||||
limit.user,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async onModuleDestroy(): Promise<void> {
|
||||
@@ -61,7 +69,7 @@ export class AuthRateLimiter implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
private async consumeBucket(
|
||||
action: AuthAction,
|
||||
dimension: 'ip' | 'email',
|
||||
dimension: 'ip' | 'email' | 'user',
|
||||
identifier: string,
|
||||
limit: number,
|
||||
): Promise<void> {
|
||||
@@ -112,6 +120,12 @@ export class AuthRateLimiter implements OnModuleInit, OnModuleDestroy {
|
||||
email: this.config.AUTH_REGISTER_EMAIL_LIMIT,
|
||||
};
|
||||
}
|
||||
if (action === 'change_password') {
|
||||
return {
|
||||
ip: this.config.AUTH_CHANGE_PASSWORD_IP_LIMIT,
|
||||
user: this.config.AUTH_CHANGE_PASSWORD_USER_LIMIT,
|
||||
};
|
||||
}
|
||||
return { ip: this.config.AUTH_REFRESH_IP_LIMIT };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ type WarehouseRow = {
|
||||
@Injectable()
|
||||
export class StoresService {
|
||||
public constructor(
|
||||
@Inject(IdempotencyService)
|
||||
private readonly idempotency: IdempotencyService,
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
|
||||
@@ -19,7 +19,13 @@ type ErrorResponse = {
|
||||
type SessionPayload = {
|
||||
access_token: string;
|
||||
access_token_expires_in: number;
|
||||
user: { id: string; email: string };
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
avatar_asset_id: string | null;
|
||||
revision: number;
|
||||
};
|
||||
workspace: { id: string; name: string; role: string };
|
||||
};
|
||||
|
||||
@@ -90,6 +96,15 @@ describe('认证 HTTP 集成测试', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
for (const user of testUsers) {
|
||||
await database.workspaceTransaction(
|
||||
user.workspaceId,
|
||||
async (tx) => {
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
|
||||
[user.workspaceId],
|
||||
);
|
||||
},
|
||||
);
|
||||
await database.query(
|
||||
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
||||
[user.userId],
|
||||
@@ -273,4 +288,143 @@ describe('认证 HTTP 集成测试', () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('更新资料遵守版本与幂等约束,改密会撤销旧会话并轮换当前会话', async () => {
|
||||
const accountEmail = email('profile-password');
|
||||
const registration = await request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: accountEmail,
|
||||
password,
|
||||
password_confirmation: password,
|
||||
display_name: '原始名称',
|
||||
}),
|
||||
});
|
||||
const registered = expectSuccess<SessionPayload>(
|
||||
registration.body,
|
||||
).data;
|
||||
testUsers.push({
|
||||
userId: registered.user.id,
|
||||
workspaceId: registered.workspace.id,
|
||||
});
|
||||
|
||||
const profileKey = randomUUID();
|
||||
const update = await request('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${registered.access_token}`,
|
||||
'If-Match': `"${registered.user.revision}"`,
|
||||
'Idempotency-Key': profileKey,
|
||||
},
|
||||
body: JSON.stringify({ display_name: '更新后的名称' }),
|
||||
});
|
||||
assert.equal(update.response.status, 200);
|
||||
const updatedUser = expectSuccess<SessionPayload['user']>(
|
||||
update.body,
|
||||
).data;
|
||||
assert.equal(updatedUser.display_name, '更新后的名称');
|
||||
assert.equal(updatedUser.avatar_asset_id, null);
|
||||
assert.equal(updatedUser.revision, registered.user.revision + 1);
|
||||
|
||||
const replay = await request('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${registered.access_token}`,
|
||||
'If-Match': `"${registered.user.revision}"`,
|
||||
'Idempotency-Key': profileKey,
|
||||
},
|
||||
body: JSON.stringify({ display_name: '更新后的名称' }),
|
||||
});
|
||||
assert.equal(replay.response.status, 200);
|
||||
assert.deepEqual(replay.body, update.body);
|
||||
|
||||
const stale = await request('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${registered.access_token}`,
|
||||
'If-Match': `"${registered.user.revision}"`,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
},
|
||||
body: JSON.stringify({ display_name: '过期修改' }),
|
||||
});
|
||||
assert.equal(stale.response.status, 409);
|
||||
assert.equal(expectError(stale.body).error.code, 'VERSION_CONFLICT');
|
||||
|
||||
const invalidAvatar = await request('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${registered.access_token}`,
|
||||
'If-Match': `"${updatedUser.revision}"`,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
},
|
||||
body: JSON.stringify({ avatar_asset_id: randomUUID() }),
|
||||
});
|
||||
assert.equal(invalidAvatar.response.status, 422);
|
||||
assert.equal(
|
||||
expectError(invalidAvatar.body).error.code,
|
||||
'AVATAR_ASSET_UNAVAILABLE',
|
||||
);
|
||||
|
||||
const secondLogin = await request('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: accountEmail, password }),
|
||||
});
|
||||
const otherSession = expectSuccess<SessionPayload>(
|
||||
secondLogin.body,
|
||||
).data;
|
||||
|
||||
const changed = await request('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${registered.access_token}` },
|
||||
body: JSON.stringify({
|
||||
old_password: password,
|
||||
password: 'new-correct-horse-battery-staple-456',
|
||||
password_confirmation: 'new-correct-horse-battery-staple-456',
|
||||
}),
|
||||
});
|
||||
assert.equal(changed.response.status, 200);
|
||||
const rotated = expectSuccess<{
|
||||
access_token: string;
|
||||
access_token_expires_in: number;
|
||||
}>(changed.body).data;
|
||||
assert.ok(rotated.access_token);
|
||||
refreshCookie(changed.response);
|
||||
|
||||
const formerCurrent = await request('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${registered.access_token}` },
|
||||
});
|
||||
assert.equal(formerCurrent.response.status, 401);
|
||||
const revokedOther = await request('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${otherSession.access_token}` },
|
||||
});
|
||||
assert.equal(revokedOther.response.status, 401);
|
||||
const current = await request('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${rotated.access_token}` },
|
||||
});
|
||||
assert.equal(current.response.status, 200);
|
||||
|
||||
const oldPasswordChange = await request('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${rotated.access_token}` },
|
||||
body: JSON.stringify({
|
||||
old_password: password,
|
||||
password: 'another-correct-horse-battery-staple-789',
|
||||
password_confirmation:
|
||||
'another-correct-horse-battery-staple-789',
|
||||
}),
|
||||
});
|
||||
assert.equal(oldPasswordChange.response.status, 422);
|
||||
assert.equal(
|
||||
expectError(oldPasswordChange.body).error.code,
|
||||
'VALIDATION_FAILED',
|
||||
);
|
||||
const newPasswordLogin = await request('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: accountEmail,
|
||||
password: 'new-correct-horse-battery-staple-456',
|
||||
}),
|
||||
});
|
||||
assert.equal(newPasswordLogin.response.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +1,88 @@
|
||||
/* 全局应用级样式补充(主题变量见 index.css) */
|
||||
|
||||
/* ── 设置类页面(修改资料 / 修改密码):内容区居中卡片 ── */
|
||||
.settings-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.settings-card-title {
|
||||
padding: 20px 32px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.settings-card-body {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.settings-field-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.avatar-upload-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-upload-placeholder {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.avatar-upload-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.avatar-upload-placeholder:hover .avatar-upload-overlay {
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.avatar-preview {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-upload-hint {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
margin: 12px 0 24px;
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@@ -102,22 +102,30 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-user {
|
||||
/* 头像 + 用户名触发区:hover 有浅色背景反馈,暗示可下拉 */
|
||||
.header-user-trigger {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
line-height: 1.25;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.header-user-workspace {
|
||||
.header-user-trigger:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
/* 用户名超长省略 */
|
||||
.header-user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.header-user-email {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.collapse-trigger,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Breadcrumb, Button, Drawer, Layout } from 'antd';
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
|
||||
import { Avatar, Breadcrumb, Drawer, Dropdown, Layout } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
LockOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import SidebarMenu from './SidebarMenu';
|
||||
import { getBreadcrumbItems } from './menuConfig';
|
||||
import { useSession } from '@/services/session';
|
||||
@@ -31,6 +38,9 @@ const MainLayout = () => {
|
||||
[location.pathname]
|
||||
);
|
||||
|
||||
const userDisplayName = session ? (session.user.display_name ?? session.user.email) : '';
|
||||
const userInitial = userDisplayName.charAt(0).toUpperCase();
|
||||
|
||||
const handleLogout = async () => {
|
||||
setLoggingOut(true);
|
||||
try {
|
||||
@@ -41,6 +51,28 @@ const MainLayout = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
if (key === 'profile') {
|
||||
navigate('/settings/profile');
|
||||
} else if (key === 'password') {
|
||||
navigate('/settings/password');
|
||||
} else if (key === 'logout') {
|
||||
void handleLogout();
|
||||
}
|
||||
};
|
||||
|
||||
const userMenuItems: MenuProps['items'] = [
|
||||
{ key: 'profile', icon: <UserOutlined />, label: '修改资料' },
|
||||
{ key: 'password', icon: <LockOutlined />, label: '修改密码' },
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: loggingOut ? '退出中…' : '退出登录',
|
||||
disabled: loggingOut,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
@@ -124,15 +156,21 @@ const MainLayout = () => {
|
||||
</div>
|
||||
{session && (
|
||||
<div className="header-right">
|
||||
<div className="header-user">
|
||||
<span className="header-user-workspace">
|
||||
{session.workspace.name}
|
||||
</span>
|
||||
<span className="header-user-email">{session.user.email}</span>
|
||||
<Dropdown
|
||||
menu={{ items: userMenuItems, onClick: handleUserMenuClick }}
|
||||
trigger={['hover']}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<div className="header-user-trigger" title={userDisplayName}>
|
||||
<Avatar
|
||||
size={28}
|
||||
style={{ backgroundColor: 'var(--sider-bg)' }}
|
||||
>
|
||||
{userInitial}
|
||||
</Avatar>
|
||||
<span className="header-user-name">{userDisplayName}</span>
|
||||
</div>
|
||||
<Button size="small" loading={loggingOut} onClick={handleLogout}>
|
||||
退出登录
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</Header>
|
||||
|
||||
@@ -4,8 +4,11 @@ import {
|
||||
EditOutlined,
|
||||
GlobalOutlined,
|
||||
InboxOutlined,
|
||||
LockOutlined,
|
||||
PictureOutlined,
|
||||
SettingOutlined,
|
||||
ShopOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
@@ -59,7 +62,7 @@ export interface BreadcrumbCrumb {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 页头面包屑:一级菜单页显示单级;商品 AI 编辑页不在菜单中,显示「商品 > 商品编辑」两级 */
|
||||
/** 页头面包屑:一级菜单页显示单级;不在菜单中的页面显示两级 */
|
||||
export const getBreadcrumbItems = (path: string): BreadcrumbCrumb[] => {
|
||||
if (path.startsWith('/products/')) {
|
||||
return [
|
||||
@@ -67,6 +70,18 @@ export const getBreadcrumbItems = (path: string): BreadcrumbCrumb[] => {
|
||||
{ icon: <EditOutlined />, label: '商品编辑' },
|
||||
];
|
||||
}
|
||||
if (path === '/settings/profile') {
|
||||
return [
|
||||
{ icon: <SettingOutlined />, label: '设置' },
|
||||
{ icon: <UserOutlined />, label: '修改资料' },
|
||||
];
|
||||
}
|
||||
if (path === '/settings/password') {
|
||||
return [
|
||||
{ icon: <SettingOutlined />, label: '设置' },
|
||||
{ icon: <LockOutlined />, label: '修改密码' },
|
||||
];
|
||||
}
|
||||
const config = routeMenuConfig.find((item) => path.startsWith(item.path)) ?? routeMenuConfig[0];
|
||||
return [{ icon: config.icon, label: config.label }];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { App as AntdApp, Button, Form, Input } from 'antd';
|
||||
import axios from 'axios';
|
||||
import { authApi } from '@/services/auth';
|
||||
import { authToken } from '@/services/authToken';
|
||||
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
|
||||
|
||||
interface PasswordFormValues {
|
||||
old_password: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码页,契约见 docs/api/auth.md(POST /api/auth/change-password)。
|
||||
* 成功后所有旧 session 失效:立即用响应中的新 access token 替换内存值
|
||||
* (本设备经新的 refresh cookie 保持登录),清理表单状态后回到首页。
|
||||
*/
|
||||
const PasswordSettingsPage = () => {
|
||||
const { message } = AntdApp.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm<PasswordFormValues>();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleFinish = async (values: PasswordFormValues) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await authApi.changePassword({
|
||||
old_password: values.old_password,
|
||||
password: values.password,
|
||||
password_confirmation: values.password_confirmation,
|
||||
});
|
||||
authToken.set(result.access_token);
|
||||
form.resetFields();
|
||||
message.success('密码已修改');
|
||||
navigate('/online-products', { replace: true });
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
message.error('原密码不正确');
|
||||
} else {
|
||||
const details = apiErrorDetails(error);
|
||||
if (details.length > 0) {
|
||||
form.setFields(
|
||||
details.map((item) => ({
|
||||
name: item.field as keyof PasswordFormValues,
|
||||
errors: [item.message],
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
message.error(apiErrorMessage(error));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<div className="settings-card">
|
||||
<div className="settings-card-title">修改密码</div>
|
||||
<div className="settings-card-body">
|
||||
<Form<PasswordFormValues>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
onFinish={handleFinish}
|
||||
className="profile-form"
|
||||
>
|
||||
<Form.Item
|
||||
name="old_password"
|
||||
label="原密码"
|
||||
rules={[{ required: true, message: '请输入原密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="请输入原密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="新密码"
|
||||
extra="至少 12 个字符;长度与复杂度以服务端安全校验为准"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 12, message: '新密码至少需要 12 个字符' },
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="请输入新密码"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password_confirmation"
|
||||
label="确认新密码"
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{ required: true, message: '请再次输入新密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的新密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="请再次输入新密码"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<div className="settings-actions">
|
||||
<Button type="primary" htmlType="submit" loading={submitting}>
|
||||
保存修改
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordSettingsPage;
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { App as AntdApp, Avatar, Button, Form, Input } from 'antd';
|
||||
import axios from 'axios';
|
||||
import { authApi } from '@/services/auth';
|
||||
import { useSession } from '@/services/session';
|
||||
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
|
||||
|
||||
interface ProfileFormValues {
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改资料页,契约见 docs/api/auth.md(PATCH /api/auth/me,If-Match + Idempotency-Key)。
|
||||
* 头像持久化依赖素材预签名直传接口(尚未交付):当前仅展示默认头像的禁用态,不提供上传。
|
||||
*/
|
||||
const ProfileSettingsPage = () => {
|
||||
const { message } = AntdApp.useApp();
|
||||
const { session, applyProfileUpdate } = useSession();
|
||||
const [form] = Form.useForm<ProfileFormValues>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const displayName = session?.user.display_name ?? session?.user.email ?? '';
|
||||
const initial = useMemo(() => displayName.charAt(0).toUpperCase(), [displayName]);
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
const handleSave = async (values: ProfileFormValues) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const user = await authApi.updateProfile(
|
||||
{ display_name: values.display_name.trim() },
|
||||
// 幂等键每次提交重新生成;以提交时的 revision 做乐观锁
|
||||
{ revision: session.user.revision, idempotencyKey: crypto.randomUUID() }
|
||||
);
|
||||
applyProfileUpdate(user);
|
||||
message.success('资料已更新');
|
||||
} catch (error) {
|
||||
// 乐观锁版本过期:服务端返回 409 与当前 revision,提示刷新后重试
|
||||
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||
message.error('资料已被其他操作更新,请刷新页面获取最新资料后重试');
|
||||
} else {
|
||||
const details = apiErrorDetails(error);
|
||||
if (details.length > 0) {
|
||||
form.setFields(
|
||||
details
|
||||
.filter((item) => item.field !== 'If-Match')
|
||||
.map((item) => ({
|
||||
name: item.field as keyof ProfileFormValues,
|
||||
errors: [item.message],
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
message.error(apiErrorMessage(error));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
form.setFieldValue('display_name', session.user.display_name ?? '');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<div className="settings-card">
|
||||
<div className="settings-card-title">修改个人信息</div>
|
||||
<div className="settings-card-body">
|
||||
<div className="settings-field-label">头像</div>
|
||||
<div className="avatar-upload-area">
|
||||
<Avatar size={160} style={{ backgroundColor: 'var(--sider-bg)' }}>
|
||||
{initial}
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="avatar-upload-hint">头像上传即将支持,敬请期待</div>
|
||||
|
||||
<Form<ProfileFormValues>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
initialValues={{ display_name: session.user.display_name ?? '' }}
|
||||
onFinish={handleSave}
|
||||
className="profile-form"
|
||||
>
|
||||
<Form.Item
|
||||
name="display_name"
|
||||
label="昵称"
|
||||
required
|
||||
rules={[{ required: true, message: '请输入昵称' }]}
|
||||
>
|
||||
<Input
|
||||
maxLength={80}
|
||||
showCount
|
||||
placeholder="请输入昵称"
|
||||
autoComplete="nickname"
|
||||
/>
|
||||
</Form.Item>
|
||||
<div className="settings-actions">
|
||||
<Button type="primary" htmlType="submit" loading={saving}>
|
||||
保存修改
|
||||
</Button>
|
||||
<Button onClick={handleReset}>重 置</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSettingsPage;
|
||||
@@ -5,8 +5,10 @@ import SessionSplash from '@/components/SessionSplash';
|
||||
import CollectionPage from '@/pages/collection/CollectionPage';
|
||||
import LoginPage from '@/pages/auth/LoginPage';
|
||||
import OnlineProductsPage from '@/pages/online-products/OnlineProductsPage';
|
||||
import PasswordSettingsPage from '@/pages/settings/PasswordSettingsPage';
|
||||
import PricingPage from '@/pages/pricing/PricingPage';
|
||||
import ProductEditPage from '@/pages/products/ProductEditPage';
|
||||
import ProfileSettingsPage from '@/pages/settings/ProfileSettingsPage';
|
||||
import RegisterPage from '@/pages/auth/RegisterPage';
|
||||
import ShopsPage from '@/pages/shops/ShopsPage';
|
||||
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
|
||||
@@ -26,12 +28,14 @@ function RequireSession({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
|
||||
/** 认证页互斥:已登录访问 /login、/register 时回到主布局 */
|
||||
/** 认证页互斥:已登录访问 /login、/register 时回到来源页或主布局 */
|
||||
function RedirectIfAuthenticated({ children }: { children: ReactNode }) {
|
||||
const { status } = useSession();
|
||||
const location = useLocation();
|
||||
|
||||
if (status === 'authenticated') {
|
||||
return <Navigate to="/" replace />;
|
||||
const from = (location.state as { from?: string } | null)?.from;
|
||||
return <Navigate to={from && from.startsWith('/') ? from : '/'} replace />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
@@ -53,6 +57,9 @@ export const router = createBrowserRouter([
|
||||
{ path: 'pricing', element: <PricingPage /> },
|
||||
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
|
||||
{ path: 'products/:id', element: <ProductEditPage /> },
|
||||
// 个人中心设置页:从页头用户菜单进入,不在左侧菜单中出现
|
||||
{ path: 'settings/profile', element: <ProfileSettingsPage /> },
|
||||
{ path: 'settings/password', element: <PasswordSettingsPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
import type { AuthSessionResponse, LoginRequest, RegisterRequest } from '@atlas/contracts';
|
||||
import type {
|
||||
AuthMeResponse,
|
||||
AuthRefreshResponse,
|
||||
AuthSessionResponse,
|
||||
AuthUser,
|
||||
ChangePasswordRequest,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
UpdateCurrentUserRequest,
|
||||
} from '@atlas/contracts';
|
||||
import { api } from '@/services/api';
|
||||
import type { MeResult, RefreshResult } from '@/types/auth';
|
||||
|
||||
/** 账户与会话接口,契约见 docs/api/auth.md;类型来自 @atlas/contracts(me/refresh 暂为本地临时类型) */
|
||||
export interface UpdateProfileOptions {
|
||||
/** 提交时的乐观锁版本,取自当前会话 user.revision */
|
||||
revision: number;
|
||||
/** 幂等键,重试安全;成功响应携带新 revision */
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
/** 账户与会话接口,契约见 docs/api/auth.md;类型全部来自 @atlas/contracts */
|
||||
export const authApi = {
|
||||
register: (payload: RegisterRequest) =>
|
||||
api.post<AuthSessionResponse>('/auth/register', payload),
|
||||
|
||||
login: (payload: LoginRequest) => api.post<AuthSessionResponse>('/auth/login', payload),
|
||||
|
||||
refresh: () => api.post<RefreshResult>('/auth/refresh'),
|
||||
refresh: () => api.post<AuthRefreshResponse>('/auth/refresh'),
|
||||
|
||||
logout: () => api.post<void>('/auth/logout'),
|
||||
|
||||
me: () => api.get<MeResult>('/auth/me'),
|
||||
me: () => api.get<AuthMeResponse>('/auth/me'),
|
||||
|
||||
/**
|
||||
* 修改资料:If-Match 乐观锁 + Idempotency-Key 幂等(docs/api/conventions.md §5)。
|
||||
* 版本过期返回 409 VERSION_CONFLICT(details.current_revision 为服务端当前版本)。
|
||||
*/
|
||||
updateProfile: (payload: UpdateCurrentUserRequest, options: UpdateProfileOptions) =>
|
||||
api.patch<AuthUser>('/auth/me', payload, {
|
||||
headers: {
|
||||
'If-Match': `"${options.revision}"`,
|
||||
'Idempotency-Key': options.idempotencyKey,
|
||||
},
|
||||
}),
|
||||
|
||||
/** 修改密码:成功后所有旧 session 失效;响应携带新 access token,必须立即替换内存值 */
|
||||
changePassword: (payload: ChangePasswordRequest) =>
|
||||
api.post<AuthRefreshResponse>('/auth/change-password', payload),
|
||||
};
|
||||
|
||||
@@ -9,16 +9,15 @@ import {
|
||||
} from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import axios from 'axios';
|
||||
import type { AuthSessionResponse } from '@atlas/contracts';
|
||||
import type { AuthMeResponse, AuthSessionResponse } from '@atlas/contracts';
|
||||
import { authApi } from '@/services/auth';
|
||||
import { authToken } from '@/services/authToken';
|
||||
import type { MeResult } from '@/types/auth';
|
||||
|
||||
export type SessionStatus = 'restoring' | 'authenticated' | 'anonymous';
|
||||
|
||||
export interface SessionState {
|
||||
user: MeResult['user'];
|
||||
workspace: MeResult['workspace'];
|
||||
user: AuthMeResponse['user'];
|
||||
workspace: AuthMeResponse['workspace'];
|
||||
}
|
||||
|
||||
interface SessionContextValue {
|
||||
@@ -26,6 +25,8 @@ interface SessionContextValue {
|
||||
session: SessionState | null;
|
||||
/** 登录 / 注册成功后写入会话 */
|
||||
applyAuthSession: (payload: AuthSessionResponse) => void;
|
||||
/** 修改资料成功后更新本地会话中的用户信息 */
|
||||
applyProfileUpdate: (user: SessionState['user']) => void;
|
||||
/** 撤销服务端会话并清除本地状态;网络失败也保证本地登出 */
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
@@ -74,6 +75,10 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
setStatus('authenticated');
|
||||
}, []);
|
||||
|
||||
const applyProfileUpdate = useCallback((user: SessionState['user']) => {
|
||||
setSession((prev) => (prev ? { ...prev, user } : prev));
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await authApi.logout();
|
||||
@@ -95,8 +100,8 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ status, session, applyAuthSession, logout }),
|
||||
[status, session, applyAuthSession, logout]
|
||||
() => ({ status, session, applyAuthSession, applyProfileUpdate, logout }),
|
||||
[status, session, applyAuthSession, applyProfileUpdate, logout]
|
||||
);
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* 临时类型:@atlas/contracts 目前只提供认证请求与会话 schema,
|
||||
* refresh / me 的响应 schema 尚未收录(契约缺口已按协作约定第 3 节提报后端 AI)。
|
||||
* 形状对齐 docs/api/auth.md §4/§5;contracts 补齐后删除本文件并改用共享类型。
|
||||
*/
|
||||
import type { AuthUser, Workspace } from '@atlas/contracts';
|
||||
|
||||
/** POST /api/auth/refresh 成功响应(信封 { data } 已在 api 层解包) */
|
||||
export interface RefreshResult {
|
||||
access_token: string;
|
||||
access_token_expires_in: number;
|
||||
}
|
||||
|
||||
export interface PlatformSummary {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** GET /api/auth/me 成功响应;default_store 摘要字段以后续门店接口文档为准 */
|
||||
export interface MeResult {
|
||||
user: AuthUser;
|
||||
workspace: Workspace;
|
||||
platforms: PlatformSummary[];
|
||||
suggested_context: {
|
||||
platform_id: string;
|
||||
default_store: { id: string; name: string } | null;
|
||||
};
|
||||
}
|
||||
+41
-5
@@ -10,8 +10,10 @@
|
||||
"id": "uuid",
|
||||
"email": "owner@example.com",
|
||||
"display_name": null,
|
||||
"avatar_asset_id": null,
|
||||
"email_verified_at": null,
|
||||
"created_at": "2026-09-02T08:00:00Z"
|
||||
"created_at": "2026-09-02T08:00:00Z",
|
||||
"revision": 1
|
||||
},
|
||||
"workspace": {
|
||||
"id": "uuid",
|
||||
@@ -21,7 +23,7 @@
|
||||
}
|
||||
```
|
||||
|
||||
`email` 返回规范化后的地址;密码散列、会话哈希、手机号及任何风控信息永不返回。
|
||||
`email` 返回规范化后的地址;`avatar_asset_id` 是当前头像的素材引用,普通账户响应不返回对象存储 URL。密码散列、会话哈希、手机号及任何风控信息永不返回。
|
||||
|
||||
## 2. 注册
|
||||
|
||||
@@ -50,7 +52,7 @@
|
||||
"data": {
|
||||
"access_token": "eyJ…",
|
||||
"access_token_expires_in": 900,
|
||||
"user": { "id": "uuid", "email": "owner@example.com", "display_name": "Joey", "email_verified_at": null, "created_at": "2026-09-02T08:00:00Z" },
|
||||
"user": { "id": "uuid", "email": "owner@example.com", "display_name": "Joey", "avatar_asset_id": null, "email_verified_at": null, "created_at": "2026-09-02T08:00:00Z", "revision": 1 },
|
||||
"workspace": { "id": "uuid", "name": "Joey 的工作空间", "role": "owner" }
|
||||
}
|
||||
}
|
||||
@@ -86,7 +88,41 @@
|
||||
|
||||
要求 Bearer access token;撤销当前 refresh session、清除 refresh cookie,返回 `204 No Content`。首期不提供“退出所有设备”。
|
||||
|
||||
## 5. 当前用户与初始页头数据
|
||||
## 5. 修改账户资料
|
||||
|
||||
`PATCH /api/auth/me`
|
||||
|
||||
Headers:`If-Match: "{revision}"` 与 `Idempotency-Key: {uuid}` 必填。
|
||||
|
||||
```json
|
||||
{ "display_name": "Joey", "avatar_asset_id": "uuid" }
|
||||
```
|
||||
|
||||
两个字段至少提交一个;传 `null` 可清除对应资料。成功返回 `200` 与更新后的 `user` 资源,`revision` 加一。`avatar_asset_id` 必须是当前工作空间中已完成素材校验的图片资产;接口不接受 URL、对象存储 key 或 base64 内容。版本过期返回 `409 VERSION_CONFLICT`,相同幂等键遵循统一重放规则。
|
||||
|
||||
头像上传复用 `POST /api/media/upload-intents` 的预签名直传和完成校验流程,并使用 `purpose: "user_avatar"`;在素材模块实现前,头像只能保持 `null`,不能以 base64 上传作为替代通道。
|
||||
|
||||
## 6. 修改密码
|
||||
|
||||
`POST /api/auth/change-password`
|
||||
|
||||
```json
|
||||
{
|
||||
"old_password": "old-strong-password",
|
||||
"password": "new-strong-password",
|
||||
"password_confirmation": "new-strong-password"
|
||||
}
|
||||
```
|
||||
|
||||
此接口要求 Bearer access token,并按来源 IP 和当前用户双维度限流。服务端在一个事务内锁定当前会话和用户、校验旧密码、更新密码散列、撤销该用户全部旧 session,并创建新的当前 session。成功返回 `200`、新的 `HttpOnly` refresh cookie 和:
|
||||
|
||||
```json
|
||||
{ "data": { "access_token": "eyJ…", "access_token_expires_in": 900 } }
|
||||
```
|
||||
|
||||
旧 access token 和其他设备的 session 会立即失效。此接口不使用 `Idempotency-Key`:密码变更及 session 轮换不能安全地将 access token 持久化为幂等回放响应;客户端收到网络中断后应以新旧密码重新登录或刷新会话,不得盲目自动重试。
|
||||
|
||||
## 7. 当前用户与初始页头数据
|
||||
|
||||
`GET /api/auth/me`
|
||||
|
||||
@@ -95,7 +131,7 @@
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"user": { "id": "uuid", "email": "owner@example.com", "display_name": "Joey", "email_verified_at": null },
|
||||
"user": { "id": "uuid", "email": "owner@example.com", "display_name": "Joey", "avatar_asset_id": null, "email_verified_at": null, "revision": 1 },
|
||||
"workspace": { "id": "uuid", "name": "Joey 的工作空间", "role": "owner" },
|
||||
"platforms": [{ "id": "uuid", "code": "OZON", "name": "Ozon" }],
|
||||
"suggested_context": {
|
||||
|
||||
@@ -100,6 +100,7 @@ X-Atlas-Platform-Id: 57ad…-uuid
|
||||
## 5. 写操作的幂等、并发和异步
|
||||
|
||||
- 创建店铺、上传意图、删除水印模板等可重试写操作应携带 `Idempotency-Key: <UUID>`。服务端按“当前用户 + 工作空间 + HTTP 方法 + 规范化实际路径 + key”保存至少 24 小时(首期默认 48 小时);请求摘要保存为服务端 HMAC,而非明文请求体。相同键、相同请求返回已保存响应;相同键、不同请求返回 `409 IDEMPOTENCY_KEY_REUSED`;原请求仍在执行返回 `409 IDEMPOTENCY_REQUEST_IN_PROGRESS`。
|
||||
- `PATCH /api/auth/me` 同样要求幂等键。`POST /api/auth/change-password` 是例外:其成功响应包含新 access token,不能将该 token 持久化到幂等回放表;客户端不得自动重试此操作。
|
||||
- 更新可编辑资源时,读取响应携带 `revision`;客户端在 `PATCH`/`PUT` 中提交 `If-Match: "<revision>"`。版本已变化返回 `409 VERSION_CONFLICT` 和最新 `current_revision` 摘要。`revision` 是并发控制版本;不要把水印的渲染 `current_version` 当作它。
|
||||
- 耗时动作(店铺凭据验证、仓库同步、媒体处理)先返回 `202 Accepted`,并在资源中返回 `operation`。客户端轮询 `GET /api/operations/{operation_id}`;后续可追加 SSE,不改变资源接口。
|
||||
- 所有启动异步工作的业务写入,必须在同一 PostgreSQL 事务中写入业务资源/`operation` 和 `outbox_events`;独立 Dispatcher 再投递 BullMQ。BullMQ 和 Redis 不得作为任务存在或最终业务结果的唯一事实。
|
||||
|
||||
@@ -102,6 +102,8 @@ User → Workspace → Membership / Role
|
||||
4. 接着实现素材直传和水印管理,再开始采集箱与商品编辑;浏览器扩展仅在采集 API 稳定后实现实际解析器。
|
||||
5. 在开始商品编辑/在线同步模块前,关闭评审文档中的 P1 事项:同商品归属约束、未映射属性持久化、乐观锁 Repository 规则、平台能力 Schema;并确认在线商品归档策略和 Client ID 编辑边界。
|
||||
|
||||
账户资料的已确认补充:`users.avatar_asset_id` 只保存素材引用,绝不保存公开 URL;`users.revision` 用于 `PATCH /api/auth/me` 的 `If-Match`。资料更新已实现幂等与版本冲突检测;修改密码已实现旧密码校验、按 IP/用户限流、撤销旧会话并轮换新会话。头像上传必须复用素材模块的预签名直传与校验流程,不引入 base64 直传;素材模块尚未实现时头像只能为 `null`。
|
||||
|
||||
## 7. 文档导航
|
||||
|
||||
- [文档总索引](../README.md)
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"dev:backend": "turbo run dev --filter=@atlas/api --filter=@atlas/worker",
|
||||
"free:api-port": "pids=$(lsof -tiTCP:8200 -sTCP:LISTEN); [ -z \"$pids\" ] || kill $pids",
|
||||
"dev:frontend": "turbo run dev --filter=@atlas/web",
|
||||
"lint": "turbo run lint",
|
||||
"typecheck": "turbo run typecheck",
|
||||
|
||||
@@ -10,13 +10,32 @@ export const loginRequestSchema = z.object({
|
||||
email: z.string().trim().email().max(254),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
export const updateCurrentUserRequestSchema = z
|
||||
.object({
|
||||
display_name: z.string().trim().min(1).max(80).nullable().optional(),
|
||||
avatar_asset_id: z.uuid().nullable().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((value) => Object.keys(value).length > 0, {
|
||||
message: '至少需要提交一个可修改字段',
|
||||
});
|
||||
export const changePasswordRequestSchema = z
|
||||
.object({
|
||||
old_password: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
password_confirmation: z.string().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const authUserSchema = z.object({
|
||||
id: z.uuid(),
|
||||
email: z.string().email(),
|
||||
display_name: z.string().nullable(),
|
||||
// 头像文件由素材模块管理,账户接口只提供稳定素材引用,绝不保存对象存储 URL。
|
||||
avatar_asset_id: z.uuid().nullable(),
|
||||
email_verified_at: z.iso.datetime().nullable(),
|
||||
created_at: z.iso.datetime(),
|
||||
revision: z.number().int().positive(),
|
||||
});
|
||||
export const workspaceSchema = z.object({
|
||||
id: z.uuid(),
|
||||
@@ -29,8 +48,29 @@ export const authSessionResponseSchema = z.object({
|
||||
user: authUserSchema,
|
||||
workspace: workspaceSchema,
|
||||
});
|
||||
export const authRefreshResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
access_token_expires_in: z.number().int().positive(),
|
||||
});
|
||||
export const authMeResponseSchema = z.object({
|
||||
user: authUserSchema,
|
||||
workspace: workspaceSchema,
|
||||
platforms: z.array(
|
||||
z.object({ id: z.uuid(), code: z.string(), name: z.string() }),
|
||||
),
|
||||
suggested_context: z.object({
|
||||
platform_id: z.uuid().nullable(),
|
||||
default_store: z.null(),
|
||||
}),
|
||||
});
|
||||
export type RegisterRequest = z.infer<typeof registerRequestSchema>;
|
||||
export type LoginRequest = z.infer<typeof loginRequestSchema>;
|
||||
export type UpdateCurrentUserRequest = z.infer<
|
||||
typeof updateCurrentUserRequestSchema
|
||||
>;
|
||||
export type ChangePasswordRequest = z.infer<typeof changePasswordRequestSchema>;
|
||||
export type AuthUser = z.infer<typeof authUserSchema>;
|
||||
export type Workspace = z.infer<typeof workspaceSchema>;
|
||||
export type AuthSessionResponse = z.infer<typeof authSessionResponseSchema>;
|
||||
export type AuthRefreshResponse = z.infer<typeof authRefreshResponseSchema>;
|
||||
export type AuthMeResponse = z.infer<typeof authMeResponseSchema>;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 用户头像是账户资料的一部分;素材文件仍由受工作空间隔离的 media_assets 管理。
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN avatar_asset_id UUID REFERENCES media_assets(id) ON DELETE SET NULL,
|
||||
ADD COLUMN revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0);
|
||||
|
||||
CREATE INDEX ix_users_avatar_asset ON users (avatar_asset_id)
|
||||
WHERE avatar_asset_id IS NOT NULL;
|
||||
Reference in New Issue
Block a user