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:
@@ -15,7 +15,7 @@ export default defineNuxtConfig({
|
||||
redirectOptions: {
|
||||
login: "/login",
|
||||
callback: "/confirm",
|
||||
exclude: ["/login"],
|
||||
exclude: ["/login", "/invite"],
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
// Reached from an invitation link. Excluded from the sign-in redirect (see
|
||||
// nuxt.config.ts) so the token in the URL survives: a visitor who is not
|
||||
// signed in is sent to /login and brought back here afterwards.
|
||||
const route = useRoute();
|
||||
const user = useSupabaseUser();
|
||||
const apiFetch = useApiFetch();
|
||||
|
||||
const token = computed(() => (typeof route.query.token === "string" ? route.query.token : ""));
|
||||
const state = ref<"waiting" | "working" | "done" | "failed">("waiting");
|
||||
const message = ref("");
|
||||
const joined = ref<{ orgName: string; role: string } | null>(null);
|
||||
|
||||
async function accept() {
|
||||
state.value = "working";
|
||||
try {
|
||||
const result = await apiFetch<{ orgName: string; role: string }>("/invitations/accept", {
|
||||
method: "POST",
|
||||
body: { token: token.value },
|
||||
});
|
||||
joined.value = result;
|
||||
state.value = "done";
|
||||
} catch (err) {
|
||||
message.value = apiErrorMessage(err, "the invitation could not be accepted");
|
||||
state.value = "failed";
|
||||
}
|
||||
}
|
||||
|
||||
const next = computed(() => `/invite?token=${encodeURIComponent(token.value)}`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="invite">
|
||||
<h1>Join an organisation</h1>
|
||||
<p v-if="!token">This link has no invitation in it.</p>
|
||||
<template v-else-if="!user">
|
||||
<p>Sign in with the email address the invitation was sent to, then you will come back here.</p>
|
||||
<NuxtLink :to="{ path: '/login', query: { next } }">Sign in</NuxtLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>You are signed in as {{ user.email }}.</p>
|
||||
<button v-if="state === 'waiting' || state === 'working'" type="button" :disabled="state === 'working'" @click="accept">
|
||||
Accept the invitation
|
||||
</button>
|
||||
<p v-if="state === 'done'" role="status">
|
||||
You have joined {{ joined?.orgName }} as {{ joined?.role }}. <NuxtLink to="/">Go to the dashboard</NuxtLink>
|
||||
</p>
|
||||
<p v-if="state === 'failed'" role="alert">{{ message }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.invite {
|
||||
max-width: 28rem;
|
||||
margin: 4rem auto;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,15 @@ const error = ref<string | null>(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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user