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:
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import type { AppFarm } from "../types";
|
||||
import type { AppFarm, EnrolmentCode } from "../types";
|
||||
|
||||
const props = defineProps<{
|
||||
farm: AppFarm;
|
||||
@@ -10,8 +10,39 @@ const props = defineProps<{
|
||||
uplinkUrl: string;
|
||||
/** Mints a new proxy token. Rejects with a readable message. */
|
||||
mint: () => Promise<string>;
|
||||
/** Makes a one-time enrolment code. Rejects with a readable message. */
|
||||
enrol: () => Promise<EnrolmentCode>;
|
||||
/** The API's address as a gateway reaches it, for the enrol command. */
|
||||
apiUrl: string;
|
||||
}>();
|
||||
|
||||
const enrolment = ref<EnrolmentCode | null>(null);
|
||||
const enrolBusy = ref(false);
|
||||
const enrolCopied = ref(false);
|
||||
const command = computed(() => `continuum-proxy enrol ${props.apiUrl} ${enrolment.value?.code ?? ""}`);
|
||||
|
||||
async function makeCode() {
|
||||
enrolBusy.value = true;
|
||||
failure.value = "";
|
||||
try {
|
||||
enrolment.value = await props.enrol();
|
||||
enrolCopied.value = false;
|
||||
} catch (err) {
|
||||
failure.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
enrolBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCommand() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command.value);
|
||||
enrolCopied.value = true;
|
||||
} catch {
|
||||
enrolCopied.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const token = ref("");
|
||||
const confirming = ref(false);
|
||||
const busy = ref(false);
|
||||
@@ -68,6 +99,23 @@ async function copy() {
|
||||
settings below.
|
||||
</p>
|
||||
|
||||
<div class="gateway-setup__enrol">
|
||||
<template v-if="!enrolment">
|
||||
<button type="button" :disabled="enrolBusy" @click="makeCode">Connect a gateway with a code</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>
|
||||
On the computer that will run the gateway, run this within 15 minutes. The code works once, and it does not
|
||||
replace a gateway that is already connected.
|
||||
</p>
|
||||
<pre class="gateway-setup__settings">{{ command }}</pre>
|
||||
<button type="button" @click="copyCommand">{{ enrolCopied ? "Copied" : "Copy" }}</button>
|
||||
<button type="button" @click="enrolment = null">Done</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<details class="gateway-setup__manual">
|
||||
<summary>Set it up by hand instead</summary>
|
||||
<template v-if="!token">
|
||||
<button v-if="!confirming" type="button" :disabled="busy" @click="start">
|
||||
{{ gatewayOnline ? "Create a new gateway token" : "Create gateway settings" }}
|
||||
@@ -91,6 +139,7 @@ async function copy() {
|
||||
<button type="button" @click="copy">{{ copied ? "Copied" : "Copy" }}</button>
|
||||
<button type="button" @click="token = ''">Done</button>
|
||||
</template>
|
||||
</details>
|
||||
|
||||
<p v-if="failure" role="alert" class="gateway-setup__error">{{ failure }}</p>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type { AppInvitation, AppMember, CreatedInvitation, OrgRole } from "../types";
|
||||
|
||||
const props = defineProps<{
|
||||
orgName: string;
|
||||
members: AppMember[];
|
||||
invitations: AppInvitation[];
|
||||
/** Creates an invitation. Rejects with a readable message. */
|
||||
invite: (email: string, role: OrgRole) => Promise<CreatedInvitation>;
|
||||
revoke: (invitationId: string) => Promise<void>;
|
||||
/** The address the console is served from, to turn an invitation's path into a link. */
|
||||
origin: string;
|
||||
}>();
|
||||
|
||||
const email = ref("");
|
||||
const role = ref<OrgRole>("operator");
|
||||
const busy = ref(false);
|
||||
const failure = ref("");
|
||||
const link = ref("");
|
||||
const invitedEmail = ref("");
|
||||
const copied = ref(false);
|
||||
|
||||
async function submit() {
|
||||
busy.value = true;
|
||||
failure.value = "";
|
||||
link.value = "";
|
||||
try {
|
||||
const created = await props.invite(email.value.trim(), role.value);
|
||||
link.value = `${props.origin}${created.acceptPath}`;
|
||||
invitedEmail.value = created.email;
|
||||
email.value = "";
|
||||
} catch (err) {
|
||||
failure.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link.value);
|
||||
copied.value = true;
|
||||
} catch {
|
||||
copied.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(id: string) {
|
||||
failure.value = "";
|
||||
try {
|
||||
await props.revoke(id);
|
||||
} catch (err) {
|
||||
failure.value = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="org-members" :aria-label="`People in ${orgName}`">
|
||||
<h3>People in {{ orgName }}</h3>
|
||||
<ul>
|
||||
<li v-for="m in members" :key="m.userId">{{ m.email ?? m.userId }} - {{ m.role }}</li>
|
||||
</ul>
|
||||
|
||||
<h4>Invite someone</h4>
|
||||
<form @submit.prevent="submit">
|
||||
<input v-model="email" type="email" required maxlength="320" placeholder="colleague@farm.com" />
|
||||
<select v-model="role">
|
||||
<option value="viewer">Viewer - can look</option>
|
||||
<option value="operator">Operator - can print and control</option>
|
||||
<option value="owner">Owner - can manage people and printers</option>
|
||||
</select>
|
||||
<button type="submit" :disabled="busy">Create invitation</button>
|
||||
</form>
|
||||
|
||||
<div v-if="link" class="org-members__link" role="status">
|
||||
<p>
|
||||
Send this link to {{ invitedEmail }}. It works once, only when they sign in with that address, and
|
||||
<strong>is shown only now</strong>. Continuum does not email it for you.
|
||||
</p>
|
||||
<pre>{{ link }}</pre>
|
||||
<button type="button" @click="copy">{{ copied ? "Copied" : "Copy" }}</button>
|
||||
</div>
|
||||
|
||||
<template v-if="invitations.length">
|
||||
<h4>Waiting to be accepted</h4>
|
||||
<ul>
|
||||
<li v-for="i in invitations" :key="i.id">
|
||||
{{ i.email }} - {{ i.role }}
|
||||
<button type="button" @click="revoke(i.id)">Revoke</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<p v-if="failure" role="alert" class="org-members__error">{{ failure }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.org-members form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.org-members__link pre {
|
||||
background: #12151c;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.org-members__error {
|
||||
color: #f2604c;
|
||||
}
|
||||
</style>
|
||||
@@ -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";
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user