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.
191 lines
6.2 KiB
Vue
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>
|