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:
@@ -18,10 +18,13 @@
|
||||
| SaaS admin | `saas-vendor` (global) | `platform:admin` | `/vendor/tenants` |
|
||||
| Tenant admin | org `owner` | `tenant:manage` | `/tenant` (dashboard) |
|
||||
| Regular user (operator/viewer) | org member | `server:operator` or `server:viewer` | Redirected to server dashboard directly |
|
||||
| New user (just registered) | none (authenticated only) | none | `/onboarding` (self-service tenant creation) |
|
||||
|
||||
- `LandingRedirect` component waits for scopes to load, then routes to the correct persona landing page
|
||||
- `LandingRedirect` component waits for scopes to load, then routes to the correct persona landing page. If user has zero organizations, redirects to `/onboarding`.
|
||||
- `RequireScope` guard on route groups enforces scope requirements
|
||||
- SSO bridge: Logto session carries over to provisioned server's OIDC flow (Traditional Web App per tenant)
|
||||
- Self-service sign-up flow: `/platform/register` → Logto OIDC with `firstScreen: 'register'` → custom sign-in UI (email + password + verification code) → callback → `LandingRedirect` → `/onboarding` → `POST /api/onboarding/tenant` → tenant provisioned, user added as org owner
|
||||
- `OnboardingController` at `/api/onboarding/**` requires `authenticated()` only (no specific scope). `OnboardingService` enforces one trial tenant per user, reuses `VendorTenantService.createAndProvision()`, and adds the calling user to the Logto org as `owner`.
|
||||
|
||||
## Server OIDC role extraction (two paths)
|
||||
|
||||
|
||||
@@ -43,10 +43,11 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/actuator/health").permitAll()
|
||||
.requestMatchers("/api/config").permitAll()
|
||||
.requestMatchers("/", "/index.html", "/login", "/callback",
|
||||
"/vendor/**", "/tenant/**",
|
||||
.requestMatchers("/", "/index.html", "/login", "/register", "/callback",
|
||||
"/vendor/**", "/tenant/**", "/onboarding",
|
||||
"/environments/**", "/license", "/admin/**").permitAll()
|
||||
.requestMatchers("/_app/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
|
||||
.requestMatchers("/api/onboarding/**").authenticated()
|
||||
.requestMatchers("/api/vendor/**").hasAuthority("SCOPE_platform:admin")
|
||||
.requestMatchers("/api/tenant/**").authenticated()
|
||||
.anyRequest().authenticated()
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package net.siegeln.cameleer.saas.onboarding;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/onboarding")
|
||||
public class OnboardingController {
|
||||
|
||||
private final OnboardingService onboardingService;
|
||||
|
||||
public OnboardingController(OnboardingService onboardingService) {
|
||||
this.onboardingService = onboardingService;
|
||||
}
|
||||
|
||||
public record CreateTrialTenantRequest(
|
||||
@jakarta.validation.constraints.NotBlank
|
||||
@jakarta.validation.constraints.Size(max = 255)
|
||||
String name,
|
||||
|
||||
@jakarta.validation.constraints.NotBlank
|
||||
@jakarta.validation.constraints.Size(max = 100)
|
||||
@jakarta.validation.constraints.Pattern(
|
||||
regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$",
|
||||
message = "Slug must be lowercase alphanumeric with hyphens")
|
||||
String slug
|
||||
) {}
|
||||
|
||||
@PostMapping("/tenant")
|
||||
public ResponseEntity<TenantResponse> createTrialTenant(
|
||||
@Valid @RequestBody CreateTrialTenantRequest request,
|
||||
@AuthenticationPrincipal Jwt jwt) {
|
||||
|
||||
String userId = jwt.getSubject();
|
||||
TenantEntity tenant = onboardingService.createTrialTenant(request.name(), request.slug(), userId);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(TenantResponse.from(tenant));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package net.siegeln.cameleer.saas.onboarding;
|
||||
|
||||
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
|
||||
import net.siegeln.cameleer.saas.vendor.VendorTenantService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Self-service onboarding: lets a newly registered user create their own trial tenant.
|
||||
* Reuses VendorTenantService for the heavy lifting (Logto org, license, Docker provisioning)
|
||||
* but adds the calling user as the tenant owner instead of creating a new admin user.
|
||||
*/
|
||||
@Service
|
||||
public class OnboardingService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OnboardingService.class);
|
||||
|
||||
private final VendorTenantService vendorTenantService;
|
||||
private final LogtoManagementClient logtoClient;
|
||||
|
||||
public OnboardingService(VendorTenantService vendorTenantService,
|
||||
LogtoManagementClient logtoClient) {
|
||||
this.vendorTenantService = vendorTenantService;
|
||||
this.logtoClient = logtoClient;
|
||||
}
|
||||
|
||||
public TenantEntity createTrialTenant(String name, String slug, String logtoUserId) {
|
||||
// Guard: check if user already has a tenant (prevent abuse)
|
||||
if (logtoClient.isAvailable()) {
|
||||
var orgs = logtoClient.getUserOrganizations(logtoUserId);
|
||||
if (!orgs.isEmpty()) {
|
||||
throw new IllegalStateException("You already have a tenant. Only one trial tenant per account.");
|
||||
}
|
||||
}
|
||||
|
||||
// Create tenant via the existing vendor flow (no admin user — we'll add the caller)
|
||||
UUID actorId = resolveActorId(logtoUserId);
|
||||
var request = new CreateTenantRequest(name, slug, "LOW", null, null);
|
||||
TenantEntity tenant = vendorTenantService.createAndProvision(request, actorId);
|
||||
|
||||
// Add the calling user to the Logto org as owner
|
||||
if (tenant.getLogtoOrgId() != null && logtoClient.isAvailable()) {
|
||||
try {
|
||||
String ownerRoleId = logtoClient.findOrgRoleIdByName("owner");
|
||||
logtoClient.addUserToOrganization(tenant.getLogtoOrgId(), logtoUserId);
|
||||
if (ownerRoleId != null) {
|
||||
logtoClient.assignOrganizationRole(tenant.getLogtoOrgId(), logtoUserId, ownerRoleId);
|
||||
}
|
||||
log.info("Added user {} as owner of tenant {}", logtoUserId, slug);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to add user {} to org for tenant {}: {}", logtoUserId, slug, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return tenant;
|
||||
}
|
||||
|
||||
private UUID resolveActorId(String subject) {
|
||||
try {
|
||||
return UUID.fromString(subject);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return UUID.nameUUIDFromBytes(subject.getBytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user