feat(invite): send email notification on invite + QR codes in user management

- Add EmailService (nodemailer/SMTP) with invite email HTML template
- createInvite() now fires email notification after saving (fire-and-forget)
- my-org page: add App download QR code + invite link QR code panels
- Install react-qr-code in web-admin, nodemailer in auth-service

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-09 06:01:22 -07:00
parent d9a785d49d
commit 146b396427
8 changed files with 233 additions and 21 deletions

View File

@ -33,6 +33,7 @@
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"react-hook-form": "^7.51.0", "react-hook-form": "^7.51.0",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",
"react-qr-code": "^2.0.18",
"react-redux": "^9.1.0", "react-redux": "^9.1.0",
"recharts": "^2.12.0", "recharts": "^2.12.0",
"sonner": "^1.4.0", "sonner": "^1.4.0",
@ -7475,6 +7476,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qr.js": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz",
"integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==",
"license": "MIT"
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -7573,6 +7580,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-qr-code": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz",
"integrity": "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1",
"qr.js": "0.0.0"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-redux": { "node_modules/react-redux": {
"version": "9.2.0", "version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",

View File

@ -36,6 +36,7 @@
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"react-hook-form": "^7.51.0", "react-hook-form": "^7.51.0",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",
"react-qr-code": "^2.0.18",
"react-redux": "^9.1.0", "react-redux": "^9.1.0",
"recharts": "^2.12.0", "recharts": "^2.12.0",
"sonner": "^1.4.0", "sonner": "^1.4.0",

View File

@ -1,7 +1,8 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import QRCode from 'react-qr-code';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
interface OrgMember { interface OrgMember {
@ -45,6 +46,7 @@ export default function MyOrgPage() {
const [inviteLink, setInviteLink] = useState<string | null>(null); const [inviteLink, setInviteLink] = useState<string | null>(null);
const [linkCopied, setLinkCopied] = useState(false); const [linkCopied, setLinkCopied] = useState(false);
const [inviteError, setInviteError] = useState<string | null>(null); const [inviteError, setInviteError] = useState<string | null>(null);
const [androidDownloadUrl, setAndroidDownloadUrl] = useState<string | null>(null);
const { data: org, isLoading } = useQuery({ const { data: org, isLoading } = useQuery({
queryKey: ['my-org'], queryKey: ['my-org'],
@ -56,6 +58,13 @@ export default function MyOrgPage() {
queryFn: () => apiClient<Invite[]>('/api/v1/auth/my-org/invites'), queryFn: () => apiClient<Invite[]>('/api/v1/auth/my-org/invites'),
}); });
useEffect(() => {
fetch('/api/app/version/check?platform=android&current_version_code=0')
.then((r) => r.json())
.then((d) => { if (d?.downloadUrl) setAndroidDownloadUrl(d.downloadUrl); })
.catch(() => {});
}, []);
const inviteMutation = useMutation({ const inviteMutation = useMutation({
mutationFn: (body: { email: string; role: string }) => mutationFn: (body: { email: string; role: string }) =>
apiClient<Invite>('/api/v1/auth/my-org/invite', { method: 'POST', body }), apiClient<Invite>('/api/v1/auth/my-org/invite', { method: 'POST', body }),
@ -99,6 +108,57 @@ export default function MyOrgPage() {
</p> </p>
</div> </div>
{/* QR Code row */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* App download QR */}
<div className="border rounded-lg p-5 bg-card flex flex-col items-center gap-3">
<h2 className="text-sm font-semibold self-start">App </h2>
{androidDownloadUrl ? (
<>
<div className="bg-white p-3 rounded-lg">
<QRCode value={androidDownloadUrl} size={140} />
</div>
<p className="text-xs text-muted-foreground text-center"> Android App</p>
<a
href={androidDownloadUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-500 hover:underline truncate max-w-full px-2"
>
{androidDownloadUrl}
</a>
</>
) : (
<div className="flex items-center justify-center h-[160px] text-xs text-muted-foreground">
App
</div>
)}
</div>
{/* Invite link QR */}
<div className="border rounded-lg p-5 bg-card flex flex-col items-center gap-3">
<h2 className="text-sm font-semibold self-start"></h2>
{inviteLink ? (
<>
<div className="bg-white p-3 rounded-lg">
<QRCode value={inviteLink} size={140} />
</div>
<p className="text-xs text-muted-foreground text-center"> {org?.name}</p>
<button
onClick={copyLink}
className="text-xs px-3 py-1 rounded-md border border-input hover:bg-accent"
>
{linkCopied ? '已复制!' : '复制邀请链接'}
</button>
</>
) : (
<div className="flex items-center justify-center h-[160px] text-xs text-muted-foreground">
</div>
)}
</div>
</div>
{/* Invite form */} {/* Invite form */}
<div className="border rounded-lg p-5 space-y-4 bg-card"> <div className="border rounded-lg p-5 space-y-4 bg-card">
<h2 className="text-base font-semibold"></h2> <h2 className="text-base font-semibold"></h2>

View File

@ -10,28 +10,30 @@
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/core": "^10.3.0",
"@nestjs/config": "^3.2.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.0",
"@nestjs/typeorm": "^10.0.0",
"@nestjs/microservices": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"bcryptjs": "^2.4.3",
"typeorm": "^0.3.20",
"pg": "^8.11.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"@it0/common": "workspace:*",
"@it0/database": "workspace:*",
"@alicloud/dysmsapi20170525": "^4.3.1", "@alicloud/dysmsapi20170525": "^4.3.1",
"@alicloud/openapi-client": "^0.4.15", "@alicloud/openapi-client": "^0.4.15",
"@alicloud/tea-util": "^1.4.11", "@alicloud/tea-util": "^1.4.11",
"ioredis": "^5.3.0" "@it0/common": "workspace:*",
"@it0/database": "workspace:*",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.3.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/microservices": "^10.3.0",
"@nestjs/passport": "^10.0.0",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/typeorm": "^10.0.0",
"@types/nodemailer": "^6.4.22",
"bcryptjs": "^2.4.3",
"ioredis": "^5.3.0",
"nodemailer": "^6.10.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.11.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"typeorm": "^0.3.20"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.3.0", "@nestjs/cli": "^10.3.0",

View File

@ -17,6 +17,7 @@ import * as crypto from 'crypto';
import { TenantProvisioningService } from '@it0/database'; import { TenantProvisioningService } from '@it0/database';
import { UserRepository } from '../../infrastructure/repositories/user.repository'; import { UserRepository } from '../../infrastructure/repositories/user.repository';
import { ApiKeyRepository } from '../../infrastructure/repositories/api-key.repository'; import { ApiKeyRepository } from '../../infrastructure/repositories/api-key.repository';
import { EmailService } from '../../infrastructure/email/email.service';
import { User } from '../../domain/entities/user.entity'; import { User } from '../../domain/entities/user.entity';
import { ApiKey } from '../../domain/entities/api-key.entity'; import { ApiKey } from '../../domain/entities/api-key.entity';
import { Tenant } from '../../domain/entities/tenant.entity'; import { Tenant } from '../../domain/entities/tenant.entity';
@ -40,6 +41,7 @@ export class AuthService {
private readonly inviteRepository: Repository<TenantInvite>, private readonly inviteRepository: Repository<TenantInvite>,
private readonly tenantProvisioningService: TenantProvisioningService, private readonly tenantProvisioningService: TenantProvisioningService,
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly emailService: EmailService,
) { ) {
this.refreshSecret = this.refreshSecret =
this.configService.get<string>('JWT_REFRESH_SECRET') || this.configService.get<string>('JWT_REFRESH_SECRET') ||
@ -435,7 +437,17 @@ export class AuthService {
invitedBy, invitedBy,
}); });
return this.inviteRepository.save(invite); const saved = await this.inviteRepository.save(invite);
// 发送邀请通知fire-and-forget不阻塞响应
this.emailService.sendInviteEmail({
to: email,
tenantName: tenant.name,
role: saved.role,
token: saved.token,
}).catch((err) => this.logger.error(`邀请邮件发送失败: ${err.message}`));
return saved;
} }
async listInvites(tenantId: string): Promise<TenantInvite[]> { async listInvites(tenantId: string): Promise<TenantInvite[]> {

View File

@ -18,6 +18,7 @@ import { AuthService } from './application/services/auth.service';
import { UserRepository } from './infrastructure/repositories/user.repository'; import { UserRepository } from './infrastructure/repositories/user.repository';
import { ApiKeyRepository } from './infrastructure/repositories/api-key.repository'; import { ApiKeyRepository } from './infrastructure/repositories/api-key.repository';
import { SmsService } from './infrastructure/sms/sms.service'; import { SmsService } from './infrastructure/sms/sms.service';
import { EmailService } from './infrastructure/email/email.service';
import { RedisProvider } from './infrastructure/redis/redis.provider'; import { RedisProvider } from './infrastructure/redis/redis.provider';
import { User } from './domain/entities/user.entity'; import { User } from './domain/entities/user.entity';
import { Role } from './domain/entities/role.entity'; import { Role } from './domain/entities/role.entity';
@ -49,6 +50,7 @@ import { TenantTagAssignment } from './domain/entities/tenant-tag-assignment.ent
ApiKeyRepository, ApiKeyRepository,
TenantProvisioningService, TenantProvisioningService,
SmsService, SmsService,
EmailService,
RedisProvider, RedisProvider,
], ],
exports: [AuthService], exports: [AuthService],

View File

@ -0,0 +1,109 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private transporter: Transporter | null = null;
private readonly enabled: boolean;
private readonly from: string;
private readonly appBaseUrl: string;
constructor(private readonly configService: ConfigService) {
this.enabled = this.configService.get('EMAIL_ENABLED') === 'true';
this.from = this.configService.get('EMAIL_FROM', 'IT0 <noreply@szaiai.com>');
this.appBaseUrl = this.configService.get('APP_BASE_URL', 'https://it0.szaiai.com');
if (this.enabled) {
this.transporter = nodemailer.createTransport({
host: this.configService.get('EMAIL_HOST'),
port: Number(this.configService.get('EMAIL_PORT', '465')),
secure: this.configService.get('EMAIL_SECURE', 'true') === 'true',
auth: {
user: this.configService.get('EMAIL_USER'),
pass: this.configService.get('EMAIL_PASS'),
},
});
this.logger.log('邮件服务已启用');
} else {
this.logger.warn('邮件服务未配置EMAIL_ENABLED != true将使用日志模式');
}
}
async sendInviteEmail(params: {
to: string;
tenantName: string;
role: string;
token: string;
inviterName?: string;
}): Promise<void> {
const { to, tenantName, role, token, inviterName } = params;
const inviteUrl = `${this.appBaseUrl}/invite/${token}`;
const roleLabel = this.roleLabel(role);
const html = `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f5f5f5;font-family:Arial,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f5;padding:40px 0">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.08)">
<tr><td style="background:#0f172a;padding:32px 40px;text-align:center">
<span style="color:#fff;font-size:24px;font-weight:700;letter-spacing:2px">IT0</span>
<p style="color:#94a3b8;margin:8px 0 0;font-size:13px">AI </p>
</td></tr>
<tr><td style="padding:40px">
<h2 style="margin:0 0 16px;color:#0f172a;font-size:20px"></h2>
<p style="color:#475569;line-height:1.6;margin:0 0 24px">
${inviterName ? `<strong>${inviterName}</strong> 邀请您` : '您被邀请'} <strong>${tenantName}</strong> <strong>${roleLabel}</strong>
</p>
<div style="text-align:center;margin:32px 0">
<a href="${inviteUrl}" style="display:inline-block;background:#3b82f6;color:#fff;text-decoration:none;padding:14px 36px;border-radius:6px;font-size:15px;font-weight:600"></a>
</div>
<p style="color:#94a3b8;font-size:13px;line-height:1.6">
<br>
<span style="color:#3b82f6;word-break:break-all">${inviteUrl}</span>
</p>
<hr style="border:none;border-top:1px solid #e2e8f0;margin:32px 0">
<p style="color:#94a3b8;font-size:12px;margin:0"> 7 </p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
const text = `您被邀请加入 ${tenantName}(角色:${roleLabel}\n\n接受邀请${inviteUrl}\n\n邀请链接有效期 7 天。`;
if (!this.enabled || !this.transporter) {
this.logger.log(`[Email 模拟] 邀请邮件 → ${to} | 链接: ${inviteUrl}`);
return;
}
try {
await this.transporter.sendMail({
from: this.from,
to,
subject: `您已被邀请加入 ${tenantName} — IT0`,
html,
text,
});
this.logger.log(`[Email] 邀请邮件已发送 → ${to}`);
} catch (error: any) {
this.logger.error(`[Email] 发送失败 → ${to}: ${error.message}`);
// 不抛出,不因邮件失败影响邀请创建
}
}
private roleLabel(role: string): string {
const labels: Record<string, string> = {
admin: '管理员',
operator: '操作员',
viewer: '只读成员',
};
return labels[role] || role;
}
}

View File

@ -238,12 +238,18 @@ importers:
'@nestjs/typeorm': '@nestjs/typeorm':
specifier: ^10.0.0 specifier: ^10.0.0
version: 10.0.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.28(ioredis@5.9.2)(pg@8.18.0)) version: 10.0.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.28(ioredis@5.9.2)(pg@8.18.0))
'@types/nodemailer':
specifier: ^6.4.22
version: 6.4.22
bcryptjs: bcryptjs:
specifier: ^2.4.3 specifier: ^2.4.3
version: 2.4.3 version: 2.4.3
ioredis: ioredis:
specifier: ^5.3.0 specifier: ^5.3.0
version: 5.9.2 version: 5.9.2
nodemailer:
specifier: ^6.10.1
version: 6.10.1
passport: passport:
specifier: ^0.7.0 specifier: ^0.7.0
version: 0.7.0 version: 0.7.0