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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user