feat: validate slug uniqueness during onboarding
Add GET /api/onboarding/slug-available endpoint to check if a slug is already taken. Frontend checks availability with 400ms debounce as the user types and shows inline feedback. Submit button disabled when slug is taken. POST /api/onboarding/tenant now returns 409 instead of 500 for duplicate slugs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,20 @@
|
|||||||
package net.siegeln.cameleer.saas.onboarding;
|
package net.siegeln.cameleer.saas.onboarding;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import java.util.Map;
|
||||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||||
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
|
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.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -17,9 +22,11 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
public class OnboardingController {
|
public class OnboardingController {
|
||||||
|
|
||||||
private final OnboardingService onboardingService;
|
private final OnboardingService onboardingService;
|
||||||
|
private final TenantRepository tenantRepository;
|
||||||
|
|
||||||
public OnboardingController(OnboardingService onboardingService) {
|
public OnboardingController(OnboardingService onboardingService, TenantRepository tenantRepository) {
|
||||||
this.onboardingService = onboardingService;
|
this.onboardingService = onboardingService;
|
||||||
|
this.tenantRepository = tenantRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CreateTrialTenantRequest(
|
public record CreateTrialTenantRequest(
|
||||||
@@ -35,13 +42,23 @@ public class OnboardingController {
|
|||||||
String slug
|
String slug
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@GetMapping("/slug-available")
|
||||||
|
public ResponseEntity<Map<String, Boolean>> slugAvailable(@RequestParam String slug) {
|
||||||
|
boolean taken = tenantRepository.existsBySlugAndStatusNot(slug, TenantStatus.DELETED);
|
||||||
|
return ResponseEntity.ok(Map.of("available", !taken));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/tenant")
|
@PostMapping("/tenant")
|
||||||
public ResponseEntity<TenantResponse> createTrialTenant(
|
public ResponseEntity<TenantResponse> createTrialTenant(
|
||||||
@Valid @RequestBody CreateTrialTenantRequest request,
|
@Valid @RequestBody CreateTrialTenantRequest request,
|
||||||
@AuthenticationPrincipal Jwt jwt) {
|
@AuthenticationPrincipal Jwt jwt) {
|
||||||
|
|
||||||
String userId = jwt.getSubject();
|
String userId = jwt.getSubject();
|
||||||
|
try {
|
||||||
TenantEntity tenant = onboardingService.createTrialTenant(request.name(), request.slug(), userId);
|
TenantEntity tenant = onboardingService.createTrialTenant(request.name(), request.slug(), userId);
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(TenantResponse.from(tenant));
|
return ResponseEntity.status(HttpStatus.CREATED).body(TenantResponse.from(tenant));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Card, Input, Button, FormField, Alert } from '@cameleer/design-system';
|
||||||
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
@@ -16,9 +16,32 @@ export function OnboardingPage() {
|
|||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [slugAvailable, setSlugAvailable] = useState<boolean | null>(null);
|
||||||
|
const [checkingSlug, setCheckingSlug] = useState(false);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
const slug = toSlug(name);
|
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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -29,7 +52,12 @@ export function OnboardingPage() {
|
|||||||
// picks up the new org membership and scopes on the next token refresh.
|
// picks up the new org membership and scopes on the next token refresh.
|
||||||
window.location.href = '/platform/';
|
window.location.href = '/platform/';
|
||||||
} catch (err) {
|
} 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);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +95,13 @@ export function OnboardingPage() {
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label="URL slug" htmlFor="onboard-slug" required
|
<FormField label="URL slug" htmlFor="onboard-slug" required
|
||||||
hint="Auto-generated from name. Appears in your dashboard URL."
|
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}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="onboard-slug"
|
id="onboard-slug"
|
||||||
@@ -81,7 +115,7 @@ export function OnboardingPage() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={loading || !name || !slug}
|
disabled={loading || !name || !slug || slugAvailable === false}
|
||||||
className={styles.submit}
|
className={styles.submit}
|
||||||
>
|
>
|
||||||
{loading ? 'Creating...' : 'Create workspace'}
|
{loading ? 'Creating...' : 'Create workspace'}
|
||||||
|
|||||||
Reference in New Issue
Block a user