feat(invite): support phone number invitation with SMS notification
- TenantInvite entity: email nullable + phone field added - createInvite() auto-detects email vs phone, routes to email/SMS - SmsService: add sendInviteSms() with ALIYUN_SMS_INVITE_TEMPLATE_CODE - acceptInvite(): handle phone-based invites (uniqueness check + insert) - my-org page: email/phone toggle on invite form - /invite/[token] page: display phone or email from invite info - DB migration: phone column added, email made nullable Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e2057bfe68
commit
79d6e0b98a
|
|
@ -41,7 +41,8 @@ const ROLE_LABELS: Record<string, string> = {
|
|||
|
||||
export default function MyOrgPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteMethod, setInviteMethod] = useState<'email' | 'phone'>('email');
|
||||
const [inviteContact, setInviteContact] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState('viewer');
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
|
|
@ -66,13 +67,13 @@ export default function MyOrgPage() {
|
|||
}, []);
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: (body: { email: string; role: string }) =>
|
||||
mutationFn: (body: { email?: string; phone?: string; role: string }) =>
|
||||
apiClient<Invite>('/api/v1/auth/my-org/invite', { method: 'POST', body }),
|
||||
onSuccess: (invite) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['my-org', 'invites'] });
|
||||
const link = `${window.location.origin}/invite/${invite.token}`;
|
||||
setInviteLink(link);
|
||||
setInviteEmail('');
|
||||
setInviteContact('');
|
||||
setInviteError(null);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
|
|
@ -81,8 +82,11 @@ export default function MyOrgPage() {
|
|||
});
|
||||
|
||||
const handleInvite = () => {
|
||||
if (!inviteEmail.trim()) { setInviteError('请填写邮箱'); return; }
|
||||
inviteMutation.mutate({ email: inviteEmail.trim(), role: inviteRole });
|
||||
if (!inviteContact.trim()) { setInviteError(inviteMethod === 'email' ? '请填写邮箱' : '请填写手机号'); return; }
|
||||
const body = inviteMethod === 'email'
|
||||
? { email: inviteContact.trim(), role: inviteRole }
|
||||
: { phone: inviteContact.trim(), role: inviteRole };
|
||||
inviteMutation.mutate(body);
|
||||
};
|
||||
|
||||
const copyLink = () => {
|
||||
|
|
@ -162,12 +166,25 @@ export default function MyOrgPage() {
|
|||
{/* Invite form */}
|
||||
<div className="border rounded-lg p-5 space-y-4 bg-card">
|
||||
<h2 className="text-base font-semibold">邀请成员</h2>
|
||||
{/* Email / Phone toggle */}
|
||||
<div className="flex rounded-md border overflow-hidden w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setInviteMethod('email'); setInviteContact(''); }}
|
||||
className={`px-4 py-1.5 text-xs font-medium transition-colors ${inviteMethod === 'email' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>邮箱邀请</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setInviteMethod('phone'); setInviteContact(''); }}
|
||||
className={`px-4 py-1.5 text-xs font-medium transition-colors ${inviteMethod === 'phone' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>手机邀请</button>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="输入邮箱地址"
|
||||
type={inviteMethod === 'email' ? 'email' : 'tel'}
|
||||
value={inviteContact}
|
||||
onChange={(e) => setInviteContact(e.target.value)}
|
||||
placeholder={inviteMethod === 'email' ? '输入邮箱地址' : '输入手机号'}
|
||||
className="flex-1 px-3 py-2 bg-input border rounded-md text-sm"
|
||||
/>
|
||||
<select
|
||||
|
|
@ -210,7 +227,7 @@ export default function MyOrgPage() {
|
|||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/20">
|
||||
<th className="text-left px-4 py-2 font-medium">邮箱</th>
|
||||
<th className="text-left px-4 py-2 font-medium">邮箱 / 手机</th>
|
||||
<th className="text-left px-4 py-2 font-medium">角色</th>
|
||||
<th className="text-left px-4 py-2 font-medium">过期时间</th>
|
||||
</tr>
|
||||
|
|
@ -218,7 +235,7 @@ export default function MyOrgPage() {
|
|||
<tbody>
|
||||
{pendingInvites.map((inv) => (
|
||||
<tr key={inv.id} className="border-b last:border-b-0">
|
||||
<td className="px-4 py-2">{inv.email}</td>
|
||||
<td className="px-4 py-2">{(inv as any).phone || inv.email}</td>
|
||||
<td className="px-4 py-2">{ROLE_LABELS[inv.role] ?? inv.role}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground text-xs">
|
||||
{new Date(inv.expiresAt).toLocaleDateString('zh-CN')}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import { useTranslation } from 'react-i18next';
|
|||
import { apiClient } from '@/infrastructure/api/api-client';
|
||||
|
||||
interface InviteInfo {
|
||||
email: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
tenantName: string;
|
||||
role: string;
|
||||
expiresAt: string;
|
||||
|
|
@ -122,8 +123,8 @@ export default function AcceptInvitePage() {
|
|||
<span className="font-medium">{invite?.tenantName}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t('inviteEmail')}</span>{' '}
|
||||
<span className="font-medium">{invite?.email}</span>
|
||||
<span className="text-muted-foreground">{invite?.phone ? '手机:' : t('inviteEmail')}</span>{' '}
|
||||
<span className="font-medium">{invite?.phone || invite?.email}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t('inviteRole')}</span>{' '}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ 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 { SmsService } from '../../infrastructure/sms/sms.service';
|
||||
import { User } from '../../domain/entities/user.entity';
|
||||
import { ApiKey } from '../../domain/entities/api-key.entity';
|
||||
import { Tenant } from '../../domain/entities/tenant.entity';
|
||||
|
|
@ -42,6 +43,7 @@ export class AuthService {
|
|||
private readonly tenantProvisioningService: TenantProvisioningService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly smsService: SmsService,
|
||||
) {
|
||||
this.refreshSecret =
|
||||
this.configService.get<string>('JWT_REFRESH_SECRET') ||
|
||||
|
|
@ -393,15 +395,16 @@ export class AuthService {
|
|||
|
||||
async createInvite(
|
||||
tenantId: string,
|
||||
email: string,
|
||||
contact: string, // email or phone
|
||||
role: string,
|
||||
invitedBy: string,
|
||||
appBaseUrl?: string,
|
||||
): Promise<TenantInvite> {
|
||||
// Check tenant exists — tenantId here is the slug (matches user.tenantId in JWT)
|
||||
const isPhone = /^[+\d][\d\s\-().]{6,}$/.test(contact) && !contact.includes('@');
|
||||
|
||||
// Check tenant exists
|
||||
const tenant = await this.tenantRepository.findOneBy({ slug: tenantId });
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Tenant not found');
|
||||
}
|
||||
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||
|
||||
// Check user quota
|
||||
if (tenant.maxUsers !== -1) {
|
||||
|
|
@ -418,34 +421,48 @@ export class AuthService {
|
|||
}
|
||||
}
|
||||
|
||||
// Check for existing pending invite
|
||||
const existing = await this.inviteRepository.findOne({
|
||||
where: { tenantId, email, status: 'pending' as const },
|
||||
});
|
||||
// Check for existing pending invite (by email or phone)
|
||||
const whereClause = isPhone
|
||||
? { tenantId, phone: contact, status: 'pending' as const }
|
||||
: { tenantId, email: contact, status: 'pending' as const };
|
||||
const existing = await this.inviteRepository.findOne({ where: whereClause });
|
||||
if (existing) {
|
||||
throw new ConflictException('An invitation has already been sent to this email');
|
||||
throw new ConflictException(`An invitation has already been sent to ${isPhone ? 'this phone' : 'this email'}`);
|
||||
}
|
||||
|
||||
const invite = this.inviteRepository.create({
|
||||
id: crypto.randomUUID(),
|
||||
tenantId,
|
||||
email,
|
||||
email: isPhone ? null : contact,
|
||||
phone: isPhone ? contact : null,
|
||||
token: crypto.randomBytes(32).toString('hex'),
|
||||
role: role || RoleType.VIEWER,
|
||||
status: 'pending',
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
invitedBy,
|
||||
});
|
||||
|
||||
const saved = await this.inviteRepository.save(invite);
|
||||
const baseUrl = appBaseUrl || process.env.APP_BASE_URL || 'https://it0.szaiai.com';
|
||||
const inviteUrl = `${baseUrl}/invite/${saved.token}`;
|
||||
|
||||
// 发送邀请通知(fire-and-forget,不阻塞响应)
|
||||
this.emailService.sendInviteEmail({
|
||||
to: email,
|
||||
tenantName: tenant.name,
|
||||
role: saved.role,
|
||||
token: saved.token,
|
||||
}).catch((err) => this.logger.error(`邀请邮件发送失败: ${err.message}`));
|
||||
if (isPhone) {
|
||||
// 发短信
|
||||
this.smsService.sendInviteSms({
|
||||
phoneNumber: contact,
|
||||
tenantName: tenant.name,
|
||||
role: saved.role,
|
||||
inviteUrl,
|
||||
}).catch((err) => this.logger.error(`邀请短信发送失败: ${err.message}`));
|
||||
} else {
|
||||
// 发邮件
|
||||
this.emailService.sendInviteEmail({
|
||||
to: contact,
|
||||
tenantName: tenant.name,
|
||||
role: saved.role,
|
||||
token: saved.token,
|
||||
}).catch((err) => this.logger.error(`邀请邮件发送失败: ${err.message}`));
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
|
@ -469,18 +486,15 @@ export class AuthService {
|
|||
}
|
||||
|
||||
async validateInvite(token: string): Promise<{
|
||||
email: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
tenantName: string;
|
||||
role: string;
|
||||
expiresAt: string;
|
||||
}> {
|
||||
const invite = await this.inviteRepository.findOneBy({ token });
|
||||
if (!invite) {
|
||||
throw new NotFoundException('Invalid invitation link');
|
||||
}
|
||||
if (invite.status !== 'pending') {
|
||||
throw new BadRequestException(`Invitation has been ${invite.status}`);
|
||||
}
|
||||
if (!invite) throw new NotFoundException('Invalid invitation link');
|
||||
if (invite.status !== 'pending') throw new BadRequestException(`Invitation has been ${invite.status}`);
|
||||
if (new Date() > invite.expiresAt) {
|
||||
invite.status = 'expired';
|
||||
await this.inviteRepository.save(invite);
|
||||
|
|
@ -490,6 +504,7 @@ export class AuthService {
|
|||
const tenant = await this.tenantRepository.findOneBy({ slug: invite.tenantId });
|
||||
return {
|
||||
email: invite.email,
|
||||
phone: invite.phone,
|
||||
tenantName: tenant?.name || 'Unknown',
|
||||
role: invite.role,
|
||||
expiresAt: invite.expiresAt.toISOString(),
|
||||
|
|
@ -503,7 +518,7 @@ export class AuthService {
|
|||
): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: { id: string; email: string; name: string; roles: string[]; tenantId: string };
|
||||
user: { id: string; email?: string; phone?: string; name: string; roles: string[]; tenantId: string };
|
||||
}> {
|
||||
const invite = await this.inviteRepository.findOneBy({ token });
|
||||
if (!invite || invite.status !== 'pending') {
|
||||
|
|
@ -521,38 +536,38 @@ export class AuthService {
|
|||
throw new BadRequestException('Tenant is not active');
|
||||
}
|
||||
|
||||
const contact = invite.email || invite.phone;
|
||||
const isPhoneInvite = !invite.email && !!invite.phone;
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const userId = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
const schemaName = `it0_t_${tenant.slug}`;
|
||||
|
||||
// Insert user into the tenant's schema
|
||||
const qr = this.dataSource.createQueryRunner();
|
||||
await qr.connect();
|
||||
try {
|
||||
await qr.startTransaction();
|
||||
// Check if email already exists in public.users (cross-tenant uniqueness)
|
||||
const existingPublic = await qr.query(
|
||||
`SELECT id FROM public.users WHERE email = $1 LIMIT 1`,
|
||||
[invite.email],
|
||||
);
|
||||
// Check uniqueness in public.users
|
||||
const existingPublic = isPhoneInvite
|
||||
? await qr.query(`SELECT id FROM public.users WHERE phone = $1 LIMIT 1`, [invite.phone])
|
||||
: await qr.query(`SELECT id FROM public.users WHERE email = $1 LIMIT 1`, [invite.email]);
|
||||
if (existingPublic.length > 0) {
|
||||
throw new ConflictException('Email already registered');
|
||||
throw new ConflictException(isPhoneInvite ? 'Phone already registered' : 'Email already registered');
|
||||
}
|
||||
|
||||
// a. Insert into public.users — enables login via email lookup
|
||||
// a. Insert into public.users
|
||||
await qr.query(
|
||||
`INSERT INTO public.users (id, tenant_id, email, password_hash, name, roles, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[userId, tenant.slug, invite.email, passwordHash, name, [invite.role], true, now, now],
|
||||
`INSERT INTO public.users (id, tenant_id, email, phone, password_hash, name, roles, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[userId, tenant.slug, invite.email ?? null, invite.phone ?? null, passwordHash, name, [invite.role], true, now, now],
|
||||
);
|
||||
|
||||
// b. Insert into tenant schema for tenant-context management
|
||||
// b. Insert into tenant schema
|
||||
await qr.query(`SET LOCAL search_path TO "${schemaName}", public`);
|
||||
await qr.query(
|
||||
`INSERT INTO users (id, tenant_id, email, password_hash, name, roles, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[userId, tenant.slug, invite.email, passwordHash, name, [invite.role], true, now, now],
|
||||
`INSERT INTO users (id, tenant_id, email, phone, password_hash, name, roles, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[userId, tenant.slug, invite.email ?? null, invite.phone ?? null, passwordHash, name, [invite.role], true, now, now],
|
||||
);
|
||||
await qr.commitTransaction();
|
||||
} catch (err) {
|
||||
|
|
@ -570,7 +585,8 @@ export class AuthService {
|
|||
const user = new User();
|
||||
user.id = userId;
|
||||
user.tenantId = tenant.slug;
|
||||
user.email = invite.email;
|
||||
user.email = invite.email ?? undefined;
|
||||
user.phone = invite.phone ?? undefined;
|
||||
user.passwordHash = passwordHash;
|
||||
user.name = name;
|
||||
user.roles = [invite.role];
|
||||
|
|
@ -582,6 +598,7 @@ export class AuthService {
|
|||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
name: user.name,
|
||||
roles: user.roles,
|
||||
tenantId: user.tenantId,
|
||||
|
|
|
|||
|
|
@ -8,8 +8,11 @@ export class TenantInvite {
|
|||
@Column({ type: 'varchar', length: 255 })
|
||||
tenantId!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
email!: string;
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
email!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
phone!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, unique: true })
|
||||
token!: string;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,60 @@ export class SmsService implements OnModuleInit {
|
|||
}
|
||||
}
|
||||
|
||||
async sendInviteSms(params: {
|
||||
phoneNumber: string;
|
||||
tenantName: string;
|
||||
role: string;
|
||||
inviteUrl: string;
|
||||
}): Promise<SmsSendResult> {
|
||||
const { phoneNumber, tenantName, role, inviteUrl } = params;
|
||||
const normalized = this.normalizePhone(phoneNumber);
|
||||
const templateCode = this.configService.get('ALIYUN_SMS_INVITE_TEMPLATE_CODE', '');
|
||||
|
||||
const roleLabel: Record<string, string> = {
|
||||
admin: '管理员', operator: '操作员', viewer: '只读成员',
|
||||
};
|
||||
|
||||
this.logger.log(`[SMS] 发送邀请短信到 ${this.maskPhone(normalized)}`);
|
||||
|
||||
if (!this.enabled || !this.client || !templateCode) {
|
||||
this.logger.warn(`[SMS 模拟] 邀请短信 → ${this.maskPhone(normalized)} | 链接: ${inviteUrl}`);
|
||||
return { success: true, code: 'OK', message: '模拟发送成功' };
|
||||
}
|
||||
|
||||
try {
|
||||
const req = new $Dysmsapi20170525.SendSmsRequest({
|
||||
phoneNumbers: normalized,
|
||||
signName: this.signName,
|
||||
templateCode,
|
||||
templateParam: JSON.stringify({
|
||||
company: tenantName,
|
||||
role: roleLabel[role] || role,
|
||||
url: inviteUrl,
|
||||
}),
|
||||
});
|
||||
const runtime = new $Util.RuntimeOptions({ connectTimeout: 15000, readTimeout: 15000 });
|
||||
const resp = await this.client.sendSmsWithOptions(req, runtime);
|
||||
const body = resp.body;
|
||||
const result: SmsSendResult = {
|
||||
success: body?.code === 'OK',
|
||||
requestId: body?.requestId,
|
||||
bizId: body?.bizId,
|
||||
code: body?.code,
|
||||
message: body?.message,
|
||||
};
|
||||
if (result.success) {
|
||||
this.logger.log(`[SMS] 邀请短信发送成功: requestId=${result.requestId}`);
|
||||
} else {
|
||||
this.logger.error(`[SMS] 邀请短信发送失败: code=${result.code}, msg=${result.message}`);
|
||||
}
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`[SMS] 邀请短信异常: ${error.message}`);
|
||||
return { success: false, code: error.code || 'UNKNOWN_ERROR', message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async sendVerificationCode(phoneNumber: string, code: string): Promise<SmsSendResult> {
|
||||
const normalized = this.normalizePhone(phoneNumber);
|
||||
this.logger.log(`[SMS] 发送验证码到 ${this.maskPhone(normalized)}`);
|
||||
|
|
|
|||
|
|
@ -144,11 +144,13 @@ export class AuthController {
|
|||
@UseGuards(AuthGuard('jwt'))
|
||||
async createMyOrgInvite(
|
||||
@Request() req: any,
|
||||
@Body() body: { email: string; role?: string },
|
||||
@Body() body: { email?: string; phone?: string; role?: string },
|
||||
) {
|
||||
const slug = req.user?.tenantId;
|
||||
const userId = req.user?.sub || req.user?.id;
|
||||
if (!slug) throw new UnauthorizedException('No tenant context');
|
||||
return this.authService.createInvite(slug, body.email, body.role || 'viewer', userId);
|
||||
const contact = body.email || body.phone;
|
||||
if (!contact) throw new BadRequestException('email or phone is required');
|
||||
return this.authService.createInvite(slug, contact, body.role || 'viewer', userId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue