Files
cameleer-saas/ui/sign-in/src/experience-api.ts
hsiegeln 9ed2cedc98
All checks were successful
CI / build (push) Successful in 1m14s
CI / docker (push) Successful in 1m15s
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>
2026-04-25 00:21:07 +02:00

147 lines
4.6 KiB
TypeScript

const BASE = '/api/experience';
async function request(method: string, path: string, body?: unknown): Promise<Response> {
const res = await fetch(`${BASE}${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
credentials: 'same-origin',
});
return res;
}
// --- Shared ---
export async function identifyUser(verificationId: string): Promise<void> {
const res = await request('POST', '/identification', { verificationId });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Identification failed (${res.status})`);
}
}
export async function submitInteraction(): Promise<string> {
const res = await request('POST', '/submit');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Submit failed (${res.status})`);
}
const data = await res.json();
return data.redirectTo;
}
// --- 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(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();
}