From 146b396427d32a3a53efcccdad10040630b03f38 Mon Sep 17 00:00:00 2001
From: hailin
Date: Mon, 9 Mar 2026 06:01:22 -0700
Subject: [PATCH] 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
---
it0-web-admin/package-lock.json | 20 ++++
it0-web-admin/package.json | 1 +
it0-web-admin/src/app/(admin)/my-org/page.tsx | 62 +++++++++-
packages/services/auth-service/package.json | 40 ++++---
.../src/application/services/auth.service.ts | 14 ++-
.../services/auth-service/src/auth.module.ts | 2 +
.../src/infrastructure/email/email.service.ts | 109 ++++++++++++++++++
pnpm-lock.yaml | 6 +
8 files changed, 233 insertions(+), 21 deletions(-)
create mode 100644 packages/services/auth-service/src/infrastructure/email/email.service.ts
diff --git a/it0-web-admin/package-lock.json b/it0-web-admin/package-lock.json
index 82426f1..f0a73ce 100644
--- a/it0-web-admin/package-lock.json
+++ b/it0-web-admin/package-lock.json
@@ -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",
diff --git a/it0-web-admin/package.json b/it0-web-admin/package.json
index 982a208..d9746e8 100644
--- a/it0-web-admin/package.json
+++ b/it0-web-admin/package.json
@@ -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",
diff --git a/it0-web-admin/src/app/(admin)/my-org/page.tsx b/it0-web-admin/src/app/(admin)/my-org/page.tsx
index dce5878..d667c37 100644
--- a/it0-web-admin/src/app/(admin)/my-org/page.tsx
+++ b/it0-web-admin/src/app/(admin)/my-org/page.tsx
@@ -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(null);
const [linkCopied, setLinkCopied] = useState(false);
const [inviteError, setInviteError] = useState(null);
+ const [androidDownloadUrl, setAndroidDownloadUrl] = useState(null);
const { data: org, isLoading } = useQuery({
queryKey: ['my-org'],
@@ -56,6 +58,13 @@ export default function MyOrgPage() {
queryFn: () => apiClient('/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('/api/v1/auth/my-org/invite', { method: 'POST', body }),
@@ -99,6 +108,57 @@ export default function MyOrgPage() {
+ {/* QR Code row */}
+
+ {/* App download QR */}
+
+
App 下载二维码
+ {androidDownloadUrl ? (
+ <>
+
+
+
+
扫码下载 Android App
+
+ {androidDownloadUrl}
+
+ >
+ ) : (
+
+ 暂无 App 版本
+
+ )}
+
+
+ {/* Invite link QR */}
+
+
邀请链接二维码
+ {inviteLink ? (
+ <>
+
+
+
+
扫码加入 {org?.name}
+
+ >
+ ) : (
+
+ 发送邀请后此处显示二维码
+
+ )}
+
+
+
{/* Invite form */}
邀请成员
diff --git a/packages/services/auth-service/package.json b/packages/services/auth-service/package.json
index f753d3e..00f5366 100644
--- a/packages/services/auth-service/package.json
+++ b/packages/services/auth-service/package.json
@@ -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",
diff --git a/packages/services/auth-service/src/application/services/auth.service.ts b/packages/services/auth-service/src/application/services/auth.service.ts
index e695d63..00670cb 100644
--- a/packages/services/auth-service/src/application/services/auth.service.ts
+++ b/packages/services/auth-service/src/application/services/auth.service.ts
@@ -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
,
private readonly tenantProvisioningService: TenantProvisioningService,
private readonly dataSource: DataSource,
+ private readonly emailService: EmailService,
) {
this.refreshSecret =
this.configService.get('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 {
diff --git a/packages/services/auth-service/src/auth.module.ts b/packages/services/auth-service/src/auth.module.ts
index 7da080e..a239af5 100644
--- a/packages/services/auth-service/src/auth.module.ts
+++ b/packages/services/auth-service/src/auth.module.ts
@@ -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],
diff --git a/packages/services/auth-service/src/infrastructure/email/email.service.ts b/packages/services/auth-service/src/infrastructure/email/email.service.ts
new file mode 100644
index 0000000..250e24a
--- /dev/null
+++ b/packages/services/auth-service/src/infrastructure/email/email.service.ts
@@ -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 ');
+ 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 {
+ const { to, tenantName, role, token, inviterName } = params;
+ const inviteUrl = `${this.appBaseUrl}/invite/${token}`;
+ const roleLabel = this.roleLabel(role);
+
+ const html = `
+
+
+
+
+
+
+
+ |
+ IT0
+ AI 智能体运维平台
+ |
+
+ 您收到一份团队邀请
+
+ ${inviterName ? `${inviterName} 邀请您` : '您被邀请'}加入 ${tenantName},担任 ${roleLabel} 角色。
+
+
+
+ 或复制以下链接到浏览器打开:
+ ${inviteUrl}
+
+
+ 邀请链接有效期 7 天。如非本人操作请忽略此邮件。
+ |
+
+ |
+
+
+`;
+
+ 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 = {
+ admin: '管理员',
+ operator: '操作员',
+ viewer: '只读成员',
+ };
+ return labels[role] || role;
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 368f761..29f362b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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