From 88b242d914d987b54009efa1168b07cf497dfec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Fri, 30 Oct 2026 08:13:00 +0000 Subject: [PATCH] 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. --- apps/web/nuxt.config.ts | 2 +- apps/web/pages/invite.vue | 60 ++++++++++ apps/web/pages/login.vue | 16 ++- apps/web/pages/setup.vue | 64 ++++++++++- packages/ui/src/components/GatewaySetup.vue | 51 ++++++++- packages/ui/src/components/OrgMembers.vue | 117 ++++++++++++++++++++ packages/ui/src/index.ts | 3 +- packages/ui/src/types.ts | 29 +++++ 8 files changed, 336 insertions(+), 6 deletions(-) create mode 100644 apps/web/pages/invite.vue create mode 100644 packages/ui/src/components/OrgMembers.vue diff --git a/apps/web/nuxt.config.ts b/apps/web/nuxt.config.ts index eeed5ee..d1936d3 100644 --- a/apps/web/nuxt.config.ts +++ b/apps/web/nuxt.config.ts @@ -15,7 +15,7 @@ export default defineNuxtConfig({ redirectOptions: { login: "/login", callback: "/confirm", - exclude: ["/login"], + exclude: ["/login", "/invite"], }, }, diff --git a/apps/web/pages/invite.vue b/apps/web/pages/invite.vue new file mode 100644 index 0000000..84cbd19 --- /dev/null +++ b/apps/web/pages/invite.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/apps/web/pages/login.vue b/apps/web/pages/login.vue index 7e19fe1..f10d240 100644 --- a/apps/web/pages/login.vue +++ b/apps/web/pages/login.vue @@ -5,6 +5,15 @@ const error = ref(null); const client = useSupabaseClient(); const passwordLogin = useRuntimeConfig().public.passwordLogin; const password = ref(""); +const route = useRoute(); + +// Where to go after signing in. Only a path on this site: a `next` of +// `//elsewhere.example` or `https://...` would turn the sign-in page into an +// open redirect. +const next = computed(() => { + const wanted = route.query.next; + return typeof wanted === "string" && wanted.startsWith("/") && !wanted.startsWith("//") ? wanted : "/"; +}); async function signInWithPassword() { error.value = null; @@ -17,12 +26,15 @@ async function signInWithPassword() { return; } // The Supabase module redirects away from /login once a session exists. - await navigateTo("/"); + await navigateTo(next.value); } async function sendMagicLink() { error.value = null; - const { error: authError } = await client.auth.signInWithOtp({ email: email.value }); + const { error: authError } = await client.auth.signInWithOtp({ + email: email.value, + options: next.value === "/" ? undefined : { emailRedirectTo: `${window.location.origin}${next.value}` }, + }); if (authError) { error.value = authError.message; return; diff --git a/apps/web/pages/setup.vue b/apps/web/pages/setup.vue index 44f14f3..6bdf393 100644 --- a/apps/web/pages/setup.vue +++ b/apps/web/pages/setup.vue @@ -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("/orgs")); const { data: farms, refresh: refreshFarms } = await useAsyncData("setup-farms", () => apiFetch("/farms")); @@ -71,6 +77,48 @@ async function mintToken(farmId: string): Promise { } } +async function makeEnrolmentCode(farmId: string): Promise { + try { + return await apiFetch(`/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>({}); +const invitations = ref>({}); +async function refreshPeople() { + for (const org of orgs.value ?? []) { + try { + members.value[org.id] = await apiFetch(`/orgs/${org.id}/members`); + if (org.role === "owner") invitations.value[org.id] = await apiFetch(`/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 { + try { + const created = await apiFetch(`/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); @@ -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" /> (printers.value ?? []).filter((p) => p.fa /> + +
+ +
diff --git a/packages/ui/src/components/GatewaySetup.vue b/packages/ui/src/components/GatewaySetup.vue index 41de89b..e0a7235 100644 --- a/packages/ui/src/components/GatewaySetup.vue +++ b/packages/ui/src/components/GatewaySetup.vue @@ -1,6 +1,6 @@ + + + + diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 6cb3251..83a6798 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -8,7 +8,8 @@ export { default as JobUpload } from "./components/JobUpload.vue"; export { default as AlertList } from "./components/AlertList.vue"; export { default as OnboardingForm } from "./components/OnboardingForm.vue"; export { default as GatewaySetup } from "./components/GatewaySetup.vue"; +export { default as OrgMembers } from "./components/OrgMembers.vue"; export { default as AddPrinter } from "./components/AddPrinter.vue"; export { default as ManagedPrinters } from "./components/ManagedPrinters.vue"; export { default as FarmStatus } from "./components/FarmStatus.vue"; -export type { PrinterKind, NewPrinter, AppOrg, AppAlert, AppDiscoveredCandidate, AppFarm, AppFarmDiscovery, AppJob, AppPrinterState, AppTelemetryMessage, PrinterCommand } from "./types"; +export type { AppMember, AppInvitation, CreatedInvitation, EnrolmentCode, OrgRole, PrinterKind, NewPrinter, AppOrg, AppAlert, AppDiscoveredCandidate, AppFarm, AppFarmDiscovery, AppJob, AppPrinterState, AppTelemetryMessage, PrinterCommand } from "./types"; diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index 31fa53d..a3692ec 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -123,6 +123,35 @@ export interface AppAlert { export interface AppOrg { id: string; name: string; + /** The caller's role in it; only an owner can invite. */ + role?: "viewer" | "operator" | "owner"; +} + +export type OrgRole = "viewer" | "operator" | "owner"; + +export interface AppMember { + userId: string; + /** Null for someone added by id rather than by invitation. */ + email: string | null; + role: OrgRole; +} + +export interface AppInvitation { + id: string; + email: string; + role: OrgRole; + expiresAt: string; +} + +/** The answer to creating an invitation. The token is in the link, shown once. */ +export interface CreatedInvitation extends AppInvitation { + acceptPath: string; +} + +/** A one-time code to enrol a gateway. */ +export interface EnrolmentCode { + code: string; + expiresAt: string; } /** The printer kinds a gateway can drive, by the name the API takes. */