|
19 | 19 | AlertTriangle, |
20 | 20 | Info, |
21 | 21 | X, |
| 22 | + Plus, |
22 | 23 | ShieldCheck, |
23 | 24 | } from "lucide-svelte"; |
| 25 | + import { Debounced } from "runed"; |
24 | 26 | import * as Alert from "$lib/components/ui/alert"; |
25 | 27 | import * as tenantRemote from "$api/generated/tenants.generated.remote"; |
| 28 | + import { |
| 29 | + createTenant, |
| 30 | + validateSlug, |
| 31 | + } from "$api/generated/myTenants.generated.remote"; |
26 | 32 | import * as adminSubjectsRemote from "../admin-subjects.remote"; |
27 | 33 | import type { TenantDetailDto, TenantMemberDto } from "$api"; |
28 | 34 | import { getCurrentTenantId } from "../../current-tenant.remote"; |
29 | | - import { getTransitionStatus } from "../../../tenants/transition-status.remote"; |
| 35 | + import { getTransitionStatus } from "./transition-status.remote"; |
30 | 36 |
|
31 | 37 | const tenantIdQuery = getCurrentTenantId(); |
32 | 38 | const currentTenantId = $derived(tenantIdQuery.current ?? undefined); |
|
146 | 152 | platformAdminSavingId = null; |
147 | 153 | } |
148 | 154 | } |
| 155 | +
|
| 156 | + // Create-tenant dialog. Tenant creation is a platform-admin action; the backend |
| 157 | + // additionally honors OperatorConfiguration.AllowSelfServiceCreation. |
| 158 | + let isCreateDialogOpen = $state(false); |
| 159 | + let createdSlug = $state<string | null>(null); |
| 160 | + let slug = $state(""); |
| 161 | + let displayName = $state(""); |
| 162 | + let creating = $state(false); |
| 163 | + let slugError = $state<string | null>(null); |
| 164 | + let slugValid = $state(false); |
| 165 | + let validating = $state(false); |
| 166 | + let createError = $state<string | null>(null); |
| 167 | +
|
| 168 | + const normalizedSlug = $derived(slug.trim().toLowerCase()); |
| 169 | + const debouncedSlug = new Debounced(() => normalizedSlug, 400); |
| 170 | +
|
| 171 | + $effect(() => { |
| 172 | + const value = normalizedSlug; |
| 173 | +
|
| 174 | + slugError = null; |
| 175 | + slugValid = false; |
| 176 | +
|
| 177 | + if (!value) return; |
| 178 | + if (value.length < 3) { |
| 179 | + slugError = "Slug must be at least 3 characters"; |
| 180 | + return; |
| 181 | + } |
| 182 | +
|
| 183 | + if (debouncedSlug.current !== value) { |
| 184 | + validating = true; |
| 185 | + return; |
| 186 | + } |
| 187 | +
|
| 188 | + const result = validateSlug({ slug: value }); |
| 189 | +
|
| 190 | + // loading=true: fetch in progress; !current: result not yet populated |
| 191 | + if (result.loading || !result.current) { |
| 192 | + validating = true; |
| 193 | + return; |
| 194 | + } |
| 195 | +
|
| 196 | + validating = false; |
| 197 | +
|
| 198 | + if (result.error) { |
| 199 | + slugError = "Could not validate slug"; |
| 200 | + return; |
| 201 | + } |
| 202 | +
|
| 203 | + if (result.current.isValid) { |
| 204 | + slugValid = true; |
| 205 | + } else { |
| 206 | + slugError = result.current.message ?? "Invalid slug"; |
| 207 | + } |
| 208 | + }); |
| 209 | +
|
| 210 | + function openCreateDialog() { |
| 211 | + slug = ""; |
| 212 | + displayName = ""; |
| 213 | + slugError = null; |
| 214 | + slugValid = false; |
| 215 | + createError = null; |
| 216 | + createdSlug = null; |
| 217 | + isCreateDialogOpen = true; |
| 218 | + } |
| 219 | +
|
| 220 | + async function handleCreate() { |
| 221 | + if (!slugValid || !displayName.trim()) return; |
| 222 | + creating = true; |
| 223 | + createError = null; |
| 224 | + try { |
| 225 | + await createTenant({ |
| 226 | + slug: normalizedSlug, |
| 227 | + displayName: displayName.trim(), |
| 228 | + }); |
| 229 | + createdSlug = normalizedSlug; |
| 230 | + isCreateDialogOpen = false; |
| 231 | + } catch (err) { |
| 232 | + createError = |
| 233 | + (err as Error)?.message ?? "Failed to create tenant. Please try again."; |
| 234 | + } finally { |
| 235 | + creating = false; |
| 236 | + } |
| 237 | + } |
149 | 238 | </script> |
150 | 239 |
|
151 | 240 | <div class="@container container mx-auto max-w-4xl p-3 @md:p-6 space-y-6"> |
152 | | - <div class="flex items-center gap-3"> |
153 | | - <Building2 class="h-8 w-8 text-primary" /> |
154 | | - <div> |
155 | | - <h1 class="text-2xl font-bold">Tenant Management</h1> |
156 | | - <p class="text-muted-foreground"> |
157 | | - Manage the current tenant's details and members |
158 | | - </p> |
| 241 | + <div class="flex items-center justify-between gap-3"> |
| 242 | + <div class="flex items-center gap-3"> |
| 243 | + <Building2 class="h-8 w-8 text-primary" /> |
| 244 | + <div> |
| 245 | + <h1 class="text-2xl font-bold">Tenant Management</h1> |
| 246 | + <p class="text-muted-foreground"> |
| 247 | + Manage the current tenant's details and members |
| 248 | + </p> |
| 249 | + </div> |
159 | 250 | </div> |
| 251 | + <Button onclick={openCreateDialog}> |
| 252 | + <Plus class="mr-2 h-4 w-4" /> |
| 253 | + Create tenant |
| 254 | + </Button> |
160 | 255 | </div> |
161 | 256 |
|
| 257 | + {#if createdSlug} |
| 258 | + <Alert.Root> |
| 259 | + <Info class="h-4 w-4" /> |
| 260 | + <Alert.Title>Tenant created</Alert.Title> |
| 261 | + <Alert.Description> |
| 262 | + <code class="rounded bg-muted px-1 py-0.5 font-mono text-xs" |
| 263 | + >{createdSlug}</code |
| 264 | + > is ready. Switch to it from the tenant selector in the sidebar. |
| 265 | + </Alert.Description> |
| 266 | + </Alert.Root> |
| 267 | + {/if} |
| 268 | + |
162 | 269 | {#if showBanner} |
163 | 270 | <Alert.Root> |
164 | 271 | <Info class="h-4 w-4" /> |
|
328 | 435 | </Dialog.Content> |
329 | 436 | </Dialog.Root> |
330 | 437 |
|
| 438 | +<!-- Create Tenant Dialog --> |
| 439 | +<Dialog.Root bind:open={isCreateDialogOpen}> |
| 440 | + <Dialog.Content class="max-w-md"> |
| 441 | + <Dialog.Header> |
| 442 | + <Dialog.Title>Create new tenant</Dialog.Title> |
| 443 | + <Dialog.Description>Set up a new Nocturne instance</Dialog.Description> |
| 444 | + </Dialog.Header> |
| 445 | + <div class="space-y-4 py-4"> |
| 446 | + {#if createError} |
| 447 | + <Alert.Root variant="destructive"> |
| 448 | + <AlertTriangle class="h-4 w-4" /> |
| 449 | + <Alert.Description>{createError}</Alert.Description> |
| 450 | + </Alert.Root> |
| 451 | + {/if} |
| 452 | + |
| 453 | + <div class="space-y-2"> |
| 454 | + <Label for="new-slug">Slug</Label> |
| 455 | + <Input |
| 456 | + id="new-slug" |
| 457 | + bind:value={slug} |
| 458 | + placeholder="my-instance" |
| 459 | + class="font-mono {slugError |
| 460 | + ? 'border-destructive' |
| 461 | + : slugValid |
| 462 | + ? 'border-green-500' |
| 463 | + : ''}" |
| 464 | + /> |
| 465 | + {#if validating} |
| 466 | + <p class="text-xs text-muted-foreground">Checking availability...</p> |
| 467 | + {:else if slugError} |
| 468 | + <p class="text-xs text-destructive">{slugError}</p> |
| 469 | + {:else if slugValid} |
| 470 | + <p class="text-xs text-green-600">Available</p> |
| 471 | + {/if} |
| 472 | + </div> |
| 473 | + |
| 474 | + <div class="space-y-2"> |
| 475 | + <Label for="new-display-name">Display name</Label> |
| 476 | + <Input |
| 477 | + id="new-display-name" |
| 478 | + bind:value={displayName} |
| 479 | + placeholder="My Nocturne Instance" |
| 480 | + /> |
| 481 | + </div> |
| 482 | + </div> |
| 483 | + <Dialog.Footer> |
| 484 | + <Button variant="outline" onclick={() => (isCreateDialogOpen = false)}> |
| 485 | + Cancel |
| 486 | + </Button> |
| 487 | + <Button |
| 488 | + onclick={handleCreate} |
| 489 | + disabled={creating || !slugValid || !displayName.trim()} |
| 490 | + > |
| 491 | + {#if creating} |
| 492 | + <Loader2 class="mr-2 h-4 w-4 animate-spin" /> |
| 493 | + {/if} |
| 494 | + Create tenant |
| 495 | + </Button> |
| 496 | + </Dialog.Footer> |
| 497 | + </Dialog.Content> |
| 498 | +</Dialog.Root> |
| 499 | + |
0 commit comments