Add members/invitations and code-based gateway setup to the Setup page

Gateway setup shows a `continuum-proxy enrol` command with a one-time
code; the manual settings stay behind "Set it up by hand". New members
section for inviting by email, plus an /invite page to accept, and the
login page honours a safe `next` redirect.
This commit is contained in:
2026-10-30 08:13:00 +00:00
parent 69f35c2a6b
commit 88b242d914
8 changed files with 336 additions and 6 deletions
+63 -1
View File
@@ -3,16 +3,22 @@ import {
AddPrinter,
GatewaySetup,
ManagedPrinters,
OrgMembers,
OnboardingForm,
type AppFarm,
type AppFarmDiscovery,
type AppInvitation,
type AppMember,
type AppOrg,
type CreatedInvitation,
type EnrolmentCode,
type OrgRole,
type AppPrinterState,
type NewPrinter,
} from "@continuum/ui";
const apiFetch = useApiFetch();
const { wsBase } = useRuntimeConfig().public;
const { wsBase, apiBase } = useRuntimeConfig().public;
const { data: orgs, refresh: refreshOrgs } = await useAsyncData("setup-orgs", () => apiFetch<AppOrg[]>("/orgs"));
const { data: farms, refresh: refreshFarms } = await useAsyncData("setup-farms", () => apiFetch<AppFarm[]>("/farms"));
@@ -71,6 +77,48 @@ async function mintToken(farmId: string): Promise<string> {
}
}
async function makeEnrolmentCode(farmId: string): Promise<EnrolmentCode> {
try {
return await apiFetch<EnrolmentCode>(`/farms/${farmId}/enrolment-codes`, { method: "POST" });
} catch (err) {
throw new Error(apiErrorMessage(err, "the code could not be created"));
}
}
// People, per organisation the caller owns (only owners can see invitations).
const members = ref<Record<string, AppMember[]>>({});
const invitations = ref<Record<string, AppInvitation[]>>({});
async function refreshPeople() {
for (const org of orgs.value ?? []) {
try {
members.value[org.id] = await apiFetch<AppMember[]>(`/orgs/${org.id}/members`);
if (org.role === "owner") invitations.value[org.id] = await apiFetch<AppInvitation[]>(`/orgs/${org.id}/invitations`);
} catch {
// Leave the last answer; the section just stays as it was.
}
}
}
onMounted(() => void refreshPeople());
async function invite(orgId: string, email: string, role: OrgRole): Promise<CreatedInvitation> {
try {
const created = await apiFetch<CreatedInvitation>(`/orgs/${orgId}/invitations`, { method: "POST", body: { email, role } });
await refreshPeople();
return created;
} catch (err) {
throw new Error(apiErrorMessage(err, "the invitation could not be created"));
}
}
async function revokeInvitation(orgId: string, invitationId: string) {
try {
await apiFetch(`/orgs/${orgId}/invitations/${invitationId}`, { method: "DELETE" });
} catch (err) {
throw new Error(apiErrorMessage(err, "the invitation could not be revoked"));
}
await refreshPeople();
}
async function addPrinter(farmId: string, printer: NewPrinter) {
try {
await apiFetch(`/farms/${farmId}/printers`, {
@@ -92,6 +140,7 @@ async function removePrinter(printerId: string) {
await refreshPrinters();
}
const origin = typeof window === "undefined" ? "" : window.location.origin;
const printersOf = (farmId: string) => (printers.value ?? []).filter((p) => p.farmId === farmId);
</script>
@@ -108,6 +157,8 @@ const printersOf = (farmId: string) => (printers.value ?? []).filter((p) => p.fa
:gateway-online="status[farm.id]?.gatewayOnline ?? null"
:uplink-url="`${wsBase}/ws/edge/v1`"
:mint="() => mintToken(farm.id)"
:enrol="() => makeEnrolmentCode(farm.id)"
:api-url="apiBase"
/>
<AddPrinter
:gateway-online="status[farm.id]?.gatewayOnline ?? null"
@@ -116,6 +167,17 @@ const printersOf = (farmId: string) => (printers.value ?? []).filter((p) => p.fa
/>
<ManagedPrinters :printers="printersOf(farm.id)" :remove="removePrinter" />
</section>
<section v-for="org in (orgs ?? []).filter((o) => o.role === 'owner')" :key="org.id" class="setup__farm">
<OrgMembers
:org-name="org.name"
:members="members[org.id] ?? []"
:invitations="invitations[org.id] ?? []"
:invite="(email: string, role: OrgRole) => invite(org.id, email, role)"
:revoke="(id: string) => revokeInvitation(org.id, id)"
:origin="origin"
/>
</section>
</div>
</template>