diff --git a/src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingController.java b/src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingController.java index df394b0..bff31fc 100644 --- a/src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingController.java +++ b/src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingController.java @@ -1,15 +1,20 @@ package net.siegeln.cameleer.saas.onboarding; import jakarta.validation.Valid; +import java.util.Map; import net.siegeln.cameleer.saas.tenant.TenantEntity; import net.siegeln.cameleer.saas.tenant.dto.TenantResponse; +import net.siegeln.cameleer.saas.tenant.TenantRepository; +import net.siegeln.cameleer.saas.tenant.TenantStatus; 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.GetMapping; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -17,9 +22,11 @@ import org.springframework.web.bind.annotation.RestController; public class OnboardingController { private final OnboardingService onboardingService; + private final TenantRepository tenantRepository; - public OnboardingController(OnboardingService onboardingService) { + public OnboardingController(OnboardingService onboardingService, TenantRepository tenantRepository) { this.onboardingService = onboardingService; + this.tenantRepository = tenantRepository; } public record CreateTrialTenantRequest( @@ -35,13 +42,23 @@ public class OnboardingController { String slug ) {} + @GetMapping("/slug-available") + public ResponseEntity> slugAvailable(@RequestParam String slug) { + boolean taken = tenantRepository.existsBySlugAndStatusNot(slug, TenantStatus.DELETED); + return ResponseEntity.ok(Map.of("available", !taken)); + } + @PostMapping("/tenant") public ResponseEntity 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)); + try { + TenantEntity tenant = onboardingService.createTrialTenant(request.name(), request.slug(), userId); + return ResponseEntity.status(HttpStatus.CREATED).body(TenantResponse.from(tenant)); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } } } diff --git a/ui/src/pages/OnboardingPage.tsx b/ui/src/pages/OnboardingPage.tsx index cbd8dd6..930165c 100644 --- a/ui/src/pages/OnboardingPage.tsx +++ b/ui/src/pages/OnboardingPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect, useRef } 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'; @@ -16,9 +16,32 @@ export function OnboardingPage() { const [name, setName] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [slugAvailable, setSlugAvailable] = useState(null); + const [checkingSlug, setCheckingSlug] = useState(false); + const debounceRef = useRef>(undefined); const slug = toSlug(name); + 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]); + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); @@ -29,7 +52,12 @@ export function OnboardingPage() { // 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'); + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('409')) { + setError('This organization name is already taken. Try a different organization name.'); + } else { + setError(msg || 'Failed to create tenant'); + } setLoading(false); } } @@ -67,7 +95,13 @@ export function OnboardingPage() { {loading ? 'Creating...' : 'Create workspace'}