Skip to content

Commit 091a107

Browse files
feat: Stage 9 pre-launch essentials — auth, security, compliance, and UX improvements
P0 (launch blockers): - Add Helmet security headers with CSP configuration - Add health check endpoint (GET /health) via @nestjs/terminus - Add 404/500 error pages and global error boundaries - Add email service (Nodemailer + Handlebars templates) - Add email verification on registration - Add forgot password / password reset flow - Add privacy policy and terms of service pages with registration consent P1 (user retention): - Add OAuth login (Google/GitHub) via Passport strategies - Add JWT refresh token rotation (15min access + rotating refresh) - Add new user onboarding modal (4-step guided flow) - Add email notifications for mentions, comments, invitations, shares - Add account deletion with password confirmation - Add mobile responsive layout with collapsible sidebar P2 (polish): - Add space ownership transfer endpoint - Add reusable Breadcrumb component - Add cookie consent banner with localStorage persistence - Add CI/CD pipeline (.github/workflows/ci.yml) - Add structured logging via nestjs-pino - Remove AI copilot ghost text / Tab autocomplete from editor Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 85b1eeb commit 091a107

55 files changed

Lines changed: 5184 additions & 61 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [master]
6+
pull_request:
7+
branches: [master]
8+
9+
jobs:
10+
ci:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Setup pnpm
17+
uses: pnpm/action-setup@v4
18+
with:
19+
version: 9
20+
21+
- name: Setup Node.js
22+
uses: actions/setup-node@v4
23+
with:
24+
node-version: 20
25+
cache: pnpm
26+
27+
- name: Install dependencies
28+
run: pnpm install --frozen-lockfile
29+
30+
- name: Generate Prisma Client
31+
run: pnpm exec prisma generate
32+
working-directory: apps/api
33+
34+
- name: Lint
35+
run: pnpm lint
36+
37+
- name: Type check
38+
run: pnpm typecheck
39+
40+
- name: Build
41+
run: pnpm build
42+
43+
- name: Test
44+
run: pnpm --filter @docStudio/api test --passWithNoTests

apps/api/.env.example

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,19 @@ COLLAB_PORT=1234
1717
# Redis (optional for multi-instance collaboration via @hocuspocus/extension-redis)
1818
REDIS_HOST=localhost
1919
REDIS_PORT=6379
20+
21+
# Email (SMTP)
22+
SMTP_HOST=smtp.example.com
23+
SMTP_PORT=587
24+
SMTP_SECURE=false
25+
SMTP_USER=
26+
SMTP_PASS=
27+
SMTP_FROM="DocStudio" <noreply@docstudio.app>
28+
29+
# OAuth (optional — leave empty to disable)
30+
GOOGLE_CLIENT_ID=
31+
GOOGLE_CLIENT_SECRET=
32+
GITHUB_CLIENT_ID=
33+
GITHUB_CLIENT_SECRET=
34+
API_URL=http://localhost:3001
35+
FRONTEND_URL=http://localhost:3000

apps/api/nest-cli.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
"collection": "@nestjs/schematics",
44
"sourceRoot": "src",
55
"compilerOptions": {
6-
"deleteOutDir": true
6+
"deleteOutDir": true,
7+
"assets": [
8+
{
9+
"include": "email/templates/**/*.hbs",
10+
"watchAssets": true
11+
}
12+
]
713
}
814
}

apps/api/package.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,15 @@
2424
"dependencies": {
2525
"@fastify/compress": "^8.3.1",
2626
"@fastify/cors": "^11.2.0",
27+
"@fastify/helmet": "^13.0.2",
2728
"@fastify/multipart": "^9.4.0",
2829
"@fastify/static": "^8.3.0",
2930
"@fastify/swagger": "^9.6.1",
3031
"@fastify/swagger-ui": "^5.2.5",
3132
"@hocuspocus/extension-database": "^3.4.4",
3233
"@hocuspocus/extension-redis": "^3.4.4",
3334
"@hocuspocus/server": "^3.4.4",
35+
"@nestjs-modules/mailer": "^2.3.4",
3436
"@nestjs/common": "^11.0.1",
3537
"@nestjs/core": "^11.0.1",
3638
"@nestjs/jwt": "^11.0.2",
@@ -39,6 +41,7 @@
3941
"@nestjs/platform-fastify": "^11.0.1",
4042
"@nestjs/serve-static": "^5.0.4",
4143
"@nestjs/swagger": "^11.2.5",
44+
"@nestjs/terminus": "^11.1.1",
4245
"@nestjs/throttler": "^6.5.0",
4346
"@prisma/client": "5.22.0",
4447
"@types/minio": "^7.1.1",
@@ -47,11 +50,17 @@
4750
"class-validator": "^0.14.3",
4851
"fastify": "^5.7.4",
4952
"fastify-multer": "^2.0.3",
53+
"handlebars": "^4.7.8",
5054
"minio": "^8.0.6",
55+
"nestjs-pino": "^4.6.1",
56+
"nodemailer": "^8.0.4",
5157
"openai": "^6.32.0",
5258
"passport": "^0.7.0",
59+
"passport-github2": "^0.1.12",
60+
"passport-google-oauth20": "^2.0.0",
5361
"passport-jwt": "^4.0.1",
5462
"passport-local": "^1.0.0",
63+
"pino-http": "^11.0.0",
5564
"prisma": "5.22.0",
5665
"reflect-metadata": "^0.2.2",
5766
"rxjs": "^7.8.1",
@@ -68,6 +77,9 @@
6877
"@types/express": "^5.0.0",
6978
"@types/jest": "^30.0.0",
7079
"@types/node": "^22.10.7",
80+
"@types/nodemailer": "^7.0.11",
81+
"@types/passport-github2": "^1.2.9",
82+
"@types/passport-google-oauth20": "^2.0.17",
7183
"@types/passport-jwt": "^4.0.1",
7284
"@types/passport-local": "^1.0.38",
7385
"@types/sharp": "^0.32.0",
@@ -78,6 +90,7 @@
7890
"eslint-plugin-prettier": "^5.2.2",
7991
"globals": "^16.0.0",
8092
"jest": "^30.0.0",
93+
"pino-pretty": "^13.1.3",
8194
"prettier": "^3.4.2",
8295
"source-map-support": "^0.5.21",
8396
"supertest": "^7.0.0",
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- AlterTable
2+
ALTER TABLE "users" ADD COLUMN "emailVerified" BOOLEAN NOT NULL DEFAULT false,
3+
ADD COLUMN "emailVerifyToken" TEXT,
4+
ADD COLUMN "githubId" TEXT,
5+
ADD COLUMN "googleId" TEXT,
6+
ADD COLUMN "onboardingCompleted" BOOLEAN NOT NULL DEFAULT false,
7+
ADD COLUMN "refreshToken" TEXT,
8+
ADD COLUMN "resetToken" TEXT,
9+
ADD COLUMN "resetTokenExpiry" TIMESTAMP(3);
10+
11+
-- CreateIndex
12+
CREATE UNIQUE INDEX "users_emailVerifyToken_key" ON "users"("emailVerifyToken");
13+
14+
-- CreateIndex
15+
CREATE UNIQUE INDEX "users_resetToken_key" ON "users"("resetToken");
16+
17+
-- CreateIndex
18+
CREATE UNIQUE INDEX "users_googleId_key" ON "users"("googleId");
19+
20+
-- CreateIndex
21+
CREATE UNIQUE INDEX "users_githubId_key" ON "users"("githubId");

apps/api/prisma/schema.prisma

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,29 @@ model User {
1515
id String @id @default(cuid())
1616
email String @unique
1717
name String
18-
password String? // 可选:如果使用 GitHub OAuth 则为空
18+
password String? // 可选:如果使用 OAuth 则为空
1919
avatarUrl String? // 用户头像 URL
2020
isSuperAdmin Boolean @default(false) // 平台级超级管理员
2121
isDisabled Boolean @default(false) // 账号禁用状态
22+
23+
// 邮箱验证
24+
emailVerified Boolean @default(false)
25+
emailVerifyToken String? @unique
26+
27+
// 密码重置
28+
resetToken String? @unique
29+
resetTokenExpiry DateTime?
30+
31+
// OAuth
32+
googleId String? @unique
33+
githubId String? @unique
34+
35+
// 新用户引导
36+
onboardingCompleted Boolean @default(false)
37+
38+
// JWT Refresh Token
39+
refreshToken String?
40+
2241
createdAt DateTime @default(now())
2342
updatedAt DateTime @updatedAt
2443

apps/api/src/app.module.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import { ActivityModule } from './activity/activity.module';
2222
import { TemplatesModule } from './templates/templates.module';
2323
import { NotificationsModule } from './notifications/notifications.module';
2424
import { AiModule } from './ai/ai.module';
25+
import { HealthModule } from './health/health.module';
26+
import { EmailModule } from './email/email.module';
27+
import { LoggerModule } from 'nestjs-pino';
2528

2629
@Module({
2730
imports: [
@@ -49,6 +52,18 @@ import { AiModule } from './ai/ai.module';
4952
TemplatesModule,
5053
NotificationsModule,
5154
AiModule,
55+
HealthModule,
56+
EmailModule,
57+
LoggerModule.forRoot({
58+
pinoHttp: {
59+
transport:
60+
process.env.NODE_ENV !== 'production'
61+
? { target: 'pino-pretty', options: { colorize: true, singleLine: true } }
62+
: undefined,
63+
level: process.env.NODE_ENV !== 'production' ? 'debug' : 'info',
64+
autoLogging: false, // 避免记录每个 HTTP 请求(太嘈杂)
65+
},
66+
}),
5267
],
5368
controllers: [AppController],
5469
providers: [

apps/api/src/auth/auth.controller.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import {
33
Post,
44
Body,
55
Get,
6+
Query,
7+
Req,
8+
Res,
69
UseGuards,
710
HttpCode,
811
HttpStatus,
@@ -12,13 +15,20 @@ import {
1215
ApiOperation,
1316
ApiResponse,
1417
ApiBearerAuth,
18+
ApiExcludeEndpoint,
1519
} from '@nestjs/swagger';
20+
// Fastify types imported as values (not 'import type') for decorator metadata
1621
import { AuthService } from './auth.service';
1722
import { RegisterDto } from './dto/register.dto';
1823
import { LoginDto } from './dto/login.dto';
1924
import { AuthResponseDto, UserResponseDto } from './dto/auth-response.dto';
2025
import { ChangePasswordDto } from './dto/change-password.dto';
26+
import { ForgotPasswordDto } from './dto/forgot-password.dto';
27+
import { ResetPasswordDto } from './dto/reset-password.dto';
28+
import { DeleteAccountDto } from './dto/delete-account.dto';
2129
import { JwtAuthGuard } from './guards/jwt-auth.guard';
30+
import { GoogleAuthGuard } from './guards/google-auth.guard';
31+
import { GithubAuthGuard } from './guards/github-auth.guard';
2232
import { CurrentUser } from '../common/decorators/current-user.decorator';
2333

2434
@ApiTags('auth')
@@ -79,4 +89,120 @@ export class AuthController {
7989
) {
8090
return this.authService.changePassword(user.id, changePasswordDto);
8191
}
92+
93+
// ==================== 邮箱验证 ====================
94+
95+
@Get('verify-email')
96+
@ApiOperation({ summary: '验证邮箱', description: '通过邮件链接验证邮箱地址' })
97+
@ApiResponse({ status: 200, description: '验证成功' })
98+
@ApiResponse({ status: 400, description: '无效的验证令牌' })
99+
async verifyEmail(@Query('token') token: string) {
100+
return this.authService.verifyEmail(token);
101+
}
102+
103+
@Post('resend-verification')
104+
@UseGuards(JwtAuthGuard)
105+
@ApiBearerAuth('JWT-auth')
106+
@HttpCode(HttpStatus.OK)
107+
@ApiOperation({ summary: '重新发送验证邮件' })
108+
@ApiResponse({ status: 200, description: '验证邮件已发送' })
109+
async resendVerification(@CurrentUser() user: UserResponseDto) {
110+
return this.authService.resendVerification(user.id);
111+
}
112+
113+
// ==================== 密码重置 ====================
114+
115+
@Post('forgot-password')
116+
@HttpCode(HttpStatus.OK)
117+
@ApiOperation({ summary: '忘记密码', description: '发送密码重置邮件' })
118+
@ApiResponse({ status: 200, description: '如果邮箱存在将发送重置邮件' })
119+
async forgotPassword(@Body() dto: ForgotPasswordDto) {
120+
return this.authService.forgotPassword(dto.email);
121+
}
122+
123+
@Post('reset-password')
124+
@HttpCode(HttpStatus.OK)
125+
@ApiOperation({ summary: '重置密码', description: '使用重置令牌设置新密码' })
126+
@ApiResponse({ status: 200, description: '密码重置成功' })
127+
@ApiResponse({ status: 400, description: '无效或已过期的重置令牌' })
128+
async resetPassword(@Body() dto: ResetPasswordDto) {
129+
return this.authService.resetPassword(dto.token, dto.newPassword);
130+
}
131+
132+
// ==================== 账号删除 ====================
133+
134+
@Post('delete-account')
135+
@UseGuards(JwtAuthGuard)
136+
@ApiBearerAuth('JWT-auth')
137+
@HttpCode(HttpStatus.OK)
138+
@ApiOperation({ summary: '删除账号', description: '永久删除当前用户账号及所有数据' })
139+
@ApiResponse({ status: 200, description: '账号已删除' })
140+
@ApiResponse({ status: 401, description: '密码错误' })
141+
async deleteAccount(
142+
@CurrentUser() user: UserResponseDto,
143+
@Body() dto: DeleteAccountDto,
144+
) {
145+
return this.authService.deleteAccount(user.id, dto.password);
146+
}
147+
148+
// ==================== Refresh Token ====================
149+
150+
@Post('refresh')
151+
@HttpCode(HttpStatus.OK)
152+
@ApiOperation({ summary: '刷新令牌', description: '使用 refresh_token 获取新的 access_token' })
153+
@ApiResponse({ status: 200, description: '刷新成功' })
154+
@ApiResponse({ status: 401, description: '无效的刷新令牌' })
155+
async refreshToken(@Body('refresh_token') refreshToken: string) {
156+
return this.authService.refreshTokens(refreshToken);
157+
}
158+
159+
// ==================== OAuth 登录 ====================
160+
161+
@Get('google')
162+
@UseGuards(GoogleAuthGuard)
163+
@ApiExcludeEndpoint()
164+
googleLogin() {
165+
// Guard 会自动重定向到 Google
166+
}
167+
168+
@Get('google/callback')
169+
@UseGuards(GoogleAuthGuard)
170+
@ApiExcludeEndpoint()
171+
async googleCallback(@Req() req: any, @Res() res: any) {
172+
const profile = req.user;
173+
const result = await this.authService.oauthLogin({
174+
googleId: profile.googleId,
175+
email: profile.email,
176+
name: profile.name,
177+
avatarUrl: profile.avatarUrl,
178+
});
179+
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
180+
return res.redirect(
181+
`${frontendUrl}/auth/oauth-callback?token=${result.access_token}`,
182+
);
183+
}
184+
185+
@Get('github')
186+
@UseGuards(GithubAuthGuard)
187+
@ApiExcludeEndpoint()
188+
githubLogin() {
189+
// Guard 会自动重定向到 GitHub
190+
}
191+
192+
@Get('github/callback')
193+
@UseGuards(GithubAuthGuard)
194+
@ApiExcludeEndpoint()
195+
async githubCallback(@Req() req: any, @Res() res: any) {
196+
const profile = req.user;
197+
const result = await this.authService.oauthLogin({
198+
githubId: profile.githubId,
199+
email: profile.email,
200+
name: profile.name,
201+
avatarUrl: profile.avatarUrl,
202+
});
203+
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
204+
return res.redirect(
205+
`${frontendUrl}/auth/oauth-callback?token=${result.access_token}`,
206+
);
207+
}
82208
}

0 commit comments

Comments
 (0)