refactor(tenant): replace tier+username with email-first creation
All checks were successful
CI / build (push) Successful in 2m9s
CI / docker (push) Successful in 1m37s

- Remove tier from create tenant form (always defaults to STARTER,
  controlled via license minting)
- Admin email is now the primary identity field
- Username auto-derived from email local part, optionally overridable
- Set primaryEmail on Logto user at creation (prevents invalid accounts)
- Async tenant delete: PG/ClickHouse cleanup runs after commit instead
  of blocking the HTTP response
- Remove legacy /server/* OIDC redirect URIs from bootstrap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-28 19:34:00 +02:00
parent 15c47fe36c
commit bd301ad1fe
11 changed files with 79 additions and 62 deletions

View File

@@ -5,8 +5,6 @@ import { useCreateTenant } from '../../api/vendor-hooks';
import { errorMessage } from '../../api/client';
import { toSlug } from '../../utils/slug';
const TIERS = ['STARTER', 'TEAM', 'BUSINESS', 'ENTERPRISE'];
export function CreateTenantPage() {
const navigate = useNavigate();
const { toast } = useToast();
@@ -15,8 +13,9 @@ export function CreateTenantPage() {
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [slugTouched, setSlugTouched] = useState(false);
const [tier, setTier] = useState('STARTER');
const [adminEmail, setAdminEmail] = useState('');
const [adminUsername, setAdminUsername] = useState('');
const [usernameTouched, setUsernameTouched] = useState(false);
const [adminPassword, setAdminPassword] = useState('');
useEffect(() => {
@@ -25,11 +24,18 @@ export function CreateTenantPage() {
}
}, [name, slugTouched]);
useEffect(() => {
if (!usernameTouched && adminEmail.includes('@')) {
setAdminUsername(adminEmail.substring(0, adminEmail.indexOf('@')).replace(/[^a-zA-Z0-9]/g, ''));
}
}, [adminEmail, usernameTouched]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
try {
const result = await createTenant.mutateAsync({
name, slug, tier,
name, slug,
adminEmail: adminEmail || undefined,
adminUsername: adminUsername || undefined,
adminPassword: adminPassword || undefined,
});
@@ -71,32 +77,24 @@ export function CreateTenantPage() {
/>
</FormField>
<FormField label="Tier" htmlFor="tenant-tier" required>
<select
id="tenant-tier"
value={tier}
onChange={(e) => setTier(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: 6,
background: 'var(--bg-surface)',
color: 'var(--text-primary)',
fontSize: '0.875rem',
}}
>
{TIERS.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
<FormField label="Admin Email" htmlFor="admin-email" hint="Initial tenant admin (owner role)">
<Input
id="admin-email"
type="email"
value={adminEmail}
onChange={(e) => setAdminEmail(e.target.value)}
placeholder="admin@acme.com"
/>
</FormField>
<FormField label="Admin Username" htmlFor="admin-user" hint="Initial tenant admin (owner role). Alphanumeric only, no hyphens.">
<FormField label="Username" htmlFor="admin-user" hint="Auto-generated from email, override if needed">
<Input
id="admin-user"
value={adminUsername}
onChange={(e) => setAdminUsername(e.target.value.replace(/[^a-zA-Z0-9]/g, ''))}
onChange={(e) => {
setAdminUsername(e.target.value.replace(/[^a-zA-Z0-9]/g, ''));
setUsernameTouched(true);
}}
placeholder="admin"
/>
</FormField>

View File

@@ -95,7 +95,7 @@ export interface VendorTenantDetail {
export interface CreateTenantRequest {
name: string;
slug: string;
tier?: string;
adminEmail?: string;
adminUsername?: string;
adminPassword?: string;
}