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:
parent
67691fc24d
commit
96bf5e7390
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
@ -9,20 +9,36 @@ import { apiClient } from '@/infrastructure/api/api-client';
|
|||
interface RegisterResponse {
|
||||
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 };
|
||||
}
|
||||
|
||||
type LoginMethod = 'email' | 'phone';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation('auth');
|
||||
const { t: tc } = useTranslation('common');
|
||||
|
||||
const [loginMethod, setLoginMethod] = useState<LoginMethod>('email');
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
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¤t_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) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -31,7 +47,11 @@ export default function RegisterPage() {
|
|||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError(t('passwordMinLength', 'Password must be at least 6 characters'));
|
||||
setError('密码至少 6 位');
|
||||
return;
|
||||
}
|
||||
if (!companyName.trim()) {
|
||||
setError('请填写企业名称');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -39,14 +59,20 @@ export default function RegisterPage() {
|
|||
setError(null);
|
||||
|
||||
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', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
...(companyName ? { companyName } : {}),
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
localStorage.setItem('access_token', data.accessToken);
|
||||
|
|
@ -58,107 +84,181 @@ export default function RegisterPage() {
|
|||
if (payload.tenantId) {
|
||||
localStorage.setItem('current_tenant', JSON.stringify({ id: payload.tenantId }));
|
||||
}
|
||||
} catch { /* ignore decode errors */ }
|
||||
} catch { /* ignore */ }
|
||||
|
||||
router.push('/dashboard');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||
setError(err instanceof Error ? err.message : '注册失败,请重试');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<img src="/icons/logo.svg" alt="iAgent" className="w-20 h-20 mx-auto mb-2" />
|
||||
<h1 className="text-3xl font-bold">{t('appTitle')}</h1>
|
||||
<p className="text-muted-foreground mt-2">{t('createAccount')}</p>
|
||||
<img src="/icons/logo.svg" alt="iAgent" className="w-16 h-16 mx-auto mb-3" />
|
||||
<h1 className="text-2xl font-bold">{t('enterpriseRegTitle')}</h1>
|
||||
<p className="text-muted-foreground mt-2 text-sm leading-relaxed">{t('enterpriseRegSubtitle')}</p>
|
||||
</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>
|
||||
<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
|
||||
/>
|
||||
<label className="block text-sm font-medium mb-2">{t('loginMethod')}</label>
|
||||
<div className="flex rounded-md border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLoginMethod('email')}
|
||||
className={`flex-1 py-2 text-sm font-medium transition-colors ${
|
||||
loginMethod === 'email'
|
||||
? '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>
|
||||
<label className="block text-sm font-medium mb-1">{t('email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Identifier field */}
|
||||
{loginMethod === 'email' ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t('email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
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>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t('organizationName')}
|
||||
<span className="text-muted-foreground font-normal ml-1">({tc('optional')})</span>
|
||||
</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')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('organizationHint')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{t('downloadAppDesc')}</p>
|
||||
<div className="flex gap-3">
|
||||
{appDownloadUrl.android ? (
|
||||
<a
|
||||
href={appDownloadUrl.android}
|
||||
className="flex-1 py-2 text-center text-xs font-medium bg-card border rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
{t('downloadAndroid')}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href="/app-versions"
|
||||
className="flex-1 py-2 text-center text-xs font-medium bg-card border rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
{t('downloadAndroid')}
|
||||
</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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,21 +6,32 @@
|
|||
"email": "Email",
|
||||
"password": "Password",
|
||||
"emailPlaceholder": "admin@example.com",
|
||||
"noAccount": "Don't have an account?",
|
||||
"createOne": "Create one",
|
||||
"noAccount": "No enterprise account yet?",
|
||||
"createOne": "Register now",
|
||||
"haveAccount": "Already have an account?",
|
||||
"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",
|
||||
"createAccountSubtitle": "Set up a new organization to get started.",
|
||||
"loginMethod": "Login method",
|
||||
"loginByEmail": "Email",
|
||||
"loginByPhone": "Phone",
|
||||
"fullName": "Full Name",
|
||||
"fullNamePlaceholder": "John Doe",
|
||||
"organizationName": "Organization Name",
|
||||
"phone": "Phone Number",
|
||||
"phonePlaceholder": "+1 555 000 0000",
|
||||
"organizationName": "Company Name",
|
||||
"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",
|
||||
"creatingAccount": "Creating account...",
|
||||
"register": "Create Account",
|
||||
"register": "Create Enterprise Account",
|
||||
"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",
|
||||
"inviteSubtitle": "You've been invited to join",
|
||||
"inviteRole": "Role:",
|
||||
|
|
|
|||
|
|
@ -6,21 +6,32 @@
|
|||
"email": "邮箱",
|
||||
"password": "密码",
|
||||
"emailPlaceholder": "admin@example.com",
|
||||
"noAccount": "还没有账号?",
|
||||
"createOne": "立即注册",
|
||||
"noAccount": "还没有企业账号?",
|
||||
"createOne": "立即开户",
|
||||
"haveAccount": "已有账号?",
|
||||
"signInLink": "去登录",
|
||||
"enterpriseRegTitle": "企业开户",
|
||||
"enterpriseRegSubtitle": "为您的企业创建 iAgent 账号,员工通过手机 App 使用智能体服务。",
|
||||
"createAccount": "创建账号",
|
||||
"createAccountSubtitle": "注册新组织,开始使用。",
|
||||
"loginMethod": "登录方式",
|
||||
"loginByEmail": "邮箱",
|
||||
"loginByPhone": "手机号",
|
||||
"fullName": "姓名",
|
||||
"fullNamePlaceholder": "张三",
|
||||
"organizationName": "组织名称",
|
||||
"organizationNamePlaceholder": "示例公司",
|
||||
"organizationHint": "将为您的组织创建一个新租户。",
|
||||
"phone": "手机号",
|
||||
"phonePlaceholder": "+86 138 0000 0000",
|
||||
"organizationName": "企业名称",
|
||||
"organizationNamePlaceholder": "示例科技有限公司",
|
||||
"organizationHint": "将为您的企业创建独立的租户空间。",
|
||||
"confirmPassword": "确认密码",
|
||||
"creatingAccount": "创建中...",
|
||||
"register": "创建账号",
|
||||
"creatingAccount": "开户中...",
|
||||
"register": "立即开户",
|
||||
"loginFailed": "登录失败",
|
||||
"downloadApp": "下载员工 App",
|
||||
"downloadAppDesc": "企业账号注册完成后,邀请员工下载 App 即可使用 AI 智能运维服务。",
|
||||
"downloadAndroid": "Android 下载",
|
||||
"downloadIOS": "iOS 下载",
|
||||
"inviteTitle": "接受邀请",
|
||||
"inviteSubtitle": "您已被邀请加入",
|
||||
"inviteRole": "角色:",
|
||||
|
|
|
|||
|
|
@ -47,14 +47,14 @@ export class AuthService {
|
|||
}
|
||||
|
||||
async login(
|
||||
email: string,
|
||||
identifier: string,
|
||||
password: string,
|
||||
): 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 user = await this.userRepository.findByEmail(email);
|
||||
const user = await this.userRepository.findByIdentifier(identifier);
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
|
@ -74,6 +74,7 @@ export class AuthService {
|
|||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
name: user.name,
|
||||
roles: user.roles,
|
||||
tenantId: user.tenantId,
|
||||
|
|
@ -88,26 +89,35 @@ export class AuthService {
|
|||
* Otherwise joins the default tenant as viewer.
|
||||
*/
|
||||
async register(
|
||||
email: string,
|
||||
password: string,
|
||||
name: string,
|
||||
companyName?: string,
|
||||
email?: string,
|
||||
phone?: string,
|
||||
): 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 };
|
||||
}> {
|
||||
// Check email uniqueness across all schemas (public users table check)
|
||||
const existing = await this.userRepository.findByEmail(email);
|
||||
if (existing) {
|
||||
throw new ConflictException('Email already registered');
|
||||
if (!email && !phone) {
|
||||
throw new BadRequestException('Email or phone is required');
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
if (companyName) {
|
||||
// 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
|
||||
|
|
@ -115,6 +125,7 @@ export class AuthService {
|
|||
user.id = crypto.randomUUID();
|
||||
user.tenantId = 'default';
|
||||
user.email = email;
|
||||
user.phone = phone;
|
||||
user.passwordHash = passwordHash;
|
||||
user.name = name;
|
||||
user.roles = [RoleType.VIEWER];
|
||||
|
|
@ -130,6 +141,7 @@ export class AuthService {
|
|||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
name: user.name,
|
||||
roles: user.roles,
|
||||
tenantId: user.tenantId,
|
||||
|
|
@ -138,10 +150,11 @@ export class AuthService {
|
|||
}
|
||||
|
||||
private async registerWithNewTenant(
|
||||
email: string,
|
||||
passwordHash: string,
|
||||
name: string,
|
||||
companyName: string,
|
||||
email?: string,
|
||||
phone?: string,
|
||||
) {
|
||||
// Generate slug from company name (underscores for valid PostgreSQL schema names)
|
||||
const slug = companyName
|
||||
|
|
@ -192,9 +205,9 @@ export class AuthService {
|
|||
try {
|
||||
await qr.query(`SET 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, slug, email, passwordHash, name, [RoleType.ADMIN], 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, slug, email ?? null, phone ?? null, passwordHash, name, [RoleType.ADMIN], true, now, now],
|
||||
);
|
||||
} finally {
|
||||
await qr.release();
|
||||
|
|
@ -205,6 +218,7 @@ export class AuthService {
|
|||
user.id = userId;
|
||||
user.tenantId = slug;
|
||||
user.email = email;
|
||||
user.phone = phone;
|
||||
user.passwordHash = passwordHash;
|
||||
user.name = name;
|
||||
user.roles = [RoleType.ADMIN];
|
||||
|
|
@ -216,6 +230,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 User {
|
|||
@Column({ type: 'varchar', length: 20 })
|
||||
tenantId!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, unique: true })
|
||||
email!: string;
|
||||
@Column({ type: 'varchar', length: 255, nullable: true, unique: true })
|
||||
email?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 30, nullable: true, unique: true })
|
||||
phone?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
passwordHash!: string;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,20 @@ export class UserRepository {
|
|||
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> {
|
||||
return this.repo.findOneBy({ id });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,24 +7,38 @@ export class AuthController {
|
|||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
async login(@Body() body: { email: string; password: string }) {
|
||||
return this.authService.login(body.email, body.password);
|
||||
async login(
|
||||
@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).
|
||||
* Otherwise joins the default tenant as viewer.
|
||||
*/
|
||||
@Post('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(
|
||||
body.email,
|
||||
body.password,
|
||||
body.name,
|
||||
body.companyName,
|
||||
body.email,
|
||||
body.phone,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -287,14 +287,17 @@ CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id);
|
|||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
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,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
roles VARCHAR(30)[] NOT NULL DEFAULT '{viewer}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
$$;
|
||||
|
|
@ -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¤t_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¤t_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
|
||||
Loading…
Reference in New Issue