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:
parent
d9a785d49d
commit
146b396427
|
|
@ -33,6 +33,7 @@
|
|||
"react-dom": "^18.3.0",
|
||||
"react-hook-form": "^7.51.0",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-redux": "^9.1.0",
|
||||
"recharts": "^2.12.0",
|
||||
"sonner": "^1.4.0",
|
||||
|
|
@ -7475,6 +7476,12 @@
|
|||
"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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
|
@ -7573,6 +7580,19 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
"react-dom": "^18.3.0",
|
||||
"react-hook-form": "^7.51.0",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-redux": "^9.1.0",
|
||||
"recharts": "^2.12.0",
|
||||
"sonner": "^1.4.0",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import QRCode from 'react-qr-code';
|
||||
import { apiClient } from '@/infrastructure/api/api-client';
|
||||
|
||||
interface OrgMember {
|
||||
|
|
@ -45,6 +46,7 @@ export default function MyOrgPage() {
|
|||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
const [inviteError, setInviteError] = useState<string | null>(null);
|
||||
const [androidDownloadUrl, setAndroidDownloadUrl] = useState<string | null>(null);
|
||||
|
||||
const { data: org, isLoading } = useQuery({
|
||||
queryKey: ['my-org'],
|
||||
|
|
@ -56,6 +58,13 @@ export default function MyOrgPage() {
|
|||
queryFn: () => apiClient<Invite[]>('/api/v1/auth/my-org/invites'),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/app/version/check?platform=android¤t_version_code=0')
|
||||
.then((r) => r.json())
|
||||
.then((d) => { if (d?.downloadUrl) setAndroidDownloadUrl(d.downloadUrl); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: (body: { email: string; role: string }) =>
|
||||
apiClient<Invite>('/api/v1/auth/my-org/invite', { method: 'POST', body }),
|
||||
|
|
@ -99,6 +108,57 @@ export default function MyOrgPage() {
|
|||
</p>
|
||||
</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 */}
|
||||
<div className="border rounded-lg p-5 space-y-4 bg-card">
|
||||
<h2 className="text-base font-semibold">邀请成员</h2>
|
||||
|
|
|
|||
|
|
@ -10,28 +10,30 @@
|
|||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"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/openapi-client": "^0.4.15",
|
||||
"@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": {
|
||||
"@nestjs/cli": "^10.3.0",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import * as crypto from 'crypto';
|
|||
import { TenantProvisioningService } from '@it0/database';
|
||||
import { UserRepository } from '../../infrastructure/repositories/user.repository';
|
||||
import { ApiKeyRepository } from '../../infrastructure/repositories/api-key.repository';
|
||||
import { EmailService } from '../../infrastructure/email/email.service';
|
||||
import { User } from '../../domain/entities/user.entity';
|
||||
import { ApiKey } from '../../domain/entities/api-key.entity';
|
||||
import { Tenant } from '../../domain/entities/tenant.entity';
|
||||
|
|
@ -40,6 +41,7 @@ export class AuthService {
|
|||
private readonly inviteRepository: Repository<TenantInvite>,
|
||||
private readonly tenantProvisioningService: TenantProvisioningService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly emailService: EmailService,
|
||||
) {
|
||||
this.refreshSecret =
|
||||
this.configService.get<string>('JWT_REFRESH_SECRET') ||
|
||||
|
|
@ -435,7 +437,17 @@ export class AuthService {
|
|||
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[]> {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { AuthService } from './application/services/auth.service';
|
|||
import { UserRepository } from './infrastructure/repositories/user.repository';
|
||||
import { ApiKeyRepository } from './infrastructure/repositories/api-key.repository';
|
||||
import { SmsService } from './infrastructure/sms/sms.service';
|
||||
import { EmailService } from './infrastructure/email/email.service';
|
||||
import { RedisProvider } from './infrastructure/redis/redis.provider';
|
||||
import { User } from './domain/entities/user.entity';
|
||||
import { Role } from './domain/entities/role.entity';
|
||||
|
|
@ -49,6 +50,7 @@ import { TenantTagAssignment } from './domain/entities/tenant-tag-assignment.ent
|
|||
ApiKeyRepository,
|
||||
TenantProvisioningService,
|
||||
SmsService,
|
||||
EmailService,
|
||||
RedisProvider,
|
||||
],
|
||||
exports: [AuthService],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -238,12 +238,18 @@ importers:
|
|||
'@nestjs/typeorm':
|
||||
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))
|
||||
'@types/nodemailer':
|
||||
specifier: ^6.4.22
|
||||
version: 6.4.22
|
||||
bcryptjs:
|
||||
specifier: ^2.4.3
|
||||
version: 2.4.3
|
||||
ioredis:
|
||||
specifier: ^5.3.0
|
||||
version: 5.9.2
|
||||
nodemailer:
|
||||
specifier: ^6.10.1
|
||||
version: 6.10.1
|
||||
passport:
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.0
|
||||
|
|
|
|||
Loading…
Reference in New Issue