Files
inkwell/frontend/src/components/InviteList.vue
T
bvandeusenandClaude Opus 5.5 c0de50d85d Email: one branded HTML layout with a text part, and invites can be emailed (#5917)
Each email was a subject and a plain-text f-string at its call site.
mail_templates.py now holds one layout: the site name, a heading, the
message, a button for the link and a footer saying who sent it and why.
Every email (password reset, test, invite) is a function returning its
subject, text and HTML. mailer.send sends them as multipart/alternative,
text first.

The HTML is email-shaped: tables, inline styles, no images or fonts, and
every value from settings or a person escaped.

POST /api/invites takes send_email. It is refused before anything is made
when email is off or the invite names no address. The link is built from
the public address. A failed send leaves the invite standing with
email_error, so the admin still gets the link once. The invite form offers
"Email the link to …" when email is on and an address is filled in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:43:13 -04:00

177 lines
5.8 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { api } from "../api/client";
import { errorMessage } from "../api/errors";
import { appLink } from "../router/links";
import BaseButton from "./BaseButton.vue";
import BaseInput from "./BaseInput.vue";
import OneTimeSecret from "./OneTimeSecret.vue";
import { toastOnFailure, useLoad } from "../composables/useAction";
import { useConfigStore } from "../stores/config";
// Admin: invite one person to register while registration stays closed (#5172).
// The server keeps only a hash of each invite's token, so the link is shown once,
// right after it is made — the same contract as a device token on Linked devices.
interface Invite {
id: string;
email: string | null;
created_at: string;
expires_at: string;
redeemed_at: string | null;
redeemed_by_email: string | null;
revoked_at: string | null;
status: "pending" | "redeemed" | "expired" | "revoked";
}
const LIFETIMES = [
{ days: 1, label: "1 day" },
{ days: 7, label: "7 days" },
{ days: 30, label: "30 days" },
];
const invites = ref<Invite[]>([]);
const { loading, error, load } = useLoad(async () => {
invites.value = (await api.get<{ invites: Invite[] }>("/api/invites")).invites;
}, "Couldn't load invites.");
const email = ref("");
const days = ref(7);
const creating = ref(false);
// The link for the invite just made. Never retrievable again once dismissed.
const freshLink = ref("");
const freshNote = ref("");
// Emailing the link (#5917) needs email set up and an address to send it to. The
// server says whether email is on in the same flag the sign-in page reads.
const config = useConfigStore();
const sendEmail = ref(true);
const canEmail = computed(() => config.passwordResetByEmail && email.value.trim() !== "");
async function create() {
creating.value = true;
error.value = "";
freshLink.value = "";
try {
const to = email.value.trim();
const res = await api.post<{ invite: Invite; token: string; emailed?: boolean; email_error?: string }>(
"/api/invites",
{ email: to || null, days: days.value, send_email: canEmail.value && sendEmail.value },
);
freshLink.value = appLink({ name: "register", query: { invite: res.token } });
freshNote.value = res.emailed
? `Emailed to ${to}. The link is here too, in case it's easier to send yourself.`
: res.email_error
? `${res.email_error} Send this link to ${to} yourself.`
: "Send this to the person you're inviting.";
invites.value = [res.invite, ...invites.value];
email.value = "";
} catch (e) {
error.value = errorMessage(e, "Couldn't create an invite.");
} finally {
creating.value = false;
}
}
async function revoke(invite: Invite) {
if (!window.confirm(`Revoke the invite for ${who(invite)}? Its link will stop working.`)) return;
await toastOnFailure(async () => {
const updated = await api.del<Invite>(`/api/invites/${invite.id}`);
invites.value = invites.value.map((i) => (i.id === updated.id ? updated : i));
}, "Couldn't revoke that invite.");
}
function who(invite: Invite): string {
return invite.email ?? "anyone with the link";
}
function day(iso: string | null): string {
return iso ? new Date(iso).toLocaleDateString() : "";
}
/** One short line saying where the invite stands. */
function state(invite: Invite): string {
switch (invite.status) {
case "redeemed":
return `Used by ${invite.redeemed_by_email ?? "a removed account"} · ${day(invite.redeemed_at)}`;
case "revoked":
return `Revoked · ${day(invite.revoked_at)}`;
case "expired":
return `Expired · ${day(invite.expires_at)}`;
default:
return `Waiting · expires ${day(invite.expires_at)}`;
}
}
onMounted(() => {
void load();
});
</script>
<template>
<section class="flex flex-col gap-5">
<h2 class="section-label">Invites</h2>
<OneTimeSecret
v-if="freshLink"
:value="freshLink"
:note="freshNote"
@done="freshLink = ''"
/>
<form class="flex flex-wrap items-end gap-3" @submit.prevent="create">
<BaseInput
id="invite-email"
v-model="email"
label="Email (optional)"
type="email"
placeholder="Only this address may use it"
class="min-w-48 flex-1"
/>
<div class="flex flex-col gap-1">
<label for="invite-days" class="text-sm font-medium text-neutral-800 dark:text-neutral-200">Expires in</label>
<select
id="invite-days"
v-model.number="days"
class="field"
>
<option v-for="l in LIFETIMES" :key="l.days" :value="l.days">{{ l.label }}</option>
</select>
</div>
<BaseButton type="submit" :loading="creating">{{
canEmail && sendEmail ? "Create and email" : "Create invite"
}}</BaseButton>
<label v-if="canEmail" class="flex w-full items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
<input v-model="sendEmail" type="checkbox" class="h-4 w-4 accent-brand" />
Email the link to {{ email.trim() }}
</label>
</form>
<p v-if="error" class="form-error">{{ error }}</p>
<div v-if="loading" class="list-empty">Loading…</div>
<p v-else-if="!invites.length" class="text-sm text-neutral-400">No invites yet.</p>
<ul v-else class="flex flex-col gap-2">
<li
v-for="i in invites"
:key="i.id"
class="row-card"
>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">
{{ i.email ?? "Anyone with the link" }}
</p>
<p class="text-xs text-neutral-400">{{ state(i) }}</p>
</div>
<button
v-if="i.status === 'pending'"
type="button"
class="shrink-0 btn-sm-danger"
@click="revoke(i)"
>
Revoke
</button>
</li>
</ul>
</section>
</template>