feat(auth): add phone registration support + enterprise register page redesign

- User entity: email nullable, add phone field (nullable unique)
- AuthService/Controller: login/register accept email OR phone
- UserRepository: findByPhone(), findByIdentifier() (auto-detects email vs phone)
- Migration 007: ALTER public.users + all existing tenant schemas to add phone
- Tenant schema template (002): users table now includes phone column
- Register page: enterprise-focused design, email/phone toggle, app download section
- Auth i18n (zh/en): new keys for phone, enterprise messaging, download CTA

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-07 03:14:47 -08:00
parent 67691fc24d
commit 96bf5e7390
10 changed files with 666 additions and 130 deletions

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -9,20 +9,36 @@ import { apiClient } from '@/infrastructure/api/api-client';
interface RegisterResponse { interface RegisterResponse {
accessToken: string; accessToken: string;
refreshToken: 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 };
} }
type LoginMethod = 'email' | 'phone';
export default function RegisterPage() { export default function RegisterPage() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation('auth'); const { t } = useTranslation('auth');
const { t: tc } = useTranslation('common'); const { t: tc } = useTranslation('common');
const [loginMethod, setLoginMethod] = useState<LoginMethod>('email');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [companyName, setCompanyName] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [companyName, setCompanyName] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [appDownloadUrl, setAppDownloadUrl] = useState<{ android?: string; ios?: string }>({});
useEffect(() => {
// Try to fetch latest app version for download links
fetch('/api/app/version/check?platform=android&current_version_code=0')
.then((r) => r.json())
.then((d) => {
if (d?.downloadUrl) setAppDownloadUrl((prev) => ({ ...prev, android: d.downloadUrl }));
})
.catch(() => {});
}, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -31,7 +47,11 @@ export default function RegisterPage() {
return; return;
} }
if (password.length < 6) { if (password.length < 6) {
setError(t('passwordMinLength', 'Password must be at least 6 characters')); setError('密码至少 6 位');
return;
}
if (!companyName.trim()) {
setError('请填写企业名称');
return; return;
} }
@ -39,14 +59,20 @@ export default function RegisterPage() {
setError(null); setError(null);
try { try {
const body: Record<string, string> = {
password,
name,
companyName,
};
if (loginMethod === 'email') {
body.email = email;
} else {
body.phone = phone;
}
const data = await apiClient<RegisterResponse>('/api/v1/auth/register', { const data = await apiClient<RegisterResponse>('/api/v1/auth/register', {
method: 'POST', method: 'POST',
body: { body,
email,
password,
name,
...(companyName ? { companyName } : {}),
},
}); });
localStorage.setItem('access_token', data.accessToken); localStorage.setItem('access_token', data.accessToken);
@ -58,107 +84,181 @@ export default function RegisterPage() {
if (payload.tenantId) { if (payload.tenantId) {
localStorage.setItem('current_tenant', JSON.stringify({ id: payload.tenantId })); localStorage.setItem('current_tenant', JSON.stringify({ id: payload.tenantId }));
} }
} catch { /* ignore decode errors */ } } catch { /* ignore */ }
router.push('/dashboard'); router.push('/dashboard');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed'); setError(err instanceof Error ? err.message : '注册失败,请重试');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
return ( return (
<div className="w-full max-w-md p-8 space-y-6 bg-card rounded-lg border"> <div className="w-full max-w-lg space-y-6">
{/* Header */}
<div className="text-center"> <div className="text-center">
<img src="/icons/logo.svg" alt="iAgent" className="w-20 h-20 mx-auto mb-2" /> <img src="/icons/logo.svg" alt="iAgent" className="w-16 h-16 mx-auto mb-3" />
<h1 className="text-3xl font-bold">{t('appTitle')}</h1> <h1 className="text-2xl font-bold">{t('enterpriseRegTitle')}</h1>
<p className="text-muted-foreground mt-2">{t('createAccount')}</p> <p className="text-muted-foreground mt-2 text-sm leading-relaxed">{t('enterpriseRegSubtitle')}</p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> {/* Registration Card */}
<div className="p-8 bg-card rounded-lg border space-y-5">
{/* Login method toggle */}
<div> <div>
<label className="block text-sm font-medium mb-1">{t('fullName')}</label> <label className="block text-sm font-medium mb-2">{t('loginMethod')}</label>
<input <div className="flex rounded-md border overflow-hidden">
type="text" <button
value={name} type="button"
onChange={(e) => setName(e.target.value)} onClick={() => setLoginMethod('email')}
className="w-full px-3 py-2 bg-input border rounded-md text-sm" className={`flex-1 py-2 text-sm font-medium transition-colors ${
placeholder={t('fullNamePlaceholder')} loginMethod === 'email'
required ? 'bg-primary text-primary-foreground'
/> : 'bg-transparent text-muted-foreground hover:text-foreground'
}`}
>
{t('loginByEmail')}
</button>
<button
type="button"
onClick={() => setLoginMethod('phone')}
className={`flex-1 py-2 text-sm font-medium transition-colors ${
loginMethod === 'phone'
? 'bg-primary text-primary-foreground'
: 'bg-transparent text-muted-foreground hover:text-foreground'
}`}
>
{t('loginByPhone')}
</button>
</div>
</div> </div>
<div> <form onSubmit={handleSubmit} className="space-y-4">
<label className="block text-sm font-medium mb-1">{t('email')}</label> {/* Identifier field */}
<input {loginMethod === 'email' ? (
type="email" <div>
value={email} <label className="block text-sm font-medium mb-1">{t('email')}</label>
onChange={(e) => setEmail(e.target.value)} <input
className="w-full px-3 py-2 bg-input border rounded-md text-sm" type="email"
placeholder={t('emailPlaceholder')} value={email}
required onChange={(e) => setEmail(e.target.value)}
/> className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder={t('emailPlaceholder')}
required
/>
</div>
) : (
<div>
<label className="block text-sm font-medium mb-1">{t('phone')}</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder={t('phonePlaceholder')}
required
/>
</div>
)}
{/* Full name */}
<div>
<label className="block text-sm font-medium mb-1">{t('fullName')}</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder={t('fullNamePlaceholder')}
required
/>
</div>
{/* Company name — required for enterprise */}
<div>
<label className="block text-sm font-medium mb-1">{t('organizationName')}</label>
<input
type="text"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder={t('organizationNamePlaceholder')}
required
/>
<p className="text-xs text-muted-foreground mt-1">{t('organizationHint')}</p>
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium mb-1">{t('password')}</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('confirmPassword')}</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
required
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<button
type="submit"
disabled={isLoading}
className="w-full py-2.5 bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50 text-sm font-medium"
>
{isLoading ? t('creatingAccount') : t('register')}
</button>
</form>
<p className="text-center text-sm text-muted-foreground">
{t('haveAccount')}{' '}
<Link href="/login" className="text-primary hover:underline">
{t('signInLink')}
</Link>
</p>
</div>
{/* App download section */}
<div className="p-5 bg-muted/50 rounded-lg border border-dashed space-y-3">
<div className="flex items-center gap-2">
<span className="text-lg">📱</span>
<h3 className="text-sm font-semibold">{t('downloadApp')}</h3>
</div> </div>
<p className="text-xs text-muted-foreground leading-relaxed">{t('downloadAppDesc')}</p>
<div> <div className="flex gap-3">
<label className="block text-sm font-medium mb-1"> {appDownloadUrl.android ? (
{t('organizationName')} <a
<span className="text-muted-foreground font-normal ml-1">({tc('optional')})</span> href={appDownloadUrl.android}
</label> className="flex-1 py-2 text-center text-xs font-medium bg-card border rounded-md hover:bg-accent transition-colors"
<input >
type="text" {t('downloadAndroid')}
value={companyName} </a>
onChange={(e) => setCompanyName(e.target.value)} ) : (
className="w-full px-3 py-2 bg-input border rounded-md text-sm" <Link
placeholder={t('organizationNamePlaceholder')} href="/app-versions"
/> className="flex-1 py-2 text-center text-xs font-medium bg-card border rounded-md hover:bg-accent transition-colors"
<p className="text-xs text-muted-foreground mt-1"> >
{t('organizationHint')} {t('downloadAndroid')}
</p> </Link>
)}
<span className="flex-1 py-2 text-center text-xs font-medium bg-card border rounded-md text-muted-foreground cursor-not-allowed">
{t('downloadIOS')} (线)
</span>
</div> </div>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('password')}</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('confirmPassword')}</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
required
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<button
type="submit"
disabled={isLoading}
className="w-full py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50 text-sm font-medium"
>
{isLoading ? t('creatingAccount') : t('register')}
</button>
</form>
<p className="text-center text-sm text-muted-foreground">
{t('haveAccount')}{' '}
<Link href="/login" className="text-primary hover:underline">
{t('signInLink')}
</Link>
</p>
</div> </div>
); );
} }

View File

@ -6,21 +6,32 @@
"email": "Email", "email": "Email",
"password": "Password", "password": "Password",
"emailPlaceholder": "admin@example.com", "emailPlaceholder": "admin@example.com",
"noAccount": "Don't have an account?", "noAccount": "No enterprise account yet?",
"createOne": "Create one", "createOne": "Register now",
"haveAccount": "Already have an account?", "haveAccount": "Already have an account?",
"signInLink": "Sign in", "signInLink": "Sign in",
"enterpriseRegTitle": "Enterprise Registration",
"enterpriseRegSubtitle": "Create an iAgent account for your enterprise. Employees use the mobile app to access AI operations services.",
"createAccount": "Create your account", "createAccount": "Create your account",
"createAccountSubtitle": "Set up a new organization to get started.", "createAccountSubtitle": "Set up a new organization to get started.",
"loginMethod": "Login method",
"loginByEmail": "Email",
"loginByPhone": "Phone",
"fullName": "Full Name", "fullName": "Full Name",
"fullNamePlaceholder": "John Doe", "fullNamePlaceholder": "John Doe",
"organizationName": "Organization Name", "phone": "Phone Number",
"phonePlaceholder": "+1 555 000 0000",
"organizationName": "Company Name",
"organizationNamePlaceholder": "Acme Corp", "organizationNamePlaceholder": "Acme Corp",
"organizationHint": "A new tenant will be created for your organization.", "organizationHint": "An isolated tenant workspace will be created for your company.",
"confirmPassword": "Confirm Password", "confirmPassword": "Confirm Password",
"creatingAccount": "Creating account...", "creatingAccount": "Creating account...",
"register": "Create Account", "register": "Create Enterprise Account",
"loginFailed": "Login failed", "loginFailed": "Login failed",
"downloadApp": "Download Employee App",
"downloadAppDesc": "After registration, invite your employees to download the app and start using AI-powered operations.",
"downloadAndroid": "Android Download",
"downloadIOS": "iOS Download",
"inviteTitle": "Accept Invitation", "inviteTitle": "Accept Invitation",
"inviteSubtitle": "You've been invited to join", "inviteSubtitle": "You've been invited to join",
"inviteRole": "Role:", "inviteRole": "Role:",

View File

@ -6,21 +6,32 @@
"email": "邮箱", "email": "邮箱",
"password": "密码", "password": "密码",
"emailPlaceholder": "admin@example.com", "emailPlaceholder": "admin@example.com",
"noAccount": "还没有账号?", "noAccount": "还没有企业账号?",
"createOne": "立即注册", "createOne": "立即开户",
"haveAccount": "已有账号?", "haveAccount": "已有账号?",
"signInLink": "去登录", "signInLink": "去登录",
"enterpriseRegTitle": "企业开户",
"enterpriseRegSubtitle": "为您的企业创建 iAgent 账号,员工通过手机 App 使用智能体服务。",
"createAccount": "创建账号", "createAccount": "创建账号",
"createAccountSubtitle": "注册新组织,开始使用。", "createAccountSubtitle": "注册新组织,开始使用。",
"loginMethod": "登录方式",
"loginByEmail": "邮箱",
"loginByPhone": "手机号",
"fullName": "姓名", "fullName": "姓名",
"fullNamePlaceholder": "张三", "fullNamePlaceholder": "张三",
"organizationName": "组织名称", "phone": "手机号",
"organizationNamePlaceholder": "示例公司", "phonePlaceholder": "+86 138 0000 0000",
"organizationHint": "将为您的组织创建一个新租户。", "organizationName": "企业名称",
"organizationNamePlaceholder": "示例科技有限公司",
"organizationHint": "将为您的企业创建独立的租户空间。",
"confirmPassword": "确认密码", "confirmPassword": "确认密码",
"creatingAccount": "创建中...", "creatingAccount": "开户中...",
"register": "创建账号", "register": "立即开户",
"loginFailed": "登录失败", "loginFailed": "登录失败",
"downloadApp": "下载员工 App",
"downloadAppDesc": "企业账号注册完成后,邀请员工下载 App 即可使用 AI 智能运维服务。",
"downloadAndroid": "Android 下载",
"downloadIOS": "iOS 下载",
"inviteTitle": "接受邀请", "inviteTitle": "接受邀请",
"inviteSubtitle": "您已被邀请加入", "inviteSubtitle": "您已被邀请加入",
"inviteRole": "角色:", "inviteRole": "角色:",

View File

@ -47,14 +47,14 @@ export class AuthService {
} }
async login( async login(
email: string, identifier: string,
password: string, password: string,
): Promise<{ ): Promise<{
accessToken: string; accessToken: string;
refreshToken: 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 user = await this.userRepository.findByEmail(email); const user = await this.userRepository.findByIdentifier(identifier);
if (!user || !user.isActive) { if (!user || !user.isActive) {
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException('Invalid credentials');
} }
@ -74,6 +74,7 @@ export class AuthService {
user: { user: {
id: user.id, id: user.id,
email: user.email, email: user.email,
phone: user.phone,
name: user.name, name: user.name,
roles: user.roles, roles: user.roles,
tenantId: user.tenantId, tenantId: user.tenantId,
@ -88,26 +89,35 @@ export class AuthService {
* Otherwise joins the default tenant as viewer. * Otherwise joins the default tenant as viewer.
*/ */
async register( async register(
email: string,
password: string, password: string,
name: string, name: string,
companyName?: string, companyName?: string,
email?: string,
phone?: string,
): Promise<{ ): Promise<{
accessToken: string; accessToken: string;
refreshToken: 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 };
}> { }> {
// Check email uniqueness across all schemas (public users table check) if (!email && !phone) {
const existing = await this.userRepository.findByEmail(email); throw new BadRequestException('Email or phone is required');
if (existing) { }
throw new ConflictException('Email already registered');
// Check uniqueness
if (email) {
const existing = await this.userRepository.findByEmail(email);
if (existing) throw new ConflictException('Email already registered');
}
if (phone) {
const existing = await this.userRepository.findByPhone(phone);
if (existing) throw new ConflictException('Phone number already registered');
} }
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
if (companyName) { if (companyName) {
// Self-service: create tenant + provision schema + create admin user // Self-service: create tenant + provision schema + create admin user
return this.registerWithNewTenant(email, passwordHash, name, companyName); return this.registerWithNewTenant(passwordHash, name, companyName, email, phone);
} }
// Default: join default tenant as viewer // Default: join default tenant as viewer
@ -115,6 +125,7 @@ export class AuthService {
user.id = crypto.randomUUID(); user.id = crypto.randomUUID();
user.tenantId = 'default'; user.tenantId = 'default';
user.email = email; user.email = email;
user.phone = phone;
user.passwordHash = passwordHash; user.passwordHash = passwordHash;
user.name = name; user.name = name;
user.roles = [RoleType.VIEWER]; user.roles = [RoleType.VIEWER];
@ -130,6 +141,7 @@ export class AuthService {
user: { user: {
id: user.id, id: user.id,
email: user.email, email: user.email,
phone: user.phone,
name: user.name, name: user.name,
roles: user.roles, roles: user.roles,
tenantId: user.tenantId, tenantId: user.tenantId,
@ -138,10 +150,11 @@ export class AuthService {
} }
private async registerWithNewTenant( private async registerWithNewTenant(
email: string,
passwordHash: string, passwordHash: string,
name: string, name: string,
companyName: string, companyName: string,
email?: string,
phone?: string,
) { ) {
// Generate slug from company name (underscores for valid PostgreSQL schema names) // Generate slug from company name (underscores for valid PostgreSQL schema names)
const slug = companyName const slug = companyName
@ -192,9 +205,9 @@ export class AuthService {
try { try {
await qr.query(`SET search_path TO "${schemaName}", public`); await qr.query(`SET search_path TO "${schemaName}", public`);
await qr.query( await qr.query(
`INSERT INTO users (id, tenant_id, email, password_hash, name, roles, is_active, created_at, updated_at) `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)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[userId, slug, email, passwordHash, name, [RoleType.ADMIN], true, now, now], [userId, slug, email ?? null, phone ?? null, passwordHash, name, [RoleType.ADMIN], true, now, now],
); );
} finally { } finally {
await qr.release(); await qr.release();
@ -205,6 +218,7 @@ export class AuthService {
user.id = userId; user.id = userId;
user.tenantId = slug; user.tenantId = slug;
user.email = email; user.email = email;
user.phone = phone;
user.passwordHash = passwordHash; user.passwordHash = passwordHash;
user.name = name; user.name = name;
user.roles = [RoleType.ADMIN]; user.roles = [RoleType.ADMIN];
@ -216,6 +230,7 @@ export class AuthService {
user: { user: {
id: user.id, id: user.id,
email: user.email, email: user.email,
phone: user.phone,
name: user.name, name: user.name,
roles: user.roles, roles: user.roles,
tenantId: user.tenantId, tenantId: user.tenantId,

View File

@ -8,8 +8,11 @@ export class User {
@Column({ type: 'varchar', length: 20 }) @Column({ type: 'varchar', length: 20 })
tenantId!: string; tenantId!: string;
@Column({ type: 'varchar', length: 255, unique: true }) @Column({ type: 'varchar', length: 255, nullable: true, unique: true })
email!: string; email?: string;
@Column({ type: 'varchar', length: 30, nullable: true, unique: true })
phone?: string;
@Column({ type: 'varchar', length: 255 }) @Column({ type: 'varchar', length: 255 })
passwordHash!: string; passwordHash!: string;

View File

@ -14,6 +14,20 @@ export class UserRepository {
return this.repo.findOneBy({ email }); return this.repo.findOneBy({ email });
} }
async findByPhone(phone: string): Promise<User | null> {
return this.repo.findOneBy({ phone });
}
/** Find by email or phone (for login with either identifier) */
async findByIdentifier(identifier: string): Promise<User | null> {
const isPhone = /^[+\d][\d\s\-().]{6,}$/.test(identifier);
if (isPhone) {
const byPhone = await this.findByPhone(identifier);
if (byPhone) return byPhone;
}
return this.findByEmail(identifier);
}
async findById(id: string): Promise<User | null> { async findById(id: string): Promise<User | null> {
return this.repo.findOneBy({ id }); return this.repo.findOneBy({ id });
} }

View File

@ -7,24 +7,38 @@ export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@Post('login') @Post('login')
async login(@Body() body: { email: string; password: string }) { async login(
return this.authService.login(body.email, body.password); @Body() body: { identifier?: string; email?: string; phone?: string; password: string },
) {
const identifier = body.identifier || body.email || body.phone;
if (!identifier) {
throw new Error('Email or phone is required');
}
return this.authService.login(identifier, body.password);
} }
/** /**
* Register a new user. * Register a new enterprise account.
* Supports email or phone as the login identifier.
* If companyName is provided, creates a new tenant (self-service registration). * If companyName is provided, creates a new tenant (self-service registration).
* Otherwise joins the default tenant as viewer.
*/ */
@Post('register') @Post('register')
async register( async register(
@Body() body: { email: string; password: string; name: string; companyName?: string }, @Body()
body: {
email?: string;
phone?: string;
password: string;
name: string;
companyName?: string;
},
) { ) {
return this.authService.register( return this.authService.register(
body.email,
body.password, body.password,
body.name, body.name,
body.companyName, body.companyName,
body.email,
body.phone,
); );
} }

View File

@ -287,14 +287,17 @@ CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id);
CREATE TABLE users ( CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(20) NOT NULL, tenant_id VARCHAR(20) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE, email VARCHAR(255) NULL,
phone VARCHAR(30) NULL,
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
roles VARCHAR(30)[] NOT NULL DEFAULT '{viewer}', roles VARCHAR(30)[] NOT NULL DEFAULT '{viewer}',
is_active BOOLEAN NOT NULL DEFAULT TRUE, is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_login_at TIMESTAMPTZ, last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT users_email_unique UNIQUE (email),
CONSTRAINT users_phone_unique UNIQUE (phone)
); );
CREATE INDEX idx_users_active ON users(is_active) WHERE is_active = TRUE; CREATE INDEX idx_users_active ON users(is_active) WHERE is_active = TRUE;

View File

@ -0,0 +1,38 @@
-- Add phone field to users tables (public schema + all tenant schemas)
-- phone is nullable, unique when present
-- 1. Public schema users table (auth-service managed, platform admins & default tenant)
ALTER TABLE public.users
ALTER COLUMN email DROP NOT NULL,
ADD COLUMN IF NOT EXISTS phone VARCHAR(30) NULL;
-- Partial unique index: only enforce uniqueness when phone is not null
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_phone_unique
ON public.users (phone) WHERE phone IS NOT NULL;
-- 2. Apply to all existing tenant schemas dynamically
DO $$
DECLARE
schema_rec RECORD;
BEGIN
FOR schema_rec IN
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name LIKE 'it0_t_%'
LOOP
EXECUTE format(
'ALTER TABLE %I.users ALTER COLUMN email DROP NOT NULL',
schema_rec.schema_name
);
EXECUTE format(
'ALTER TABLE %I.users ADD COLUMN IF NOT EXISTS phone VARCHAR(30) NULL',
schema_rec.schema_name
);
EXECUTE format(
'CREATE UNIQUE INDEX IF NOT EXISTS idx_%s_phone_unique ON %I.users (phone) WHERE phone IS NOT NULL',
replace(schema_rec.schema_name, 'it0_t_', ''),
schema_rec.schema_name
);
END LOOP;
END;
$$;

327
scripts/api-test.sh Normal file
View File

@ -0,0 +1,327 @@
#!/usr/bin/env bash
# IT0 API Comprehensive Curl Test Suite
# Usage: bash scripts/api-test.sh [BASE_URL]
# Default BASE_URL: https://it0api.szaiai.com
set -euo pipefail
BASE="${1:-https://it0api.szaiai.com}"
PASS=0
FAIL=0
TOKEN=""
REFRESH_TOKEN=""
TENANT_ID=""
# ─── colors ───────────────────────────────────────────────────────────────────
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
ok() { echo -e "${GREEN}[PASS]${NC} $1"; ((PASS++)); }
fail() { echo -e "${RED}[FAIL]${NC} $1"; ((FAIL++)); }
info() { echo -e "${CYAN}[INFO]${NC} $1"; }
section() { echo -e "\n${YELLOW}══ $1 ══${NC}"; }
# ─── helpers ──────────────────────────────────────────────────────────────────
get_status() {
curl -s -o /dev/null -w "%{http_code}" "$@"
}
post_json() {
local url="$1"; shift
curl -s -w "\n__STATUS__%{http_code}" -X POST \
-H "Content-Type: application/json" \
"$@" "$url"
}
auth_header() {
echo "-H Authorization: Bearer ${TOKEN}"
}
# ─── Unique test values ────────────────────────────────────────────────────────
TS=$(date +%s)
TEST_EMAIL="apitest_${TS}@example.com"
TEST_PHONE="+861380${TS: -7}"
TEST_COMPANY="TestCo${TS}"
TEST_PASS="Test@12345"
# ══════════════════════════════════════════════════════════════════════════════
section "1. Health Checks"
STATUS=$(get_status "${BASE}/health" 2>/dev/null || echo "000")
[[ "$STATUS" == "200" ]] && ok "GET /health → 200" || fail "GET /health → ${STATUS}"
# ══════════════════════════════════════════════════════════════════════════════
section "2. Auth — Register (Email)"
RESP=$(post_json "${BASE}/api/v1/auth/register" \
-d "{\"email\":\"${TEST_EMAIL}\",\"password\":\"${TEST_PASS}\",\"name\":\"Test User\",\"companyName\":\"${TEST_COMPANY}\"}")
STATUS=$(echo "$RESP" | grep -o '__STATUS__[0-9]*' | cut -d_ -f3)
BODY=$(echo "$RESP" | sed 's/__STATUS__[0-9]*//')
if [[ "$STATUS" == "201" || "$STATUS" == "200" ]]; then
ok "POST /api/v1/auth/register (email) → ${STATUS}"
TOKEN=$(echo "$BODY" | grep -o '"accessToken":"[^"]*"' | cut -d'"' -f4)
REFRESH_TOKEN=$(echo "$BODY" | grep -o '"refreshToken":"[^"]*"' | cut -d'"' -f4)
TENANT_ID=$(echo "$BODY" | grep -o '"tenantId":"[^"]*"' | cut -d'"' -f4)
info "Tenant: ${TENANT_ID}"
else
fail "POST /api/v1/auth/register (email) → ${STATUS}: ${BODY}"
fi
# ══════════════════════════════════════════════════════════════════════════════
section "3. Auth — Register (Phone)"
PHONE_COMPANY="PhoneCo${TS}"
RESP=$(post_json "${BASE}/api/v1/auth/register" \
-d "{\"phone\":\"${TEST_PHONE}\",\"password\":\"${TEST_PASS}\",\"name\":\"Phone User\",\"companyName\":\"${PHONE_COMPANY}\"}")
STATUS=$(echo "$RESP" | grep -o '__STATUS__[0-9]*' | cut -d_ -f3)
BODY=$(echo "$RESP" | sed 's/__STATUS__[0-9]*//')
if [[ "$STATUS" == "201" || "$STATUS" == "200" ]]; then
ok "POST /api/v1/auth/register (phone) → ${STATUS}"
else
fail "POST /api/v1/auth/register (phone) → ${STATUS}: ${BODY}"
fi
# ══════════════════════════════════════════════════════════════════════════════
section "4. Auth — Duplicate registration should fail"
RESP=$(post_json "${BASE}/api/v1/auth/register" \
-d "{\"email\":\"${TEST_EMAIL}\",\"password\":\"${TEST_PASS}\",\"name\":\"Dup\",\"companyName\":\"Dup${TS}\"}")
STATUS=$(echo "$RESP" | grep -o '__STATUS__[0-9]*' | cut -d_ -f3)
[[ "$STATUS" == "409" ]] && ok "Duplicate email → 409 Conflict" || fail "Duplicate email → expected 409, got ${STATUS}"
# ══════════════════════════════════════════════════════════════════════════════
section "5. Auth — Login (email)"
RESP=$(post_json "${BASE}/api/v1/auth/login" \
-d "{\"email\":\"${TEST_EMAIL}\",\"password\":\"${TEST_PASS}\"}")
STATUS=$(echo "$RESP" | grep -o '__STATUS__[0-9]*' | cut -d_ -f3)
BODY=$(echo "$RESP" | sed 's/__STATUS__[0-9]*//')
if [[ "$STATUS" == "200" || "$STATUS" == "201" ]]; then
ok "POST /api/v1/auth/login (email) → ${STATUS}"
TOKEN=$(echo "$BODY" | grep -o '"accessToken":"[^"]*"' | cut -d'"' -f4)
REFRESH_TOKEN=$(echo "$BODY" | grep -o '"refreshToken":"[^"]*"' | cut -d'"' -f4)
else
fail "POST /api/v1/auth/login (email) → ${STATUS}: ${BODY}"
fi
# ══════════════════════════════════════════════════════════════════════════════
section "6. Auth — Login (phone)"
RESP=$(post_json "${BASE}/api/v1/auth/login" \
-d "{\"phone\":\"${TEST_PHONE}\",\"password\":\"${TEST_PASS}\"}")
STATUS=$(echo "$RESP" | grep -o '__STATUS__[0-9]*' | cut -d_ -f3)
[[ "$STATUS" == "200" || "$STATUS" == "201" ]] && ok "POST /api/v1/auth/login (phone) → ${STATUS}" \
|| fail "POST /api/v1/auth/login (phone) → ${STATUS}"
# ══════════════════════════════════════════════════════════════════════════════
section "7. Auth — Login (identifier field)"
RESP=$(post_json "${BASE}/api/v1/auth/login" \
-d "{\"identifier\":\"${TEST_EMAIL}\",\"password\":\"${TEST_PASS}\"}")
STATUS=$(echo "$RESP" | grep -o '__STATUS__[0-9]*' | cut -d_ -f3)
[[ "$STATUS" == "200" || "$STATUS" == "201" ]] && ok "POST /api/v1/auth/login (identifier) → ${STATUS}" \
|| fail "POST /api/v1/auth/login (identifier) → ${STATUS}"
# ══════════════════════════════════════════════════════════════════════════════
section "8. Auth — Wrong password should be 401"
RESP=$(post_json "${BASE}/api/v1/auth/login" \
-d "{\"email\":\"${TEST_EMAIL}\",\"password\":\"wrongpass\"}")
STATUS=$(echo "$RESP" | grep -o '__STATUS__[0-9]*' | cut -d_ -f3)
[[ "$STATUS" == "401" ]] && ok "Wrong password → 401" || fail "Wrong password → expected 401, got ${STATUS}"
# ══════════════════════════════════════════════════════════════════════════════
section "9. Auth — Get profile (JWT required)"
if [[ -n "$TOKEN" ]]; then
STATUS=$(get_status "${BASE}/api/v1/auth/profile" \
-H "Authorization: Bearer ${TOKEN}")
[[ "$STATUS" == "200" ]] && ok "GET /api/v1/auth/profile → 200" || fail "GET /api/v1/auth/profile → ${STATUS}"
else
fail "GET /api/v1/auth/profile — no token available"
fi
# ══════════════════════════════════════════════════════════════════════════════
section "10. Auth — Profile without token should be 401"
STATUS=$(get_status "${BASE}/api/v1/auth/profile")
[[ "$STATUS" == "401" ]] && ok "GET /api/v1/auth/profile (no token) → 401" \
|| fail "GET /api/v1/auth/profile (no token) → expected 401, got ${STATUS}"
# ══════════════════════════════════════════════════════════════════════════════
section "11. Auth — Refresh token"
if [[ -n "$REFRESH_TOKEN" ]]; then
RESP=$(post_json "${BASE}/api/v1/auth/refresh" \
-d "{\"refreshToken\":\"${REFRESH_TOKEN}\"}")
STATUS=$(echo "$RESP" | grep -o '__STATUS__[0-9]*' | cut -d_ -f3)
[[ "$STATUS" == "200" || "$STATUS" == "201" ]] && ok "POST /api/v1/auth/refresh → ${STATUS}" \
|| fail "POST /api/v1/auth/refresh → ${STATUS}"
else
fail "POST /api/v1/auth/refresh — no refresh token"
fi
# ══════════════════════════════════════════════════════════════════════════════
section "12. Auth — Platform admin login (ops@it0.com)"
RESP=$(post_json "${BASE}/api/v1/auth/login" \
-d '{"email":"ops@it0.com","password":"it0ops2024"}')
STATUS=$(echo "$RESP" | grep -o '__STATUS__[0-9]*' | cut -d_ -f3)
BODY=$(echo "$RESP" | sed 's/__STATUS__[0-9]*//')
if [[ "$STATUS" == "200" || "$STATUS" == "201" ]]; then
ok "Platform admin login → ${STATUS}"
ADMIN_TOKEN=$(echo "$BODY" | grep -o '"accessToken":"[^"]*"' | cut -d'"' -f4)
info "Admin token: ${ADMIN_TOKEN:0:40}..."
else
fail "Platform admin login → ${STATUS}: ${BODY}"
ADMIN_TOKEN=""
fi
# ══════════════════════════════════════════════════════════════════════════════
section "13. Tenants — List (platform admin)"
if [[ -n "${ADMIN_TOKEN:-}" ]]; then
STATUS=$(get_status "${BASE}/api/v1/tenants" \
-H "Authorization: Bearer ${ADMIN_TOKEN}")
[[ "$STATUS" == "200" ]] && ok "GET /api/v1/tenants → 200" || fail "GET /api/v1/tenants → ${STATUS}"
else
info "Skipped — no admin token"
fi
# ══════════════════════════════════════════════════════════════════════════════
section "14. Users — List (tenant admin)"
if [[ -n "$TOKEN" ]]; then
STATUS=$(get_status "${BASE}/api/v1/users" \
-H "Authorization: Bearer ${TOKEN}")
[[ "$STATUS" == "200" ]] && ok "GET /api/v1/users → 200" || fail "GET /api/v1/users → ${STATUS}"
else
info "Skipped — no token"
fi
# ══════════════════════════════════════════════════════════════════════════════
section "15. Billing — Plans (public)"
STATUS=$(get_status "${BASE}/api/v1/billing/plans")
[[ "$STATUS" == "200" ]] && ok "GET /api/v1/billing/plans → 200" || fail "GET /api/v1/billing/plans → ${STATUS}"
# ══════════════════════════════════════════════════════════════════════════════
section "16. Billing — Current subscription (JWT required)"
if [[ -n "$TOKEN" ]]; then
STATUS=$(get_status "${BASE}/api/v1/billing/subscription" \
-H "Authorization: Bearer ${TOKEN}")
[[ "$STATUS" == "200" ]] && ok "GET /api/v1/billing/subscription → 200" \
|| fail "GET /api/v1/billing/subscription → ${STATUS}"
fi
# ══════════════════════════════════════════════════════════════════════════════
section "17. Billing — Invoices (JWT required)"
if [[ -n "$TOKEN" ]]; then
STATUS=$(get_status "${BASE}/api/v1/billing/invoices" \
-H "Authorization: Bearer ${TOKEN}")
[[ "$STATUS" == "200" ]] && ok "GET /api/v1/billing/invoices → 200" \
|| fail "GET /api/v1/billing/invoices → ${STATUS}"
fi
# ══════════════════════════════════════════════════════════════════════════════
section "18. App Version — Public check endpoint"
STATUS=$(get_status "${BASE}/api/app/version/check?platform=android&current_version_code=0")
[[ "$STATUS" == "200" ]] && ok "GET /api/app/version/check → 200" \
|| fail "GET /api/app/version/check → ${STATUS}"
RESP=$(curl -s "${BASE}/api/app/version/check?platform=android&current_version_code=0")
if echo "$RESP" | grep -q '"needUpdate"'; then
ok "Version check response has needUpdate field"
else
fail "Version check response missing needUpdate field: ${RESP}"
fi
# ══════════════════════════════════════════════════════════════════════════════
section "19. App Versions — Admin CRUD (JWT required)"
if [[ -n "${ADMIN_TOKEN:-}" ]]; then
STATUS=$(get_status "${BASE}/api/v1/versions" \
-H "Authorization: Bearer ${ADMIN_TOKEN}")
[[ "$STATUS" == "200" ]] && ok "GET /api/v1/versions → 200" || fail "GET /api/v1/versions → ${STATUS}"
else
info "Skipped — no admin token"
fi
# ══════════════════════════════════════════════════════════════════════════════
section "20. Servers — List (JWT required)"
if [[ -n "$TOKEN" ]]; then
STATUS=$(get_status "${BASE}/api/v1/servers" \
-H "Authorization: Bearer ${TOKEN}")
[[ "$STATUS" == "200" ]] && ok "GET /api/v1/servers → 200" || fail "GET /api/v1/servers → ${STATUS}"
fi
# ══════════════════════════════════════════════════════════════════════════════
section "21. Agent Sessions — List (JWT required)"
if [[ -n "$TOKEN" ]]; then
STATUS=$(get_status "${BASE}/api/v1/agent/sessions" \
-H "Authorization: Bearer ${TOKEN}")
[[ "$STATUS" == "200" ]] && ok "GET /api/v1/agent/sessions → 200" \
|| fail "GET /api/v1/agent/sessions → ${STATUS}"
fi
# ══════════════════════════════════════════════════════════════════════════════
section "22. Roles — List (JWT required)"
if [[ -n "$TOKEN" ]]; then
STATUS=$(get_status "${BASE}/api/v1/roles" \
-H "Authorization: Bearer ${TOKEN}")
[[ "$STATUS" == "200" ]] && ok "GET /api/v1/roles → 200" || fail "GET /api/v1/roles → ${STATUS}"
fi
# ══════════════════════════════════════════════════════════════════════════════
section "23. API Keys — Create (JWT required)"
if [[ -n "$TOKEN" ]]; then
RESP=$(post_json "${BASE}/api/v1/auth/api-keys" \
-H "Authorization: Bearer ${TOKEN}" \
-d "{\"name\":\"test-key-${TS}\"}")
STATUS=$(echo "$RESP" | grep -o '__STATUS__[0-9]*' | cut -d_ -f3)
[[ "$STATUS" == "200" || "$STATUS" == "201" ]] && ok "POST /api/v1/auth/api-keys → ${STATUS}" \
|| fail "POST /api/v1/auth/api-keys → ${STATUS}"
fi
# ══════════════════════════════════════════════════════════════════════════════
section "24. Invite flow — Create invite"
if [[ -n "$TOKEN" ]]; then
INVITE_EMAIL="invite_${TS}@example.com"
RESP=$(post_json "${BASE}/api/v1/tenants/invites" \
-H "Authorization: Bearer ${TOKEN}" \
-d "{\"email\":\"${INVITE_EMAIL}\",\"role\":\"viewer\"}")
STATUS=$(echo "$RESP" | grep -o '__STATUS__[0-9]*' | cut -d_ -f3)
BODY=$(echo "$RESP" | sed 's/__STATUS__[0-9]*//')
if [[ "$STATUS" == "200" || "$STATUS" == "201" ]]; then
ok "POST /api/v1/tenants/invites → ${STATUS}"
INVITE_TOKEN=$(echo "$BODY" | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
if [[ -n "$INVITE_TOKEN" ]]; then
STATUS2=$(get_status "${BASE}/api/v1/auth/invite/${INVITE_TOKEN}")
[[ "$STATUS2" == "200" ]] && ok "GET /api/v1/auth/invite/:token → 200" \
|| fail "GET /api/v1/auth/invite/:token → ${STATUS2}"
fi
else
fail "POST /api/v1/tenants/invites → ${STATUS}: ${BODY}"
fi
fi
# ══════════════════════════════════════════════════════════════════════════════
section "Summary"
TOTAL=$((PASS + FAIL))
echo -e "\nTotal: ${TOTAL} ${GREEN}Pass: ${PASS}${NC} ${RED}Fail: ${FAIL}${NC}"
if [[ $FAIL -gt 0 ]]; then
exit 1
fi