2026-04-26 09:40:17 +02:00
|
|
|
import { useState, useEffect, useRef } from 'react';
|
2026-04-26 12:06:39 +02:00
|
|
|
import { useLogto } from '@logto/react';
|
2026-04-25 00:21:07 +02:00
|
|
|
import { Card, Input, Button, FormField, Alert } from '@cameleer/design-system';
|
|
|
|
|
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
2026-04-26 21:10:28 +02:00
|
|
|
import { api, errorMessage } from '../api/client';
|
2026-04-25 00:21:07 +02:00
|
|
|
import { toSlug } from '../utils/slug';
|
|
|
|
|
import styles from './OnboardingPage.module.css';
|
|
|
|
|
|
|
|
|
|
interface TenantResponse {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
slug: string;
|
|
|
|
|
status: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function OnboardingPage() {
|
2026-04-26 12:06:39 +02:00
|
|
|
const { signIn } = useLogto();
|
2026-04-25 00:21:07 +02:00
|
|
|
const [name, setName] = useState('');
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
2026-04-26 09:40:17 +02:00
|
|
|
const [slugAvailable, setSlugAvailable] = useState<boolean | null>(null);
|
|
|
|
|
const [checkingSlug, setCheckingSlug] = useState(false);
|
|
|
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
2026-04-25 00:21:07 +02:00
|
|
|
|
2026-04-25 22:05:48 +02:00
|
|
|
const slug = toSlug(name);
|
2026-04-25 00:21:07 +02:00
|
|
|
|
2026-04-26 09:40:17 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
setSlugAvailable(null);
|
|
|
|
|
if (!slug) return;
|
|
|
|
|
|
|
|
|
|
setCheckingSlug(true);
|
|
|
|
|
clearTimeout(debounceRef.current);
|
|
|
|
|
debounceRef.current = setTimeout(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await api.get<{ available: boolean }>(`/onboarding/slug-available?slug=${encodeURIComponent(slug)}`);
|
|
|
|
|
setSlugAvailable(res.available);
|
|
|
|
|
} catch {
|
|
|
|
|
setSlugAvailable(null);
|
|
|
|
|
} finally {
|
|
|
|
|
setCheckingSlug(false);
|
|
|
|
|
}
|
|
|
|
|
}, 400);
|
|
|
|
|
|
|
|
|
|
return () => clearTimeout(debounceRef.current);
|
|
|
|
|
}, [slug]);
|
|
|
|
|
|
2026-04-25 00:21:07 +02:00
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setError(null);
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
await api.post<TenantResponse>('/onboarding/tenant', { name, slug });
|
2026-04-26 12:06:39 +02:00
|
|
|
// Tenant created — force a fresh OIDC sign-in so the Logto SDK gets
|
|
|
|
|
// new tokens that include the org membership just created. The existing
|
|
|
|
|
// Logto session cookie means the user won't see a login form — Logto
|
|
|
|
|
// auto-approves and redirects back with fresh tokens.
|
|
|
|
|
await signIn(`${window.location.origin}/platform/callback`);
|
2026-04-25 00:21:07 +02:00
|
|
|
} catch (err) {
|
2026-04-26 21:10:28 +02:00
|
|
|
const msg = err instanceof Error ? err.message : errorMessage(err);
|
2026-04-26 09:40:17 +02:00
|
|
|
if (msg.includes('409')) {
|
|
|
|
|
setError('This organization name is already taken. Try a different organization name.');
|
|
|
|
|
} else {
|
|
|
|
|
setError(msg || 'Failed to create tenant');
|
|
|
|
|
}
|
2026-04-25 00:21:07 +02:00
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={styles.page}>
|
|
|
|
|
<div className={styles.wrapper}>
|
2026-04-25 12:43:11 +02:00
|
|
|
<Card className={styles.card}>
|
2026-04-25 00:21:07 +02:00
|
|
|
<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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-04-25 12:43:11 +02:00
|
|
|
<form onSubmit={handleSubmit} className={styles.form} noValidate>
|
2026-04-25 00:21:07 +02:00
|
|
|
<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
|
2026-04-26 09:40:17 +02:00
|
|
|
hint={
|
|
|
|
|
!slug ? 'Auto-generated from name. Appears in your dashboard URL.'
|
|
|
|
|
: checkingSlug ? 'Checking availability...'
|
|
|
|
|
: slugAvailable === true ? 'Available'
|
|
|
|
|
: 'Auto-generated from name. Appears in your dashboard URL.'
|
|
|
|
|
}
|
|
|
|
|
error={slugAvailable === false ? 'This organization name is already taken. Try a different name.' : undefined}
|
2026-04-25 00:21:07 +02:00
|
|
|
>
|
|
|
|
|
<Input
|
|
|
|
|
id="onboard-slug"
|
|
|
|
|
value={slug}
|
|
|
|
|
placeholder="acme-corp"
|
2026-04-25 22:05:48 +02:00
|
|
|
readOnly
|
2026-04-25 00:21:07 +02:00
|
|
|
/>
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="primary"
|
|
|
|
|
type="submit"
|
|
|
|
|
loading={loading}
|
2026-04-26 09:40:17 +02:00
|
|
|
disabled={loading || !name || !slug || slugAvailable === false}
|
2026-04-25 00:21:07 +02:00
|
|
|
className={styles.submit}
|
|
|
|
|
>
|
|
|
|
|
{loading ? 'Creating...' : 'Create workspace'}
|
|
|
|
|
</Button>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|