invites: an admin lets one person register while registration stays closed
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 54s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m8s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m28s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m14s
Desktop (Tauri) / Update manifest (push) Successful in 4s

Until now adding a second person meant re-opening registration to the
whole internet while they signed up (#2939 §1). An admin now makes an
invite in Settings: a link that works once, expires (7 days by default,
1 to 30), and can be pinned to one email address. Only the token's hash
is stored, so the link is shown once.

POST /api/auth/register takes `invite`. Redemption is one conditional
UPDATE inside the transaction that creates the account, so two people
racing one link can't both get in, and a taken email leaves the invite
unused. Every refusal says "invalid or expired invite". The register
page reads ?invite= and opens even while registration is closed.

Refs #5172

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 13:13:03 -04:00
co-authored by Claude Opus 5.5
parent df85535ea2
commit 28fa8badcb
18 changed files with 715 additions and 28 deletions
+205
View File
@@ -0,0 +1,205 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { api } from "../api/client";
import { errorMessage } from "../api/errors";
import { useUiStore } from "../stores/ui";
import BaseButton from "./BaseButton.vue";
import BaseInput from "./BaseInput.vue";
import Icon from "./Icon.vue";
// 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 router = useRouter();
const ui = useUiStore();
const invites = ref<Invite[]>([]);
const loading = ref(true);
const error = ref("");
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("");
async function load() {
error.value = "";
try {
invites.value = (await api.get<{ invites: Invite[] }>("/api/invites")).invites;
} catch (e) {
error.value = errorMessage(e, "Couldn't load invites.");
} finally {
loading.value = false;
}
}
/** The register page's address with the token on it, as people reach this server. */
function linkFor(token: string): string {
const path = router.resolve({ name: "register", query: { invite: token } }).href;
return new URL(path, window.location.origin).href;
}
async function create() {
creating.value = true;
error.value = "";
freshLink.value = "";
try {
const res = await api.post<{ invite: Invite; token: string }>("/api/invites", {
email: email.value.trim() || null,
days: days.value,
});
freshLink.value = linkFor(res.token);
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 copyLink() {
try {
await navigator.clipboard.writeText(freshLink.value);
ui.showToast("Invite link copied.");
} catch {
ui.showToast("Couldn't copy — select and copy it manually.");
}
}
async function revoke(invite: Invite) {
if (!window.confirm(`Revoke the invite for ${who(invite)}? Its link will stop working.`)) return;
try {
const updated = await api.del<Invite>(`/api/invites/${invite.id}`);
invites.value = invites.value.map((i) => (i.id === updated.id ? updated : i));
} catch (e) {
ui.showToast(errorMessage(e, "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="text-xs font-semibold uppercase tracking-wide text-neutral-400">Invites</h2>
<!-- One-time link reveal -->
<div
v-if="freshLink"
class="rounded-xl border border-brand/40 bg-brand/5 p-4 dark:border-brand/30 dark:bg-brand/10"
>
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">
Copy this link now — it won't be shown again.
</p>
<div class="mt-2 flex items-center gap-2">
<code
class="min-w-0 flex-1 overflow-x-auto rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-xs text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100"
>{{ freshLink }}</code
>
<button type="button" class="icon-btn shrink-0" title="Copy link" aria-label="Copy link" @click="copyLink">
<Icon name="copy" />
</button>
</div>
<button
type="button"
class="mt-3 text-xs text-neutral-500 underline hover:text-neutral-700 dark:hover:text-neutral-300"
@click="freshLink = ''"
>
Done
</button>
</div>
<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="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
<option v-for="l in LIFETIMES" :key="l.days" :value="l.days">{{ l.label }}</option>
</select>
</div>
<BaseButton type="submit" :loading="creating">Create invite</BaseButton>
</form>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<div v-if="loading" class="py-6 text-center text-sm text-neutral-400">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="flex items-center justify-between gap-4 rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<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 rounded-md border border-neutral-300 px-2.5 py-1 text-xs text-red-600 hover:bg-red-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:text-red-400 dark:hover:bg-red-950/40"
@click="revoke(i)"
>
Revoke
</button>
</li>
</ul>
</section>
</template>