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>
177 lines
5.8 KiB
Vue
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>
|