Skip to content

Commit 47f04c5

Browse files
committed
tenants
1 parent 549df99 commit 47f04c5

10 files changed

Lines changed: 208 additions & 351 deletions

File tree

src/API/Nocturne.API/Services/Connectors/DataSourceService.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,13 @@ public List<UploaderApp> GetUploaderApps()
555555
Icon = "glucotracker",
556556
Url = "https://glucotracker.app",
557557
},
558+
new()
559+
{
560+
Id = "prelude",
561+
Platform = UploaderPlatform.Android,
562+
Category = UploaderCategory.Uploader,
563+
Icon = "prelude",
564+
},
558565
};
559566
}
560567

src/Web/packages/app/src/lib/components/command-palette/CommandPalette.svelte

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@
4040
4141
const visibleItems = $derived(
4242
items.filter(
43-
(item) => !item.permission || authStore.hasPermission(item.permission)
43+
(item) =>
44+
(!item.permission || authStore.hasPermission(item.permission)) &&
45+
(!item.role || authStore.hasRole(item.role))
4446
)
4547
);
4648

src/Web/packages/app/src/lib/components/command-palette/command-palette-items.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export interface CommandPaletteItem {
6666
icon?: ComponentType;
6767
shortcut?: string;
6868
permission?: string;
69+
/** Role required to see this item (e.g. "platform_admin"). */
70+
role?: string;
6971
href?: string;
7072
linkedHref?: string;
7173
}
@@ -359,12 +361,12 @@ export const items: CommandPaletteItem[] = [
359361
},
360362
{
361363
id: "page-tenants",
362-
label: "Tenants",
363-
group: "pages",
364-
keywords: ["tenants", "organizations", "accounts"],
364+
label: "Tenant Management",
365+
group: "settings",
366+
keywords: ["tenants", "organizations", "accounts", "platform admin"],
365367
icon: Building,
366-
href: "/tenants",
367-
permission: "api:tenants:read",
368+
href: "/settings/admin/tenants",
369+
role: "platform_admin",
368370
},
369371

370372
// ─── Settings ────────────────────────────────────────────────────────

src/Web/packages/app/src/lib/components/connectors/UploaderSetupDialog.svelte

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import Apple from "lucide-svelte/icons/apple";
1515
import TabletSmartphone from "lucide-svelte/icons/tablet-smartphone";
1616
import XdripQuickConnect from "$lib/components/XdripQuickConnect.svelte";
17+
import PreludeQuickConnect from "$lib/components/PreludeQuickConnect.svelte";
1718
import type {
1819
UploaderApp,
1920
UploaderSetupResponse,
@@ -43,7 +44,9 @@
4344
);
4445
let copiedField = $state<string | null>(null);
4546
46-
const hasOAuthFlow = $derived(selectedUploader?.id === "xdrip");
47+
const hasOAuthFlow = $derived(
48+
selectedUploader?.id === "xdrip" || selectedUploader?.id === "prelude",
49+
);
4750
4851
function handleRequestApiKey() {
4952
if (!selectedUploader) return;
@@ -93,6 +96,10 @@
9396
<div class="border-b pb-4 mb-4">
9497
<XdripQuickConnect instanceUrl={window.location.origin} />
9598
</div>
99+
{:else if selectedUploader?.id === "prelude" && typeof window !== "undefined"}
100+
<div class="border-b pb-4 mb-4">
101+
<PreludeQuickConnect instanceUrl={window.location.origin} />
102+
</div>
96103
{/if}
97104

98105
<!-- Connection Info -->

src/Web/packages/app/src/lib/components/layout/AppSidebar.svelte

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -233,14 +233,6 @@
233233
},
234234
);
235235
236-
if (totalTenantCount >= 2) {
237-
items.push({
238-
title: "Tenants",
239-
href: "/tenants",
240-
icon: Building2,
241-
});
242-
}
243-
244236
items.push(
245237
{
246238
title: "Alerts",

src/Web/packages/app/src/lib/utils/uploader-labels.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ const UPLOADER_LABELS: Record<string, UploaderLabel> = {
4646
name: 'Nightscout Uploader',
4747
description: 'Android uploader for Nightscout-compatible sites',
4848
},
49+
prelude: {
50+
name: 'Prelude',
51+
description: 'CGM follower app for Android',
52+
},
4953
};
5054

5155
/** Get the display name for an uploader app, falling back to its ID */

src/Web/packages/app/src/routes/(authenticated)/settings/admin/tenants/+page.svelte

Lines changed: 177 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,20 @@
1919
AlertTriangle,
2020
Info,
2121
X,
22+
Plus,
2223
ShieldCheck,
2324
} from "lucide-svelte";
25+
import { Debounced } from "runed";
2426
import * as Alert from "$lib/components/ui/alert";
2527
import * as tenantRemote from "$api/generated/tenants.generated.remote";
28+
import {
29+
createTenant,
30+
validateSlug,
31+
} from "$api/generated/myTenants.generated.remote";
2632
import * as adminSubjectsRemote from "../admin-subjects.remote";
2733
import type { TenantDetailDto, TenantMemberDto } from "$api";
2834
import { getCurrentTenantId } from "../../current-tenant.remote";
29-
import { getTransitionStatus } from "../../../tenants/transition-status.remote";
35+
import { getTransitionStatus } from "./transition-status.remote";
3036
3137
const tenantIdQuery = getCurrentTenantId();
3238
const currentTenantId = $derived(tenantIdQuery.current ?? undefined);
@@ -146,19 +152,120 @@
146152
platformAdminSavingId = null;
147153
}
148154
}
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+
}
149238
</script>
150239

151240
<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>
159250
</div>
251+
<Button onclick={openCreateDialog}>
252+
<Plus class="mr-2 h-4 w-4" />
253+
Create tenant
254+
</Button>
160255
</div>
161256

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+
162269
{#if showBanner}
163270
<Alert.Root>
164271
<Info class="h-4 w-4" />
@@ -328,3 +435,65 @@
328435
</Dialog.Content>
329436
</Dialog.Root>
330437

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+

src/Web/packages/app/src/routes/(authenticated)/tenants/transition-status.remote.ts renamed to src/Web/packages/app/src/routes/(authenticated)/settings/admin/tenants/transition-status.remote.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Remote function to fetch the multitenancy transition status.
3-
* Used by the tenants page and tenant admin settings to show
4-
* a notice when BaseDomain is configured (multitenancy enabled).
3+
* Used by the tenant admin settings to show a notice when BaseDomain
4+
* is configured (multitenancy enabled).
55
*/
66
import { getRequestEvent, query } from "$app/server";
77

src/Web/packages/app/src/routes/(authenticated)/tenants/+layout.server.ts

Lines changed: 0 additions & 9 deletions
This file was deleted.

0 commit comments

Comments
 (0)