feat: self-service sign-up with email verification and onboarding
Complete sign-up pipeline: email registration via Logto Experience API, SMTP email verification, and self-service trial tenant creation. Layer 1 — Logto config: - Bootstrap Phase 8b: SMTP email connector with branded HTML templates - Bootstrap Phase 8c: enable SignInAndRegister (email+password sign-up) - Dockerfile installs official Logto connectors (ensures SMTP available) - SMTP env vars in docker-compose, installer templates, .env.example Layer 2 — Experience API (ui/sign-in/experience-api.ts): - Registration flow: initRegistration → sendVerificationCode → verifyCode → addProfile (password) → identifyUser → submit - Sign-in auto-detects email vs username identifier Layer 3 — Custom sign-in UI (ui/sign-in/SignInPage.tsx): - Three-mode state machine: signIn / register / verifyCode - Reads first_screen=register from URL query params - Toggle links between sign-in and register views Layer 4 — Post-registration onboarding: - OnboardingService: reuses VendorTenantService.createAndProvision(), adds calling user to Logto org as owner, enforces one trial per user - OnboardingController: POST /api/onboarding/tenant (authenticated only) - OnboardingPage.tsx: org name + auto-slug form - LandingRedirect: detects zero orgs → redirects to /onboarding - RegisterPage.tsx: /platform/register initiates OIDC with firstScreen Installers (install.sh + install.ps1): - Both prompt for SMTP config in SaaS mode - CLI args, env var capture, cameleer.conf persistence Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ React 19 SPA served at `/platform/*` by the Spring Boot backend.
|
||||
## Core files
|
||||
|
||||
- `main.tsx` — React 19 root
|
||||
- `router.tsx` — `/vendor/*` + `/tenant/*` with `RequireScope` guards and `LandingRedirect` that waits for scopes
|
||||
- `router.tsx` — `/vendor/*` + `/tenant/*` with `RequireScope` guards, `LandingRedirect` that waits for scopes (redirects to `/onboarding` if user has zero orgs), `/register` route for OIDC sign-up flow, `/onboarding` route for self-service tenant creation
|
||||
- `Layout.tsx` — persona-aware sidebar: vendor sees expandable "Vendor" section (Tenants, Audit Log, Certificates, Infrastructure, Identity/Logto), tenant admin sees Dashboard/License/SSO/Team/Audit/Settings
|
||||
- `OrgResolver.tsx` — merges global + org-scoped token scopes (vendor's platform:admin is global)
|
||||
- `config.ts` — fetch Logto config from /platform/api/config
|
||||
@@ -16,9 +16,12 @@ React 19 SPA served at `/platform/*` by the Spring Boot backend.
|
||||
- `auth/useOrganization.ts` — Zustand store for current tenant
|
||||
- `auth/useScopes.ts` — decode JWT scopes, hasScope()
|
||||
- `auth/ProtectedRoute.tsx` — guard (redirects to /login)
|
||||
- `auth/LoginPage.tsx` — redirects to Logto OIDC sign-in
|
||||
- `auth/RegisterPage.tsx` — redirects to Logto OIDC with `firstScreen: 'register'`
|
||||
|
||||
## Pages
|
||||
|
||||
- **Onboarding**: `OnboardingPage.tsx` — self-service trial tenant creation (org name + slug), shown to users with zero org memberships after sign-up
|
||||
- **Vendor pages**: `VendorTenantsPage.tsx`, `CreateTenantPage.tsx`, `TenantDetailPage.tsx`, `VendorAuditPage.tsx`, `CertificatesPage.tsx`, `InfrastructurePage.tsx`
|
||||
- **Tenant pages**: `TenantDashboardPage.tsx` (restart + upgrade server), `TenantLicensePage.tsx`, `SsoPage.tsx`, `TeamPage.tsx` (reset member passwords), `TenantAuditPage.tsx`, `SettingsPage.tsx` (change own password, reset server admin password)
|
||||
|
||||
@@ -26,5 +29,5 @@ React 19 SPA served at `/platform/*` by the Spring Boot backend.
|
||||
|
||||
Separate Vite+React SPA replacing Logto's default sign-in page. Built as custom Logto Docker image — see `docker/CLAUDE.md` for details.
|
||||
|
||||
- `SignInPage.tsx` — form with @cameleer/design-system components
|
||||
- `experience-api.ts` — Logto Experience API client (4-step: init -> verify -> identify -> submit)
|
||||
- `SignInPage.tsx` — sign-in + registration form with @cameleer/design-system components. Three modes: `signIn` (email/username + password), `register` (email + password + confirm), `verifyCode` (6-digit email verification). Reads `first_screen=register` from URL query params to determine initial view.
|
||||
- `experience-api.ts` — Logto Experience API client. Sign-in: init -> verify password -> identify -> submit. Registration: init Register -> send verification code -> verify code -> add password profile -> identify -> submit. Auto-detects email vs username identifiers.
|
||||
|
||||
@@ -15,6 +15,9 @@ FROM ghcr.io/logto-io/logto:latest
|
||||
# Install bootstrap dependencies (curl, jq for API calls; postgresql16-client for DB reads)
|
||||
RUN apk add --no-cache curl jq postgresql16-client
|
||||
|
||||
# Install all official Logto connectors (ensures SMTP email is available for self-hosted)
|
||||
RUN cd /etc/logto/packages/core && npm run cli connector add -- --official 2>/dev/null || true
|
||||
|
||||
# Custom sign-in UI
|
||||
COPY --from=build /ui/dist/ /etc/logto/packages/experience/dist/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.loginForm {
|
||||
.formContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -53,6 +53,53 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.passwordWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.passwordToggle {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.switchText {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.switchLink {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-link, #C6820E);
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.switchLink:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.verifyHint {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, var(--text-muted));
|
||||
margin: 0 0 4px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { type FormEvent, useMemo, useState } from 'react';
|
||||
import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
||||
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
||||
import { signIn } from './experience-api';
|
||||
import { signIn, startRegistration, completeRegistration } from './experience-api';
|
||||
import styles from './SignInPage.module.css';
|
||||
|
||||
const SUBTITLES = [
|
||||
type Mode = 'signIn' | 'register' | 'verifyCode';
|
||||
|
||||
const SIGN_IN_SUBTITLES = [
|
||||
"Prove you're not a mirage",
|
||||
"Only authorized cameleers beyond this dune",
|
||||
"Halt, traveler — state your business",
|
||||
@@ -33,20 +35,63 @@ const SUBTITLES = [
|
||||
"No ticket, no caravan",
|
||||
];
|
||||
|
||||
const REGISTER_SUBTITLES = [
|
||||
"Every great journey starts with a single sign-up",
|
||||
"Welcome to the caravan — let's get you registered",
|
||||
"A new cameleer approaches the oasis",
|
||||
"Join the caravan. We have dashboards.",
|
||||
"The desert is better with company",
|
||||
"First time here? The camels don't bite.",
|
||||
"Pack your bags, you're joining the caravan",
|
||||
"Room for one more on this caravan",
|
||||
"New rider? Excellent. Credentials, please.",
|
||||
"The Silk Road awaits — just need your email first",
|
||||
];
|
||||
|
||||
function pickRandom(arr: string[]) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
function getInitialMode(): Mode {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('first_screen') === 'register' ? 'register' : 'signIn';
|
||||
}
|
||||
|
||||
export function SignInPage() {
|
||||
const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []);
|
||||
const [username, setUsername] = useState('');
|
||||
const [mode, setMode] = useState<Mode>(getInitialMode);
|
||||
const subtitle = useMemo(
|
||||
() => pickRandom(mode === 'signIn' ? SIGN_IN_SUBTITLES : REGISTER_SUBTITLES),
|
||||
[mode === 'signIn' ? 'signIn' : 'register'],
|
||||
);
|
||||
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [verificationId, setVerificationId] = useState('');
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
// Reset error when switching modes
|
||||
useEffect(() => { setError(null); }, [mode]);
|
||||
|
||||
const switchMode = (next: Mode) => {
|
||||
setMode(next);
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
setCode('');
|
||||
setShowPassword(false);
|
||||
setVerificationId('');
|
||||
};
|
||||
|
||||
// --- Sign-in ---
|
||||
const handleSignIn = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const redirectTo = await signIn(username, password);
|
||||
const redirectTo = await signIn(identifier, password);
|
||||
window.location.replace(redirectTo);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Sign-in failed');
|
||||
@@ -54,10 +99,59 @@ export function SignInPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// --- Register step 1: send verification code ---
|
||||
const handleRegister = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const vId = await startRegistration(identifier);
|
||||
setVerificationId(vId);
|
||||
setMode('verifyCode');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Register step 2: verify code + complete ---
|
||||
const handleVerifyCode = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const redirectTo = await completeRegistration(identifier, password, verificationId, code);
|
||||
window.location.replace(redirectTo);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Verification failed');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const passwordToggle = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className={styles.passwordToggle}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.loginForm}>
|
||||
<div className={styles.formContainer}>
|
||||
<div className={styles.logo}>
|
||||
<img src={cameleerLogo} alt="" className={styles.logoImg} />
|
||||
Cameleer
|
||||
@@ -70,55 +164,156 @@ export function SignInPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className={styles.fields} onSubmit={handleSubmit} aria-label="Sign in" noValidate>
|
||||
<FormField label="Username" htmlFor="login-username">
|
||||
<Input
|
||||
id="login-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Password" htmlFor="login-password">
|
||||
<div style={{ position: 'relative' }}>
|
||||
{/* --- Sign-in form --- */}
|
||||
{mode === 'signIn' && (
|
||||
<form className={styles.fields} onSubmit={handleSignIn} aria-label="Sign in" noValidate>
|
||||
<FormField label="Email or username" htmlFor="login-identifier">
|
||||
<Input
|
||||
id="login-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
id="login-identifier"
|
||||
type="email"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
style={{
|
||||
position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)',
|
||||
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)',
|
||||
padding: 4, display: 'flex', alignItems: 'center',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</FormField>
|
||||
</FormField>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading || !username || !password}
|
||||
className={styles.submitButton}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
<FormField label="Password" htmlFor="login-password">
|
||||
<div className={styles.passwordWrapper}>
|
||||
<Input
|
||||
id="login-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
disabled={loading}
|
||||
/>
|
||||
{passwordToggle}
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading || !identifier || !password}
|
||||
className={styles.submitButton}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
|
||||
<p className={styles.switchText}>
|
||||
Don't have an account?{' '}
|
||||
<button type="button" className={styles.switchLink} onClick={() => switchMode('register')}>
|
||||
Sign up
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* --- Register form --- */}
|
||||
{mode === 'register' && (
|
||||
<form className={styles.fields} onSubmit={handleRegister} aria-label="Create account" noValidate>
|
||||
<FormField label="Email" htmlFor="register-email">
|
||||
<Input
|
||||
id="register-email"
|
||||
type="email"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
autoFocus
|
||||
autoComplete="email"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Password" htmlFor="register-password">
|
||||
<div className={styles.passwordWrapper}>
|
||||
<Input
|
||||
id="register-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
autoComplete="new-password"
|
||||
disabled={loading}
|
||||
/>
|
||||
{passwordToggle}
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Confirm password" htmlFor="register-confirm">
|
||||
<Input
|
||||
id="register-confirm"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading || !identifier || !password || !confirmPassword}
|
||||
className={styles.submitButton}
|
||||
>
|
||||
Create account
|
||||
</Button>
|
||||
|
||||
<p className={styles.switchText}>
|
||||
Already have an account?{' '}
|
||||
<button type="button" className={styles.switchLink} onClick={() => switchMode('signIn')}>
|
||||
Sign in
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* --- Verification code form --- */}
|
||||
{mode === 'verifyCode' && (
|
||||
<form className={styles.fields} onSubmit={handleVerifyCode} aria-label="Verify email" noValidate>
|
||||
<p className={styles.verifyHint}>
|
||||
We sent a verification code to <strong>{identifier}</strong>
|
||||
</p>
|
||||
|
||||
<FormField label="Verification code" htmlFor="verify-code">
|
||||
<Input
|
||||
id="verify-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
autoFocus
|
||||
autoComplete="one-time-code"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading || code.length < 6}
|
||||
className={styles.submitButton}
|
||||
>
|
||||
Verify & create account
|
||||
</Button>
|
||||
|
||||
<p className={styles.switchText}>
|
||||
<button type="button" className={styles.switchLink} onClick={() => switchMode('register')}>
|
||||
Back
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -10,32 +10,7 @@ async function request(method: string, path: string, body?: unknown): Promise<Re
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function initInteraction(): Promise<void> {
|
||||
const res = await request('PUT', '', { interactionEvent: 'SignIn' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.message || `Failed to initialize sign-in (${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<string> {
|
||||
const res = await request('POST', '/verification/password', {
|
||||
identifier: { type: 'username', value: username },
|
||||
password,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
if (res.status === 422) {
|
||||
throw new Error('Invalid username or password');
|
||||
}
|
||||
throw new Error(err.message || `Authentication failed (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.verificationId;
|
||||
}
|
||||
// --- Shared ---
|
||||
|
||||
export async function identifyUser(verificationId: string): Promise<void> {
|
||||
const res = await request('POST', '/identification', { verificationId });
|
||||
@@ -55,9 +30,117 @@ export async function submitInteraction(): Promise<string> {
|
||||
return data.redirectTo;
|
||||
}
|
||||
|
||||
export async function signIn(username: string, password: string): Promise<string> {
|
||||
// --- Sign-in ---
|
||||
|
||||
export async function initInteraction(): Promise<void> {
|
||||
const res = await request('PUT', '', { interactionEvent: 'SignIn' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.message || `Failed to initialize sign-in (${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
function detectIdentifierType(input: string): 'email' | 'username' {
|
||||
return input.includes('@') ? 'email' : 'username';
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
identifier: string,
|
||||
password: string
|
||||
): Promise<string> {
|
||||
const type = detectIdentifierType(identifier);
|
||||
const res = await request('POST', '/verification/password', {
|
||||
identifier: { type, value: identifier },
|
||||
password,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
if (res.status === 422) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
throw new Error(err.message || `Authentication failed (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.verificationId;
|
||||
}
|
||||
|
||||
export async function signIn(identifier: string, password: string): Promise<string> {
|
||||
await initInteraction();
|
||||
const verificationId = await verifyPassword(username, password);
|
||||
const verificationId = await verifyPassword(identifier, password);
|
||||
await identifyUser(verificationId);
|
||||
return submitInteraction();
|
||||
}
|
||||
|
||||
// --- Registration ---
|
||||
|
||||
export async function initRegistration(): Promise<void> {
|
||||
const res = await request('PUT', '', { interactionEvent: 'Register' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.message || `Failed to initialize registration (${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendVerificationCode(email: string): Promise<string> {
|
||||
const res = await request('POST', '/verification/verification-code', {
|
||||
identifier: { type: 'email', value: email },
|
||||
interactionEvent: 'Register',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
if (res.status === 422) {
|
||||
throw new Error('This email is already registered');
|
||||
}
|
||||
throw new Error(err.message || `Failed to send verification code (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.verificationId;
|
||||
}
|
||||
|
||||
export async function verifyCode(
|
||||
email: string,
|
||||
verificationId: string,
|
||||
code: string
|
||||
): Promise<string> {
|
||||
const res = await request('POST', '/verification/verification-code/verify', {
|
||||
identifier: { type: 'email', value: email },
|
||||
verificationId,
|
||||
code,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
if (res.status === 422) {
|
||||
throw new Error('Invalid or expired verification code');
|
||||
}
|
||||
throw new Error(err.message || `Verification failed (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.verificationId;
|
||||
}
|
||||
|
||||
export async function addProfile(type: string, value: string): Promise<void> {
|
||||
const res = await request('POST', '/profile', { type, value });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.message || `Failed to update profile (${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Phase 1: init registration + send verification email. Returns verificationId for phase 2. */
|
||||
export async function startRegistration(email: string): Promise<string> {
|
||||
await initRegistration();
|
||||
return sendVerificationCode(email);
|
||||
}
|
||||
|
||||
/** Phase 2: verify code, set password, create user, submit. Returns redirect URL. */
|
||||
export async function completeRegistration(
|
||||
email: string,
|
||||
password: string,
|
||||
verificationId: string,
|
||||
code: string
|
||||
): Promise<string> {
|
||||
const verifiedId = await verifyCode(email, verificationId, code);
|
||||
await addProfile('password', password);
|
||||
await identifyUser(verifiedId);
|
||||
return submitInteraction();
|
||||
}
|
||||
|
||||
32
ui/src/auth/RegisterPage.tsx
Normal file
32
ui/src/auth/RegisterPage.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLogto } from '@logto/react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Spinner } from '@cameleer/design-system';
|
||||
|
||||
export function RegisterPage() {
|
||||
const { signIn, isAuthenticated, isLoading } = useLogto();
|
||||
const navigate = useNavigate();
|
||||
const redirected = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated && !redirected.current) {
|
||||
redirected.current = true;
|
||||
signIn({
|
||||
redirectUri: `${window.location.origin}/platform/callback`,
|
||||
firstScreen: 'register',
|
||||
});
|
||||
}
|
||||
}, [isLoading, isAuthenticated, signIn]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
ui/src/pages/OnboardingPage.module.css
Normal file
59
ui/src/pages/OnboardingPage.module.css
Normal file
@@ -0,0 +1,59 @@
|
||||
.page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-family: var(--font-body);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.logoImg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
}
|
||||
103
ui/src/pages/OnboardingPage.tsx
Normal file
103
ui/src/pages/OnboardingPage.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Input, Button, FormField, Alert } from '@cameleer/design-system';
|
||||
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
||||
import { api } from '../api/client';
|
||||
import { toSlug } from '../utils/slug';
|
||||
import styles from './OnboardingPage.module.css';
|
||||
|
||||
interface TenantResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function OnboardingPage() {
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slugTouched) {
|
||||
setSlug(toSlug(name));
|
||||
}
|
||||
}, [name, slugTouched]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post<TenantResponse>('/onboarding/tenant', { name, slug });
|
||||
// Tenant created — force a full page reload so the Logto SDK
|
||||
// picks up the new org membership and scopes on the next token refresh.
|
||||
window.location.href = '/platform/';
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create tenant');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.wrapper}>
|
||||
<Card>
|
||||
<div className={styles.inner}>
|
||||
<div className={styles.logo}>
|
||||
<img src={cameleerLogo} alt="" className={styles.logoImg} />
|
||||
Welcome to Cameleer
|
||||
</div>
|
||||
<p className={styles.subtitle}>
|
||||
Set up your workspace to start monitoring your Camel routes.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error}>
|
||||
<Alert variant="error">{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<FormField label="Organization name" htmlFor="onboard-name" required>
|
||||
<Input
|
||||
id="onboard-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Acme Corp"
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="URL slug" htmlFor="onboard-slug" required
|
||||
hint="Auto-generated from name. Appears in your dashboard URL."
|
||||
>
|
||||
<Input
|
||||
id="onboard-slug"
|
||||
value={slug}
|
||||
onChange={(e) => { setSlugTouched(true); setSlug(e.target.value); }}
|
||||
placeholder="acme-corp"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading || !name || !slug}
|
||||
className={styles.submit}
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create workspace'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Routes, Route, Navigate } from 'react-router';
|
||||
import { LoginPage } from './auth/LoginPage';
|
||||
import { RegisterPage } from './auth/RegisterPage';
|
||||
import { CallbackPage } from './auth/CallbackPage';
|
||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||
import { OrgResolver } from './auth/OrgResolver';
|
||||
@@ -21,6 +22,7 @@ import { SsoPage } from './pages/tenant/SsoPage';
|
||||
import { TeamPage } from './pages/tenant/TeamPage';
|
||||
import { SettingsPage } from './pages/tenant/SettingsPage';
|
||||
import { TenantAuditPage } from './pages/tenant/TenantAuditPage';
|
||||
import { OnboardingPage } from './pages/OnboardingPage';
|
||||
|
||||
function LandingRedirect() {
|
||||
const scopes = useScopes();
|
||||
@@ -45,7 +47,11 @@ function LandingRedirect() {
|
||||
window.location.href = `/t/${currentOrg.slug}/`;
|
||||
return null;
|
||||
}
|
||||
// No org resolved yet — stay on tenant portal
|
||||
// No org membership at all → onboarding (self-service tenant creation)
|
||||
if (organizations.length === 0) {
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
// Has org but no scopes resolved yet — stay on tenant portal
|
||||
return <Navigate to="/tenant" replace />;
|
||||
}
|
||||
|
||||
@@ -53,9 +59,12 @@ export function AppRouter() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/callback" element={<CallbackPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<OrgResolver />}>
|
||||
{/* Onboarding — outside Layout, shown to users with no tenants */}
|
||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||
<Route element={<Layout />}>
|
||||
{/* Vendor console */}
|
||||
<Route path="/vendor/tenants" element={
|
||||
|
||||
Reference in New Issue
Block a user