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
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:
@@ -66,7 +66,9 @@ export interface ConfigRepo {
|
||||
export interface AuthRepo {
|
||||
me(): Promise<User>;
|
||||
login(email: string, password: string): Promise<User>;
|
||||
register(email: string, password: string, displayName: string): Promise<User>;
|
||||
/** `invite` is the token from an invite link, which lets this one account in
|
||||
* while registration is closed. */
|
||||
register(email: string, password: string, displayName: string, invite?: string): Promise<User>;
|
||||
logout(): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,8 +53,8 @@ export const rest: Repo = {
|
||||
auth: {
|
||||
me: () => api.get<User>("/api/auth/me"),
|
||||
login: (email, password) => api.post<User>("/api/auth/login", { email, password }),
|
||||
register: (email, password, displayName) =>
|
||||
api.post<User>("/api/auth/register", { email, password, display_name: displayName }),
|
||||
register: (email, password, displayName, invite) =>
|
||||
api.post<User>("/api/auth/register", { email, password, display_name: displayName, invite }),
|
||||
logout: () => api.post<void>("/api/auth/logout"),
|
||||
},
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -112,7 +112,9 @@ router.beforeEach(async (to) => {
|
||||
if (to.meta.requiresServer && isDesktop()) {
|
||||
return { name: "board" };
|
||||
}
|
||||
if (to.name === "register" && !config.allowRegistration) {
|
||||
// An invite link opens the form while registration is closed; the server decides
|
||||
// whether the invite holds.
|
||||
if (to.name === "register" && !config.allowRegistration && !to.query.invite) {
|
||||
return { name: "login" };
|
||||
}
|
||||
if (to.meta.guestOnly && session.user) {
|
||||
|
||||
@@ -29,8 +29,8 @@ export const useSessionStore = defineStore("session", () => {
|
||||
user.value = await repo.auth.login(email, password);
|
||||
}
|
||||
|
||||
async function register(email: string, password: string, displayName: string): Promise<void> {
|
||||
user.value = await repo.auth.register(email, password, displayName);
|
||||
async function register(email: string, password: string, displayName: string, invite?: string): Promise<void> {
|
||||
user.value = await repo.auth.register(email, password, displayName, invite);
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useSessionStore } from "../stores/session";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import BaseInput from "../components/BaseInput.vue";
|
||||
@@ -10,6 +10,11 @@ import { errorMessage } from "../api/errors";
|
||||
const session = useSessionStore();
|
||||
const config = useConfigStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// The token from an invite link (/register?invite=…). It lets this one account in
|
||||
// while registration is closed.
|
||||
const invite = computed(() => (typeof route.query.invite === "string" ? route.query.invite : ""));
|
||||
|
||||
const displayName = ref("");
|
||||
const email = ref("");
|
||||
@@ -25,7 +30,7 @@ async function submit() {
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
await session.register(email.value, password.value, displayName.value);
|
||||
await session.register(email.value, password.value, displayName.value, invite.value || undefined);
|
||||
await router.replace("/");
|
||||
} catch (e) {
|
||||
error.value = errorMessage(e, "Could not create your account.");
|
||||
@@ -46,8 +51,10 @@ async function submit() {
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
<h1 class="text-2xl font-bold tracking-tight">Create your space</h1>
|
||||
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Start capturing in seconds.</p>
|
||||
<h1 class="text-2xl font-bold tracking-tight">{{ invite ? "You're invited" : "Create your space" }}</h1>
|
||||
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{{ invite ? `Create your account on ${config.siteName}.` : "Start capturing in seconds." }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="flex flex-col gap-4" novalidate @submit.prevent="submit">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import BaseButton from "../components/BaseButton.vue";
|
||||
import InviteList from "../components/InviteList.vue";
|
||||
import { errorMessage } from "../api/errors";
|
||||
|
||||
interface SettingItem {
|
||||
@@ -170,5 +171,9 @@ onMounted(load);
|
||||
<span v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Outside the settings form: each invite action saves on its own, and the
|
||||
form's Save button has nothing to do with them. -->
|
||||
<InviteList v-if="items.length" class="mt-10" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user