Files
octoturge 88b242d914 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.
2026-10-30 08:13:00 +00:00

191 lines
6.2 KiB
Vue

<script setup lang="ts">
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, 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"));
const { data: printers, refresh: refreshPrinters } = await useAsyncData("setup-printers", () =>
apiFetch<AppPrinterState[]>("/printers"),
);
// The gateway's presence and what it found, per farm - the same endpoint the
// fleet page reads. Polled while this page is open so "gateway connected"
// turns true as soon as someone finishes configuring it.
const status = ref<Record<string, AppFarmDiscovery>>({});
async function refreshStatus() {
for (const farm of farms.value ?? []) {
try {
status.value[farm.id] = await apiFetch<AppFarmDiscovery>(`/farms/${farm.id}/discovered-printers`);
} catch {
// Keep the last answer; a failed poll is not "offline".
}
}
}
let poll: ReturnType<typeof setInterval> | undefined;
onMounted(() => {
void refreshStatus();
poll = setInterval(() => void refreshStatus(), 5_000);
});
onBeforeUnmount(() => clearInterval(poll));
/**
* Creates the first farm. Reuses the caller's organisation if they already
* have one (a person who was invited has an org and no farm), and creates one
* named after the farm otherwise.
*/
async function createFarm(farmName: string) {
try {
let orgId = orgs.value?.[0]?.id;
if (!orgId) {
const org = await apiFetch<AppOrg>("/orgs", { method: "POST", body: { name: farmName } });
orgId = org.id;
}
await apiFetch("/farms", { method: "POST", body: { name: farmName, orgId } });
} catch (err) {
throw new Error(apiErrorMessage(err, "the farm could not be created"));
}
await Promise.all([refreshOrgs(), refreshFarms()]);
await refreshStatus();
}
async function mintToken(farmId: string): Promise<string> {
try {
const result = await apiFetch<{ edgeProxyToken: string }>(`/farms/${farmId}/edge-proxy-token`, {
method: "POST",
});
return result.edgeProxyToken;
} catch (err) {
throw new Error(apiErrorMessage(err, "the token could not be created"));
}
}
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`, {
method: "POST",
body: printer,
});
} catch (err) {
throw new Error(apiErrorMessage(err, "the printer could not be added"));
}
await refreshPrinters();
}
async function removePrinter(printerId: string) {
try {
await apiFetch(`/printers/${printerId}`, { method: "DELETE" });
} catch (err) {
throw new Error(apiErrorMessage(err, "the printer could not be removed"));
}
await refreshPrinters();
}
const origin = typeof window === "undefined" ? "" : window.location.origin;
const printersOf = (farmId: string) => (printers.value ?? []).filter((p) => p.farmId === farmId);
</script>
<template>
<div class="setup">
<h1>Setup</h1>
<OnboardingForm v-if="!farms?.length" :create="createFarm" />
<section v-for="farm in farms ?? []" :key="farm.id" class="setup__farm">
<h2>{{ farm.name }}</h2>
<GatewaySetup
:farm="farm"
: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"
:candidates="status[farm.id]?.discovery?.printers ?? []"
:add="(printer: NewPrinter) => addPrinter(farm.id, printer)"
/>
<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>
<style scoped>
.setup__farm {
border-top: 1px solid #1f232c;
margin-top: 1.5rem;
padding-top: 1rem;
}
</style>