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';
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&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) => {
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 data = await apiClient<RegisterResponse>('/api/v1/auth/register', {
method: 'POST',
body: {
email,
const body: Record<string, string> = {
password,
name,
...(companyName ? { companyName } : {}),
},
companyName,
};
if (loginMethod === 'email') {
body.email = email;
} else {
body.phone = phone;
}
const data = await apiClient<RegisterResponse>('/api/v1/auth/register', {
method: 'POST',
body,
});
localStorage.setItem('access_token', data.accessToken);
@ -58,25 +84,85 @@ 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>
{/* 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-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>
<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
@ -89,35 +175,21 @@ export default function RegisterPage() {
/>
</div>
{/* Company name — required for enterprise */}
<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('organizationName')}
<span className="text-muted-foreground font-normal ml-1">({tc('optional')})</span>
</label>
<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>
<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
@ -140,14 +212,12 @@ export default function RegisterPage() {
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
{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"
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>
@ -160,5 +230,35 @@ export default function RegisterPage() {
</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>
<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>
</div>
);
}

View File

@ -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:",

View File

@ -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": "角色:",

View File

@ -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)
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 (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,

View File

@ -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;

View File

@ -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 });
}

View File

@ -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,
);
}

View File

@ -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;

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