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: {
|
redirectOptions: {
|
||||||
login: "/login",
|
login: "/login",
|
||||||
callback: "/confirm",
|
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 client = useSupabaseClient();
|
||||||
const passwordLogin = useRuntimeConfig().public.passwordLogin;
|
const passwordLogin = useRuntimeConfig().public.passwordLogin;
|
||||||
const password = ref("");
|
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() {
|
async function signInWithPassword() {
|
||||||
error.value = null;
|
error.value = null;
|
||||||
@@ -17,12 +26,15 @@ async function signInWithPassword() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The Supabase module redirects away from /login once a session exists.
|
// The Supabase module redirects away from /login once a session exists.
|
||||||
await navigateTo("/");
|
await navigateTo(next.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMagicLink() {
|
async function sendMagicLink() {
|
||||||
error.value = null;
|
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) {
|
if (authError) {
|
||||||
error.value = authError.message;
|
error.value = authError.message;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -3,16 +3,22 @@ import {
|
|||||||
AddPrinter,
|
AddPrinter,
|
||||||
GatewaySetup,
|
GatewaySetup,
|
||||||
ManagedPrinters,
|
ManagedPrinters,
|
||||||
|
OrgMembers,
|
||||||
OnboardingForm,
|
OnboardingForm,
|
||||||
type AppFarm,
|
type AppFarm,
|
||||||
type AppFarmDiscovery,
|
type AppFarmDiscovery,
|
||||||
|
type AppInvitation,
|
||||||
|
type AppMember,
|
||||||
type AppOrg,
|
type AppOrg,
|
||||||
|
type CreatedInvitation,
|
||||||
|
type EnrolmentCode,
|
||||||
|
type OrgRole,
|
||||||
type AppPrinterState,
|
type AppPrinterState,
|
||||||
type NewPrinter,
|
type NewPrinter,
|
||||||
} from "@continuum/ui";
|
} from "@continuum/ui";
|
||||||
|
|
||||||
const apiFetch = useApiFetch();
|
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: orgs, refresh: refreshOrgs } = await useAsyncData("setup-orgs", () => apiFetch<AppOrg[]>("/orgs"));
|
||||||
const { data: farms, refresh: refreshFarms } = await useAsyncData("setup-farms", () => apiFetch<AppFarm[]>("/farms"));
|
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) {
|
async function addPrinter(farmId: string, printer: NewPrinter) {
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/farms/${farmId}/printers`, {
|
await apiFetch(`/farms/${farmId}/printers`, {
|
||||||
@@ -92,6 +140,7 @@ async function removePrinter(printerId: string) {
|
|||||||
await refreshPrinters();
|
await refreshPrinters();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const origin = typeof window === "undefined" ? "" : window.location.origin;
|
||||||
const printersOf = (farmId: string) => (printers.value ?? []).filter((p) => p.farmId === farmId);
|
const printersOf = (farmId: string) => (printers.value ?? []).filter((p) => p.farmId === farmId);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -108,6 +157,8 @@ const printersOf = (farmId: string) => (printers.value ?? []).filter((p) => p.fa
|
|||||||
:gateway-online="status[farm.id]?.gatewayOnline ?? null"
|
:gateway-online="status[farm.id]?.gatewayOnline ?? null"
|
||||||
:uplink-url="`${wsBase}/ws/edge/v1`"
|
:uplink-url="`${wsBase}/ws/edge/v1`"
|
||||||
:mint="() => mintToken(farm.id)"
|
:mint="() => mintToken(farm.id)"
|
||||||
|
:enrol="() => makeEnrolmentCode(farm.id)"
|
||||||
|
:api-url="apiBase"
|
||||||
/>
|
/>
|
||||||
<AddPrinter
|
<AddPrinter
|
||||||
:gateway-online="status[farm.id]?.gatewayOnline ?? null"
|
: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" />
|
<ManagedPrinters :printers="printersOf(farm.id)" :remove="removePrinter" />
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import type { AppFarm } from "../types";
|
import type { AppFarm, EnrolmentCode } from "../types";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
farm: AppFarm;
|
farm: AppFarm;
|
||||||
@@ -10,8 +10,39 @@ const props = defineProps<{
|
|||||||
uplinkUrl: string;
|
uplinkUrl: string;
|
||||||
/** Mints a new proxy token. Rejects with a readable message. */
|
/** Mints a new proxy token. Rejects with a readable message. */
|
||||||
mint: () => Promise<string>;
|
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 token = ref("");
|
||||||
const confirming = ref(false);
|
const confirming = ref(false);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
@@ -68,6 +99,23 @@ async function copy() {
|
|||||||
settings below.
|
settings below.
|
||||||
</p>
|
</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">
|
<template v-if="!token">
|
||||||
<button v-if="!confirming" type="button" :disabled="busy" @click="start">
|
<button v-if="!confirming" type="button" :disabled="busy" @click="start">
|
||||||
{{ gatewayOnline ? "Create a new gateway token" : "Create gateway settings" }}
|
{{ 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="copy">{{ copied ? "Copied" : "Copy" }}</button>
|
||||||
<button type="button" @click="token = ''">Done</button>
|
<button type="button" @click="token = ''">Done</button>
|
||||||
</template>
|
</template>
|
||||||
|
</details>
|
||||||
|
|
||||||
<p v-if="failure" role="alert" class="gateway-setup__error">{{ failure }}</p>
|
<p v-if="failure" role="alert" class="gateway-setup__error">{{ failure }}</p>
|
||||||
</section>
|
</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 AlertList } from "./components/AlertList.vue";
|
||||||
export { default as OnboardingForm } from "./components/OnboardingForm.vue";
|
export { default as OnboardingForm } from "./components/OnboardingForm.vue";
|
||||||
export { default as GatewaySetup } from "./components/GatewaySetup.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 AddPrinter } from "./components/AddPrinter.vue";
|
||||||
export { default as ManagedPrinters } from "./components/ManagedPrinters.vue";
|
export { default as ManagedPrinters } from "./components/ManagedPrinters.vue";
|
||||||
export { default as FarmStatus } from "./components/FarmStatus.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 {
|
export interface AppOrg {
|
||||||
id: string;
|
id: string;
|
||||||
name: 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. */
|
/** The printer kinds a gateway can drive, by the name the API takes. */
|
||||||
|
|||||||
Reference in New Issue
Block a user