feat: self-service sign-up with email verification and onboarding
All checks were successful
CI / build (push) Successful in 1m14s
CI / docker (push) Successful in 1m15s

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:
hsiegeln
2026-04-25 00:21:07 +02:00
parent dc7ac3a1ec
commit 9ed2cedc98
24 changed files with 1011 additions and 95 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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%;
}

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

View File

@@ -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={