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:
@@ -0,0 +1,42 @@
|
|||||||
|
"""invites: single-use, expiring registration links an admin issues
|
||||||
|
|
||||||
|
Revision ID: 0032
|
||||||
|
Revises: 0031
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
Until now the only way to add a second person was to re-open registration to the
|
||||||
|
whole internet while they signed up (#2939 §1). An invite lets one person register
|
||||||
|
while registration stays closed. Only the token's SHA-256 hash is stored.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Drops the table. Accounts created through invites are untouched; the record of who
|
||||||
|
invited them goes with it.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import CITEXT, UUID
|
||||||
|
|
||||||
|
revision = "0032"
|
||||||
|
down_revision = "0031"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"invites",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("token_hash", sa.Text(), nullable=False, unique=True),
|
||||||
|
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("email", CITEXT(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("redeemed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("redeemed_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("invites")
|
||||||
@@ -20,8 +20,9 @@ first account is created, so a server whose admin already existed keeps whatever
|
|||||||
Access → Allow new registrations** before exposing an instance you have been running
|
Access → Allow new registrations** before exposing an instance you have been running
|
||||||
on a LAN.
|
on a LAN.
|
||||||
|
|
||||||
To let someone else in, turn it back on, have them register, turn it off. There is no
|
To let someone else in, send them an invite: **Settings → Invites** makes a link that
|
||||||
invite system yet, so that is the mechanism.
|
works once, expires (a week by default), and can be pinned to one email address. It
|
||||||
|
lets that one person register while registration stays closed.
|
||||||
|
|
||||||
**2. Terminate TLS in front of it, and forward the scheme.** The app marks the
|
**2. Terminate TLS in front of it, and forward the scheme.** The app marks the
|
||||||
session cookie `Secure` and sends HSTS only when it can tell the request arrived over
|
session cookie `Secure` and sends HSTS only when it can tell the request arrived over
|
||||||
@@ -121,9 +122,6 @@ Know these before you decide who gets an account.
|
|||||||
with `docker compose logs app`, which is enough to see whether anyone is knocking.
|
with `docker compose logs app`, which is enough to see whether anyone is knocking.
|
||||||
They are not queryable, not retained beyond the container's log rotation, and not
|
They are not queryable, not retained beyond the container's log rotation, and not
|
||||||
attributable after the fact.
|
attributable after the fact.
|
||||||
- **No invites.** Adding a second person means re-opening registration while they
|
|
||||||
sign up, then closing it again. There is no per-person token, no expiry, and no
|
|
||||||
record of who invited whom.
|
|
||||||
|
|
||||||
None of these are hard blockers for an instance whose accounts are you and people you
|
None of these are hard blockers for an instance whose accounts are you and people you
|
||||||
know. They are the reason not to hand out open registration to strangers.
|
know. They are the reason not to hand out open registration to strangers.
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ export interface ConfigRepo {
|
|||||||
export interface AuthRepo {
|
export interface AuthRepo {
|
||||||
me(): Promise<User>;
|
me(): Promise<User>;
|
||||||
login(email: string, password: string): 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>;
|
logout(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ export const rest: Repo = {
|
|||||||
auth: {
|
auth: {
|
||||||
me: () => api.get<User>("/api/auth/me"),
|
me: () => api.get<User>("/api/auth/me"),
|
||||||
login: (email, password) => api.post<User>("/api/auth/login", { email, password }),
|
login: (email, password) => api.post<User>("/api/auth/login", { email, password }),
|
||||||
register: (email, password, displayName) =>
|
register: (email, password, displayName, invite) =>
|
||||||
api.post<User>("/api/auth/register", { email, password, display_name: displayName }),
|
api.post<User>("/api/auth/register", { email, password, display_name: displayName, invite }),
|
||||||
logout: () => api.post<void>("/api/auth/logout"),
|
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()) {
|
if (to.meta.requiresServer && isDesktop()) {
|
||||||
return { name: "board" };
|
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" };
|
return { name: "login" };
|
||||||
}
|
}
|
||||||
if (to.meta.guestOnly && session.user) {
|
if (to.meta.guestOnly && session.user) {
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
user.value = await repo.auth.login(email, password);
|
user.value = await repo.auth.login(email, password);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function register(email: string, password: string, displayName: string): Promise<void> {
|
async function register(email: string, password: string, displayName: string, invite?: string): Promise<void> {
|
||||||
user.value = await repo.auth.register(email, password, displayName);
|
user.value = await repo.auth.register(email, password, displayName, invite);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout(): Promise<void> {
|
async function logout(): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useSessionStore } from "../stores/session";
|
import { useSessionStore } from "../stores/session";
|
||||||
import { useConfigStore } from "../stores/config";
|
import { useConfigStore } from "../stores/config";
|
||||||
import BaseInput from "../components/BaseInput.vue";
|
import BaseInput from "../components/BaseInput.vue";
|
||||||
@@ -10,6 +10,11 @@ import { errorMessage } from "../api/errors";
|
|||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
const config = useConfigStore();
|
const config = useConfigStore();
|
||||||
const router = useRouter();
|
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 displayName = ref("");
|
||||||
const email = ref("");
|
const email = ref("");
|
||||||
@@ -25,7 +30,7 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
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("/");
|
await router.replace("/");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = errorMessage(e, "Could not create your account.");
|
error.value = errorMessage(e, "Could not create your account.");
|
||||||
@@ -46,8 +51,10 @@ async function submit() {
|
|||||||
width="48"
|
width="48"
|
||||||
height="48"
|
height="48"
|
||||||
/>
|
/>
|
||||||
<h1 class="text-2xl font-bold tracking-tight">Create your space</h1>
|
<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">Start capturing in seconds.</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<form class="flex flex-col gap-4" novalidate @submit.prevent="submit">
|
<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 { api } from "../api/client";
|
||||||
import { useConfigStore } from "../stores/config";
|
import { useConfigStore } from "../stores/config";
|
||||||
import BaseButton from "../components/BaseButton.vue";
|
import BaseButton from "../components/BaseButton.vue";
|
||||||
|
import InviteList from "../components/InviteList.vue";
|
||||||
import { errorMessage } from "../api/errors";
|
import { errorMessage } from "../api/errors";
|
||||||
|
|
||||||
interface SettingItem {
|
interface SettingItem {
|
||||||
@@ -170,5 +171,9 @@ onMounted(load);
|
|||||||
<span v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</span>
|
<span v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from .auth import bp as auth_bp
|
|||||||
from .client_dist import advertisement as client_advertisement, bp as client_bp
|
from .client_dist import advertisement as client_advertisement, bp as client_bp
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .db import session_scope
|
from .db import session_scope
|
||||||
|
from .invites_api import bp as invites_bp
|
||||||
from .labels import bp as labels_bp
|
from .labels import bp as labels_bp
|
||||||
from .notes import bp as notes_bp
|
from .notes import bp as notes_bp
|
||||||
from .proxy import is_https
|
from .proxy import is_https
|
||||||
@@ -91,6 +92,7 @@ def create_app() -> Quart:
|
|||||||
app.register_blueprint(notes_bp)
|
app.register_blueprint(notes_bp)
|
||||||
app.register_blueprint(labels_bp)
|
app.register_blueprint(labels_bp)
|
||||||
app.register_blueprint(settings_bp)
|
app.register_blueprint(settings_bp)
|
||||||
|
app.register_blueprint(invites_bp)
|
||||||
app.register_blueprint(sync_bp)
|
app.register_blueprint(sync_bp)
|
||||||
app.register_blueprint(saved_filters_bp)
|
app.register_blueprint(saved_filters_bp)
|
||||||
app.register_blueprint(client_bp)
|
app.register_blueprint(client_bp)
|
||||||
|
|||||||
+22
-6
@@ -10,6 +10,7 @@ from sqlalchemy import func, select
|
|||||||
|
|
||||||
from .common import iso
|
from .common import iso
|
||||||
from .db import session_scope
|
from .db import session_scope
|
||||||
|
from .invites import INVALID as INVALID_INVITE, record_redeemer, redeem
|
||||||
from .models.device_token import DeviceToken
|
from .models.device_token import DeviceToken
|
||||||
from .models.user import User
|
from .models.user import User
|
||||||
from .proxy import client_address
|
from .proxy import client_address
|
||||||
@@ -176,6 +177,7 @@ async def register():
|
|||||||
email = (data.get("email") or "").strip().lower()
|
email = (data.get("email") or "").strip().lower()
|
||||||
password = data.get("password") or ""
|
password = data.get("password") or ""
|
||||||
display_name = (data.get("display_name") or "").strip()
|
display_name = (data.get("display_name") or "").strip()
|
||||||
|
invite = (data.get("invite") or "").strip()
|
||||||
|
|
||||||
if not email or "@" not in email:
|
if not email or "@" not in email:
|
||||||
return jsonify({"error": "a valid email is required"}), 400
|
return jsonify({"error": "a valid email is required"}), 400
|
||||||
@@ -196,10 +198,21 @@ async def register():
|
|||||||
user_count = await db.scalar(select(func.count()).select_from(User)) or 0
|
user_count = await db.scalar(select(func.count()).select_from(User)) or 0
|
||||||
is_first = user_count == 0
|
is_first = user_count == 0
|
||||||
# The first account bootstraps the admin and is always allowed, even when
|
# The first account bootstraps the admin and is always allowed, even when
|
||||||
# registration is otherwise closed.
|
# registration is otherwise closed. After that, an invite lets one person in
|
||||||
if not is_first and not await get_setting(db, "allow_registration"):
|
# while it is closed (#5172). One that was offered is redeemed even while
|
||||||
|
# registration is open, so the list still says who used it, and one that
|
||||||
|
# doesn't hold is refused rather than ignored.
|
||||||
|
invite_id = None
|
||||||
|
if not is_first and invite:
|
||||||
|
invite_id = await redeem(db, invite, email)
|
||||||
|
if invite_id is None:
|
||||||
|
logger.warning("registration refused (bad invite) email=%s from=%s", email, client_address())
|
||||||
|
return jsonify({"error": INVALID_INVITE}), 403
|
||||||
|
elif not is_first and not await get_setting(db, "allow_registration"):
|
||||||
logger.warning("registration refused (closed) email=%s from=%s", email, client_address())
|
logger.warning("registration refused (closed) email=%s from=%s", email, client_address())
|
||||||
return jsonify({"error": "registration is closed"}), 403
|
return jsonify({"error": "registration is closed"}), 403
|
||||||
|
# Returning without a commit rolls back the redemption above with it, so a
|
||||||
|
# taken email leaves the invite usable.
|
||||||
existing = await db.scalar(select(User).where(User.email == email))
|
existing = await db.scalar(select(User).where(User.email == email))
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return jsonify({"error": "an account with that email already exists"}), 409
|
return jsonify({"error": "an account with that email already exists"}), 409
|
||||||
@@ -220,16 +233,19 @@ async def register():
|
|||||||
# off in Settings" was wide open, and on a public host that gap is the
|
# off in Settings" was wide open, and on a public host that gap is the
|
||||||
# entire exposure — it starts the moment DNS resolves.
|
# entire exposure — it starts the moment DNS resolves.
|
||||||
#
|
#
|
||||||
# An admin who wants a second person turns it back on in Settings → Access,
|
# An admin who wants a second person sends them an invite (Settings →
|
||||||
# adds them, and turns it off. Crude until invites exist, but it is a
|
# Invites), which works with registration closed.
|
||||||
# deliberate act rather than a default nobody chose.
|
|
||||||
await set_settings(db, {"allow_registration": False})
|
await set_settings(db, {"allow_registration": False})
|
||||||
|
if invite_id is not None:
|
||||||
|
await db.flush()
|
||||||
|
await record_redeemer(db, invite_id, user.id)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
session[SESSION_KEY] = str(user.id)
|
session[SESSION_KEY] = str(user.id)
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
logger.info(
|
logger.info(
|
||||||
"account created email=%s admin=%s from=%s", email, is_first, client_address()
|
"account created email=%s admin=%s invite=%s from=%s",
|
||||||
|
email, is_first, invite_id, client_address(),
|
||||||
)
|
)
|
||||||
return jsonify(_serialize_user(user)), 201
|
return jsonify(_serialize_user(user)), 201
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Invites: an admin lets one person register while registration is closed (#5172).
|
||||||
|
|
||||||
|
Before these, adding a second person meant turning registration back on for the
|
||||||
|
whole internet while they signed up, with no record of who was let in (#2939 §1).
|
||||||
|
|
||||||
|
An invite is a link carrying a random token. It works once, until it expires, and
|
||||||
|
optionally only for one email address. Only the token's hash is kept, so the link is
|
||||||
|
shown once, when it is made; an admin who loses it revokes it and makes another.
|
||||||
|
|
||||||
|
This module is the invite itself: its status, its wire shape, and redemption, which
|
||||||
|
`auth.register` calls inside the transaction that creates the account. The admin
|
||||||
|
routes are in `invites_api.py`, the same split as `settings` and `settings_api`, and
|
||||||
|
for the same reason: the routes need `auth`, and `auth` needs this.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import or_, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from .common import iso
|
||||||
|
from .models.invite import Invite
|
||||||
|
from .security import hash_token
|
||||||
|
|
||||||
|
# A week covers "I'll send it tonight and they'll get to it at the weekend"; a month
|
||||||
|
# is the longest a live credential to an instance should sit in someone's inbox.
|
||||||
|
DEFAULT_DAYS = 7
|
||||||
|
MAX_DAYS = 30
|
||||||
|
|
||||||
|
# The one answer to every failed redemption. Which of unknown, used, expired, revoked
|
||||||
|
# or "not for this email" it was is the admin's business (the list says), not the
|
||||||
|
# business of whoever is holding the link.
|
||||||
|
INVALID = "invalid or expired invite"
|
||||||
|
|
||||||
|
|
||||||
|
def status(invite: Invite, now: datetime) -> str:
|
||||||
|
"""pending, redeemed, revoked or expired. Redeemed wins over revoked: once an
|
||||||
|
account exists, revoking the link that made it changes nothing."""
|
||||||
|
if invite.redeemed_at is not None:
|
||||||
|
return "redeemed"
|
||||||
|
if invite.revoked_at is not None:
|
||||||
|
return "revoked"
|
||||||
|
if invite.expires_at <= now:
|
||||||
|
return "expired"
|
||||||
|
return "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def serialize(invite: Invite, now: datetime, redeemed_email: str | None = None) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(invite.id),
|
||||||
|
"email": invite.email,
|
||||||
|
"created_at": iso(invite.created_at),
|
||||||
|
"expires_at": iso(invite.expires_at),
|
||||||
|
"redeemed_at": iso(invite.redeemed_at),
|
||||||
|
"redeemed_by_email": redeemed_email,
|
||||||
|
"revoked_at": iso(invite.revoked_at),
|
||||||
|
"status": status(invite, now),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lifetime_days(raw: object) -> int | None:
|
||||||
|
"""The requested lifetime in whole days, or None when it isn't one we allow."""
|
||||||
|
if raw is None:
|
||||||
|
return DEFAULT_DAYS
|
||||||
|
# Whole numbers only, refused rather than rounded: an invite that quietly lasts a
|
||||||
|
# different time from the one asked for is a surprise in the wrong direction.
|
||||||
|
if isinstance(raw, int) and not isinstance(raw, bool):
|
||||||
|
days = raw
|
||||||
|
elif isinstance(raw, str) and raw.strip().isdigit():
|
||||||
|
days = int(raw)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return days if 1 <= days <= MAX_DAYS else None
|
||||||
|
|
||||||
|
|
||||||
|
async def redeem(db: AsyncSession, token: str, email: str) -> uuid.UUID | None:
|
||||||
|
"""Claim the invite for `email`, returning its id, or None if it can't be used.
|
||||||
|
|
||||||
|
One conditional UPDATE, so two people racing the same link can't both win: the
|
||||||
|
second waits on the first's row lock and then finds `redeemed_at` set. Not
|
||||||
|
committed here. The caller commits together with the new account, so an account
|
||||||
|
that fails to be created (the email is taken) leaves the invite unused.
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return await db.scalar(
|
||||||
|
update(Invite)
|
||||||
|
.where(
|
||||||
|
Invite.token_hash == hash_token(token),
|
||||||
|
Invite.redeemed_at.is_(None),
|
||||||
|
Invite.revoked_at.is_(None),
|
||||||
|
Invite.expires_at > now,
|
||||||
|
or_(Invite.email.is_(None), Invite.email == email),
|
||||||
|
)
|
||||||
|
.values(redeemed_at=now)
|
||||||
|
.returning(Invite.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def record_redeemer(db: AsyncSession, invite_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||||
|
"""Name the account an invite made, once that account has an id."""
|
||||||
|
await db.execute(update(Invite).where(Invite.id == invite_id).values(redeemed_by=user_id))
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Admin routes for invites: make one, list them, revoke one (#5172).
|
||||||
|
|
||||||
|
What an invite is, and how it is redeemed, is in `invites.py`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from quart import Blueprint, g, jsonify, request
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
|
from .auth import require_admin
|
||||||
|
from .db import session_scope
|
||||||
|
from .invites import MAX_DAYS, lifetime_days, serialize
|
||||||
|
from .models.invite import Invite
|
||||||
|
from .models.user import User
|
||||||
|
from .proxy import client_address
|
||||||
|
from .security import generate_token, hash_token
|
||||||
|
|
||||||
|
bp = Blueprint("invites", __name__, url_prefix="/api/invites")
|
||||||
|
|
||||||
|
# Credential events go to the app log, as in `auth` (there is no audit table yet).
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("")
|
||||||
|
@require_admin
|
||||||
|
async def create_invite():
|
||||||
|
data = await request.get_json(silent=True) or {}
|
||||||
|
email = (data.get("email") or "").strip().lower() or None
|
||||||
|
if email is not None and "@" not in email:
|
||||||
|
return jsonify({"error": "that doesn't look like an email address"}), 400
|
||||||
|
days = lifetime_days(data.get("days"))
|
||||||
|
if days is None:
|
||||||
|
return jsonify({"error": f"an invite lasts between 1 and {MAX_DAYS} days"}), 400
|
||||||
|
|
||||||
|
token = generate_token()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
async with session_scope() as db:
|
||||||
|
invite = Invite(
|
||||||
|
token_hash=hash_token(token),
|
||||||
|
created_by=g.user_id,
|
||||||
|
email=email,
|
||||||
|
expires_at=now + timedelta(days=days),
|
||||||
|
)
|
||||||
|
db.add(invite)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(invite)
|
||||||
|
logger.info(
|
||||||
|
"invite created id=%s for=%s days=%s by=%s from=%s",
|
||||||
|
invite.id, email or "anyone", days, g.user_id, client_address(),
|
||||||
|
)
|
||||||
|
# The token goes back exactly once. The client builds the link from it, since only
|
||||||
|
# the browser knows the address people actually reach this server at.
|
||||||
|
return jsonify({"invite": serialize(invite, now), "token": token}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("")
|
||||||
|
@require_admin
|
||||||
|
async def list_invites():
|
||||||
|
redeemer = aliased(User)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
async with session_scope() as db:
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(Invite, redeemer.email)
|
||||||
|
.outerjoin(redeemer, redeemer.id == Invite.redeemed_by)
|
||||||
|
.order_by(Invite.created_at.desc())
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return jsonify({"invites": [serialize(invite, now, email) for invite, email in rows]})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.delete("/<invite_id>")
|
||||||
|
@require_admin
|
||||||
|
async def revoke_invite(invite_id: str):
|
||||||
|
try:
|
||||||
|
iid = uuid.UUID(invite_id)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
async with session_scope() as db:
|
||||||
|
invite = await db.get(Invite, iid)
|
||||||
|
if invite is None:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
# A used invite has done its work and its record stays as it is; an already
|
||||||
|
# revoked one keeps its first revocation time.
|
||||||
|
if invite.redeemed_at is None and invite.revoked_at is None:
|
||||||
|
invite.revoked_at = now
|
||||||
|
await db.commit()
|
||||||
|
logger.info("invite revoked id=%s by=%s from=%s", iid, g.user_id, client_address())
|
||||||
|
return jsonify(serialize(invite, now))
|
||||||
@@ -6,6 +6,7 @@ Imported for side effects only (model registration on Base.metadata).
|
|||||||
from . import ( # noqa: F401
|
from . import ( # noqa: F401
|
||||||
device_token,
|
device_token,
|
||||||
group,
|
group,
|
||||||
|
invite,
|
||||||
label,
|
label,
|
||||||
note,
|
note,
|
||||||
note_attachment,
|
note_attachment,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import CITEXT, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from . import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Invite(Base):
|
||||||
|
"""A single-use, expiring link an admin hands to one person so they can register
|
||||||
|
while registration is closed (#5172).
|
||||||
|
|
||||||
|
Only the token's SHA-256 hash is stored, as for device tokens; the link is shown
|
||||||
|
once, when the invite is made. Rows are never deleted by the app: a used, expired
|
||||||
|
or revoked invite stays as the record of who let whom in.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "invites"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
token_hash: Mapped[str] = mapped_column(Text(), nullable=False, unique=True)
|
||||||
|
# SET NULL rather than CASCADE: the account that redeemed an invite outlives the
|
||||||
|
# admin who issued it, and so should the record of how it got here.
|
||||||
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
# When set, only this address may register with the invite. CITEXT, like
|
||||||
|
# users.email, so the pin can't be dodged or tripped by capitalisation.
|
||||||
|
email: Mapped[str | None] = mapped_column(CITEXT(), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
redeemed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
redeemed_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -39,8 +39,8 @@ REGISTRY: list[SettingDef] = [
|
|||||||
"bool",
|
"bool",
|
||||||
True,
|
True,
|
||||||
"Allow new registrations",
|
"Allow new registrations",
|
||||||
"When off, only existing users can sign in. Closes itself once the first "
|
"When off, new people can join only with an invite. Closes itself once the "
|
||||||
"account exists — turn it back on only while you're adding someone.",
|
"first account exists.",
|
||||||
"Access",
|
"Access",
|
||||||
),
|
),
|
||||||
SettingDef(
|
SettingDef(
|
||||||
|
|||||||
+136
-4
@@ -15,18 +15,20 @@ deployment has ever seen.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from sqlalchemy import func, select, text
|
from sqlalchemy import func, select, text, update
|
||||||
|
|
||||||
from inkwell import ratelimit
|
from inkwell import ratelimit
|
||||||
from inkwell.app import create_app
|
from inkwell.app import create_app
|
||||||
from inkwell.config import Config
|
from inkwell.config import Config
|
||||||
from inkwell.db import dispose_engine, session_scope
|
from inkwell.db import dispose_engine, session_scope
|
||||||
|
from inkwell.models.invite import Invite
|
||||||
from inkwell.models.label import NoteLabel
|
from inkwell.models.label import NoteLabel
|
||||||
from inkwell.models.note import Note
|
from inkwell.models.note import Note
|
||||||
from inkwell.models.note_attachment import NoteAttachment
|
from inkwell.models.note_attachment import NoteAttachment
|
||||||
@@ -44,7 +46,7 @@ pytestmark = pytest.mark.integration
|
|||||||
|
|
||||||
# Every table the tests touch, child-first so FKs never block the truncate.
|
# Every table the tests touch, child-first so FKs never block the truncate.
|
||||||
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
|
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
|
||||||
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, users"
|
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, invites, users"
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
@@ -390,8 +392,8 @@ async def test_registration_closes_itself_once_an_admin_exists(app_client, db):
|
|||||||
)
|
)
|
||||||
assert second.status_code == 403
|
assert second.status_code == 403
|
||||||
|
|
||||||
# Re-opening it deliberately still works — that is how a second person gets in
|
# Re-opening it deliberately still works, for an admin who would rather open the
|
||||||
# until invites exist.
|
# door than send an invite.
|
||||||
async with session_scope() as fresh:
|
async with session_scope() as fresh:
|
||||||
await set_settings(fresh, {"allow_registration": True})
|
await set_settings(fresh, {"allow_registration": True})
|
||||||
await fresh.commit()
|
await fresh.commit()
|
||||||
@@ -932,3 +934,133 @@ async def test_a_keep_note_that_is_only_a_photo_imports(app_client, db):
|
|||||||
[note] = (await db.scalars(select(Note))).all()
|
[note] = (await db.scalars(select(Note))).all()
|
||||||
[att] = (await db.scalars(select(NoteAttachment).where(NoteAttachment.note_id == note.id))).all()
|
[att] = (await db.scalars(select(NoteAttachment).where(NoteAttachment.note_id == note.id))).all()
|
||||||
assert att.mime == "image/png"
|
assert att.mime == "image/png"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- invites (#5172) ---------------------------------------------------------------
|
||||||
|
|
||||||
|
_PASSWORD = "a-long-enough-password"
|
||||||
|
|
||||||
|
|
||||||
|
async def _admin_with_invite(app_client, **body) -> str:
|
||||||
|
"""Register the instance's first account (the admin, signed in on `app_client`)
|
||||||
|
and have it issue an invite. Returns the token."""
|
||||||
|
created = await app_client.post("/api/auth/register", json={"email": "owner@example.test", "password": _PASSWORD})
|
||||||
|
assert created.status_code == 201
|
||||||
|
resp = await app_client.post("/api/invites", json=body)
|
||||||
|
assert resp.status_code == 201, await resp.get_data(as_text=True)
|
||||||
|
return (await resp.get_json())["token"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _register(email: str, invite: str | None = None):
|
||||||
|
"""Register from a separate client, so the admin's session stays where it is."""
|
||||||
|
body = {"email": email, "password": _PASSWORD}
|
||||||
|
if invite is not None:
|
||||||
|
body["invite"] = invite
|
||||||
|
return await create_app().test_client().post("/api/auth/register", json=body)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_invite_lets_one_person_in_while_registration_stays_closed(app_client, db):
|
||||||
|
token = await _admin_with_invite(app_client)
|
||||||
|
|
||||||
|
# Registration closed itself behind the admin; a stranger with no invite is out.
|
||||||
|
stranger = await _register("stranger@example.test")
|
||||||
|
assert stranger.status_code == 403
|
||||||
|
|
||||||
|
invited = await _register("guest@example.test", token)
|
||||||
|
assert invited.status_code == 201, await invited.get_data(as_text=True)
|
||||||
|
assert (await invited.get_json())["is_admin"] is False
|
||||||
|
|
||||||
|
# Single use: the same link again, for anyone, is refused with the generic answer.
|
||||||
|
again = await _register("someone-else@example.test", token)
|
||||||
|
assert again.status_code == 403
|
||||||
|
assert (await again.get_json())["error"] == "invalid or expired invite"
|
||||||
|
|
||||||
|
# The admin's list says who used it.
|
||||||
|
listed = (await (await app_client.get("/api/invites")).get_json())["invites"]
|
||||||
|
assert [(i["status"], i["redeemed_by_email"]) for i in listed] == [("redeemed", "guest@example.test")]
|
||||||
|
async with session_scope() as fresh:
|
||||||
|
assert await get_setting(fresh, "allow_registration") is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_only_an_admin_handles_invites(app_client, db):
|
||||||
|
token = await _admin_with_invite(app_client)
|
||||||
|
guest = create_app().test_client()
|
||||||
|
joined = await guest.post(
|
||||||
|
"/api/auth/register", json={"email": "guest@example.test", "password": _PASSWORD, "invite": token}
|
||||||
|
)
|
||||||
|
assert joined.status_code == 201
|
||||||
|
# Signed in as the guest now, who is not an admin.
|
||||||
|
assert (await guest.post("/api/invites", json={})).status_code == 403
|
||||||
|
assert (await guest.get("/api/invites")).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_invite_pinned_to_an_email_admits_only_that_email(app_client, db):
|
||||||
|
token = await _admin_with_invite(app_client, email="Pinned@Example.test")
|
||||||
|
|
||||||
|
wrong = await _register("other@example.test", token)
|
||||||
|
assert wrong.status_code == 403
|
||||||
|
assert (await wrong.get_json())["error"] == "invalid or expired invite"
|
||||||
|
|
||||||
|
# Any capitalisation of the pinned address, since emails compare case-insensitively.
|
||||||
|
right = await _register("PINNED@example.test", token)
|
||||||
|
assert right.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
async def test_revoked_and_expired_invites_are_refused(app_client, db):
|
||||||
|
revoked = await _admin_with_invite(app_client)
|
||||||
|
expiring = (await (await app_client.post("/api/invites", json={"days": 1})).get_json())["token"]
|
||||||
|
|
||||||
|
listed = (await (await app_client.get("/api/invites")).get_json())["invites"]
|
||||||
|
by_status = {i["status"] for i in listed}
|
||||||
|
assert by_status == {"pending"}
|
||||||
|
# The invite made first is the last in the list (newest first).
|
||||||
|
revoked_id = listed[-1]["id"]
|
||||||
|
resp = await app_client.delete(f"/api/invites/{revoked_id}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert (await resp.get_json())["status"] == "revoked"
|
||||||
|
|
||||||
|
async with session_scope() as fresh:
|
||||||
|
await fresh.execute(
|
||||||
|
update(Invite)
|
||||||
|
.where(Invite.id != uuid.UUID(revoked_id))
|
||||||
|
.values(expires_at=datetime.now(timezone.utc) - timedelta(minutes=1))
|
||||||
|
)
|
||||||
|
await fresh.commit()
|
||||||
|
|
||||||
|
assert (await _register("a@example.test", revoked)).status_code == 403
|
||||||
|
assert (await _register("b@example.test", expiring)).status_code == 403
|
||||||
|
statuses = sorted(i["status"] for i in (await (await app_client.get("/api/invites")).get_json())["invites"])
|
||||||
|
assert statuses == ["expired", "revoked"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_invite_lifetime_outside_the_bounds_is_refused(app_client, db):
|
||||||
|
await _admin_with_invite(app_client)
|
||||||
|
for days in (0, 31, "a week", True):
|
||||||
|
resp = await app_client.post("/api/invites", json={"days": days})
|
||||||
|
assert resp.status_code == 400, days
|
||||||
|
resp = await app_client.post("/api/invites", json={"email": "not-an-address"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_taken_email_leaves_the_invite_unused(app_client, db):
|
||||||
|
"""The redemption and the new account are one transaction: an account that can't
|
||||||
|
be created must not use up the link."""
|
||||||
|
token = await _admin_with_invite(app_client)
|
||||||
|
taken = await _register("owner@example.test", token)
|
||||||
|
assert taken.status_code == 409
|
||||||
|
listed = (await (await app_client.get("/api/invites")).get_json())["invites"]
|
||||||
|
assert listed[0]["status"] == "pending"
|
||||||
|
assert (await _register("guest@example.test", token)).status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
async def test_two_people_racing_one_invite_cannot_both_get_in(app_client, db):
|
||||||
|
token = await _admin_with_invite(app_client)
|
||||||
|
results = await asyncio.gather(
|
||||||
|
_register("first@example.test", token),
|
||||||
|
_register("second@example.test", token),
|
||||||
|
)
|
||||||
|
assert sorted(r.status_code for r in results) == [201, 403]
|
||||||
|
async with session_scope() as fresh:
|
||||||
|
count = await fresh.scalar(select(func.count()).select_from(User))
|
||||||
|
assert count == 2, "the admin and exactly one of the two"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Invite status and lifetime rules, DB-free. Redemption itself is a conditional
|
||||||
|
UPDATE and is tested against Postgres in test_integration.py."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from inkwell.invites import DEFAULT_DAYS, MAX_DAYS, lifetime_days, status
|
||||||
|
from inkwell.models.invite import Invite
|
||||||
|
|
||||||
|
NOW = datetime(2026, 10, 7, 12, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _invite(**fields) -> Invite:
|
||||||
|
fields.setdefault("expires_at", NOW + timedelta(days=1))
|
||||||
|
return Invite(token_hash="x", **fields)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_untouched_invite_in_date_is_pending():
|
||||||
|
assert status(_invite(), NOW) == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_invite_is_expired_from_its_expiry_instant():
|
||||||
|
assert status(_invite(expires_at=NOW), NOW) == "expired"
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoked_beats_expired_and_redeemed_beats_both():
|
||||||
|
assert status(_invite(expires_at=NOW, revoked_at=NOW), NOW) == "revoked"
|
||||||
|
assert status(_invite(expires_at=NOW, revoked_at=NOW, redeemed_at=NOW), NOW) == "redeemed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw, days",
|
||||||
|
[(None, DEFAULT_DAYS), (1, 1), ("14", 14), (MAX_DAYS, MAX_DAYS), (0, None), (MAX_DAYS + 1, None),
|
||||||
|
("soon", None), (True, None), (2.5, None)],
|
||||||
|
)
|
||||||
|
def test_lifetimes(raw, days):
|
||||||
|
assert lifetime_days(raw) == days
|
||||||
Reference in New Issue
Block a user