feat: allow disabling registrations + invitation system

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-27 19:30:34 +02:00
1 parent a1b8ac7887
commit 91ffbf5441
22 files changed
+834 -66

No files matched your search

@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "Invitation" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"token" TEXT NOT NULL,
"invitedById" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Invitation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Invitation_email_key" ON "Invitation"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Invitation_token_key" ON "Invitation"("token");
-- AddForeignKey
ALTER TABLE "Invitation" ADD CONSTRAINT "Invitation_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+38 -21
View File
@@ -58,10 +58,27 @@ model User {
logs Log[]
authorLinks AuthorLink[]
bookmarks Bookmark[]
invitationsSent Invitation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// A pending invitation to register while new registrations are turned off (Administration >
// Users). The emailed link carries `token`; registering with it is the only way in for
// that email, and it's deleted the moment the account is created. One per email -- inviting the
// same address again replaces the token, so only the newest link works. See
// services/invitationService.ts.
model Invitation {
id String @id @default(cuid())
email String @unique
token String @unique
invitedById String?
invitedBy User? @relation(fields: [invitedById], references: [id], onDelete: SetNull)
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Category {
id String @id @default(cuid())
userId String
@@ -101,18 +118,18 @@ model Category {
}
model Print {
id String @id @default(cuid())
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
nameNormalized String
title String?
notes String?
creator String?
collection String?
tags String[] @default([])
viewCount Int @default(0)
printCount Int @default(0)
tags String[] @default([])
viewCount Int @default(0)
printCount Int @default(0)
// Backs the two built-in "Favourites"/"Browsing History" pseudo-collections (see
// routes/collections.ts's SYSTEM_COLLECTIONS) -- not a real Collection/CollectionItem row.
// lastViewedAt is stamped on every GET /print/:id alongside viewCount, so "Browsing History"
@@ -126,19 +143,19 @@ model Print {
sourceProvider String?
sourceExternalId String?
categoryId String?
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
authorId String?
author Author? @relation(fields: [authorId], references: [id], onDelete: SetNull)
author Author? @relation(fields: [authorId], references: [id], onDelete: SetNull)
sourcePath String?
plates Plate[]
previewImages PreviewImage[]
files PrintFile[] @relation("PrintFiles")
preparedFileId String? @unique
preparedFile PrintFile? @relation("PreparedFile", fields: [preparedFileId], references: [id], onDelete: SetNull)
files PrintFile[] @relation("PrintFiles")
preparedFileId String? @unique
preparedFile PrintFile? @relation("PreparedFile", fields: [preparedFileId], references: [id], onDelete: SetNull)
preparedMetadata Json?
collectionItems CollectionItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Generated column (name/title weight A, tags weight B, notes weight C, creator weight D) that
// backs the global search box -- see services/searchService.ts and the hand-written migration
// that actually creates it (`GENERATED ALWAYS AS (...) STORED`, plus its own GIN index).
@@ -162,17 +179,17 @@ model Print {
/// on nameNormalized so re-importing the same MakerWorld collection reuses the same row
/// instead of creating a duplicate.
model Collection {
id String @id @default(cuid())
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
nameNormalized String
description String?
tags String[] @default([])
tags String[] @default([])
items CollectionItem[]
bookmarks Bookmark[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Same generated-tsvector search column as Print.searchVector above (name weight A, tags
// weight B, description weight C) -- see that field's comment and services/searchService.ts.
searchVector Unsupported("tsvector")?
@@ -302,21 +319,21 @@ model Notification {
/// duplicate. Print.creator stays as the plain display string for backward compatibility and
/// non-provider sources; this table is the richer record clickable author pages will use.
model Author {
id String @id
id String @id
provider String
externalId String
name String?
handle String?
bio String?
bioTranslated String?
links String[] @default([])
links String[] @default([])
avatarUrl String?
backgroundUrl String?
prints Print[]
// At most one Thingport account can claim to be this real-world creator -- see AuthorLink.
link AuthorLink?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([provider, externalId])
}
+25
View File
@@ -3,7 +3,11 @@ import { prisma } from "../db";
import { requireAdmin, requireAuth } from "../auth";
import { HttpError } from "../utils/fileUtils";
import { asyncHandler } from "../utils/asyncHandler";
import { z } from "zod";
import { parseBody } from "../utils/validate";
import { deleteAllPrintsForUser, getStorageUsage, listLogs, listUsersWithPrintCounts } from "../services/adminService";
import { createLog } from "../services/auditLog";
import { INVITATION_TTL_DAYS, inviteUser } from "../services/invitationService";
const router = Router();
router.use(requireAuth);
@@ -67,6 +71,27 @@ router.get(
}),
);
// Administration > Users > "Invite users" -- only while registrations are closed and SMTP
// is set up (inviteUser enforces both). Inviting an already-invited address sends a fresh link
// and retires the old one.
const inviteSchema = z.object({ email: z.string().trim().email("Enter a valid email address") });
router.post(
"/admin/invitations",
asyncHandler(async (req, res) => {
const body = parseBody(inviteSchema, req.body);
const admin = await prisma.user.findUnique({ where: { id: req.userId! } });
if (!admin) throw new HttpError(401, "Invalid or expired token");
const invitation = await inviteUser({
email: body.email,
invitedById: admin.id,
inviterName: admin.displayName,
origin: req.get("origin") ?? null,
});
void createLog({ userId: admin.id, action: "user_invited", details: { email: invitation.email } });
res.json({ email: invitation.email, expires_at: invitation.expiresAt, expires_in_days: INVITATION_TTL_DAYS });
}),
);
// The two-part "select a user + type their email to confirm" flow lives entirely on the
// frontend (TriggersSection.tsx) -- this endpoint just does the (irreversible) deletion once
// asked, trusting the UI already got explicit confirmation from the admin.
+31 -2
View File
@@ -9,6 +9,7 @@ import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import { getAllowRegistrations, isSmtpConfigured } from "../services/settingsService";
import { sendVerificationEmail } from "../services/mailer";
import { findValidInvitation } from "../services/invitationService";
import { createLog } from "../services/auditLog";
import { seedDefaultCategories } from "../services/categoryService";
import { toUserOut } from "../dto";
@@ -27,6 +28,9 @@ const registerSchema = z.object({
displayName: z.string().trim().min(1, "Display name is required"),
email: z.string().trim().email("Enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
// From an invitation link (services/invitationService.ts) -- the only way to register while
// registrations are closed.
invite_token: z.string().min(1).optional(),
});
const loginSchema = z.object({
@@ -46,7 +50,17 @@ router.post(
const adminExists = (await prisma.user.count({ where: { role: "ADMIN" } })) > 0;
const bootstrapping = !adminExists;
if (!bootstrapping && !(await getAllowRegistrations(true))) {
// An invitation lets its own email in even while registrations are closed, and only that
// email: the token has to match an unexpired invitation *for the address being registered*.
const invitation = body.invite_token ? await findValidInvitation(body.invite_token) : null;
if (body.invite_token && !invitation) {
throw new HttpError(400, "This invitation link is invalid or has expired. Ask for a new one.");
}
if (invitation && invitation.email !== email) {
throw new HttpError(400, "This invitation is for a different email address.");
}
if (!bootstrapping && !invitation && !(await getAllowRegistrations(true))) {
throw new HttpError(403, "Registration is currently disabled");
}
if (await prisma.user.findUnique({ where: { email } })) {
@@ -60,7 +74,9 @@ router.post(
// Otherwise, email verification only actually happens when SMTP is configured; unconfigured
// instances create fully-verified accounts immediately so this feature is opt-in, not a
// requirement that breaks self-hosters who never set up a mail server.
const smtpConfigured = !bootstrapping && (await isSmtpConfigured());
// An invited user skips it too: they reached this form through a link sent to that very
// address, which already proves they own the mailbox.
const smtpConfigured = !bootstrapping && !invitation && (await isSmtpConfigured());
const verification = smtpConfigured ? newVerificationToken() : null;
// Wrapped together so an account never ends up missing its starter categories (or vice
@@ -78,6 +94,8 @@ router.post(
},
});
await seedDefaultCategories(tx, created.id);
// Single use: the invitation goes away with the account it created.
if (invitation) await tx.invitation.delete({ where: { id: invitation.id } });
return created;
}, { timeout: 15000 }); // seedDefaultCategories is ~80 sequential inserts -- Prisma's 5s default is too tight
@@ -120,6 +138,17 @@ router.post(
}),
);
// Backs the invitation link's registration form: confirms the link is still good before the
// invitee fills anything in. Returns only the invited email, which the link already contains.
router.get(
"/invitations/:token",
asyncHandler(async (req, res) => {
const invitation = await findValidInvitation(req.params.token);
if (!invitation) throw new HttpError(404, "This invitation link is invalid or has expired. Ask for a new one.");
res.json({ email: invitation.email, expires_at: invitation.expiresAt });
}),
);
const verifyEmailSchema = z.object({ token: z.string().min(1) });
router.post(
+5 -1
View File
@@ -1,5 +1,6 @@
import { Router } from "express";
import { asyncHandler } from "../utils/asyncHandler";
import { prisma } from "../db";
import { getAllowRegistrations } from "../services/settingsService";
const router = Router();
@@ -7,7 +8,10 @@ const router = Router();
router.get(
"/health",
asyncHandler(async (_req, res) => {
res.json({ ok: true, auth_required: true, allow_registrations: await getAllowRegistrations(true) });
// Whether the sign-in page offers a Register tab. Always true until the first admin exists --
// that first account can register whatever the setting says (see routes/auth.ts).
const bootstrapping = (await prisma.user.count({ where: { role: "ADMIN" } })) === 0;
res.json({ ok: true, auth_required: true, allow_registrations: bootstrapping || (await getAllowRegistrations(true)) });
}),
);
+2 -2
View File
@@ -95,8 +95,8 @@ router.get(
}),
);
// No admin UI calls this yet -- added now so the "admin panel later" plan has a working
// endpoint to build against without another backend change.
// Administration > Users. Closing registrations makes POST /register refuse everyone but
// invitees (see services/invitationService.ts) and the very first account.
const registrationsSchema = z.object({ allow_registrations: z.boolean() });
router.post(
"/settings/registrations",
+1
View File
@@ -4,6 +4,7 @@ import { prisma } from "../db";
export type LogAction =
| "user_logged_in"
| "user_logged_out"
| "user_invited"
| "model_uploaded"
| "model_imported"
| "import_completed"
+74
View File
@@ -0,0 +1,74 @@
// Invitations to register while new registrations are turned off (Administration > Users).
// An admin enters an email; the invitee gets a link with a single-use token, and registering with
// that token is the only way to create an account for that address (see routes/auth.ts). The token
// -- not just the email in the link -- is what authorizes it: otherwise anyone who knew an invited
// address could register it first and lock the real invitee out. It also proves the invitee owns
// the mailbox, so accounts created from an invitation skip the separate verification email.
import crypto from "node:crypto";
import type { Invitation } from "@prisma/client";
import { PUBLIC_URL } from "../config";
import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { sendInvitationEmail } from "./mailer";
import { getAllowRegistrations, isSmtpConfigured } from "./settingsService";
export const INVITATION_TTL_DAYS = 7;
/** The registration link an invitation email points to. `origin` (the admin's own browser origin)
* is only a fallback for instances without PUBLIC_URL -- it's the address the admin reached the
* frontend at, which is exactly what the invitee needs too. */
export function invitationLink(email: string, token: string, origin: string | null): string {
const base = PUBLIC_URL || (origin ?? "").replace(/\/+$/, "");
return `${base}/register?email=${encodeURIComponent(email)}&invite=${encodeURIComponent(token)}`;
}
/** Creates (or, for an address already invited, replaces) the invitation and emails it. Only
* allowed while registrations are closed -- open registrations need no invitation -- and with SMTP
* set up, since the link only ever travels by email. */
export async function inviteUser(params: { email: string; invitedById: string; inviterName: string; origin: string | null }): Promise<Invitation> {
const email = params.email.trim().toLowerCase();
if (await getAllowRegistrations(true)) {
throw new HttpError(400, "Registrations are open, so anyone can sign up without an invitation.");
}
if (!(await isSmtpConfigured())) {
throw new HttpError(400, "Set up SMTP (Connections > SMTP) to send invitations.");
}
if (await prisma.user.findUnique({ where: { email } })) {
throw new HttpError(409, "An account with this email already exists");
}
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000);
const previous = await prisma.invitation.findUnique({ where: { email } });
const invitation = await prisma.invitation.upsert({
where: { email },
create: { email, token, expiresAt, invitedById: params.invitedById },
update: { token, expiresAt, invitedById: params.invitedById },
});
try {
await sendInvitationEmail(email, params.inviterName, invitationLink(email, token, params.origin), INVITATION_TTL_DAYS);
} catch (err) {
console.error("[invitations] Failed to send invitation email:", err);
// Leave things as they were: no brand-new invitation nobody received, and a re-invite keeps
// the earlier (already delivered) link working.
if (previous) {
await prisma.invitation.update({
where: { email },
data: { token: previous.token, expiresAt: previous.expiresAt, invitedById: previous.invitedById },
});
} else {
await prisma.invitation.delete({ where: { email } }).catch(() => undefined);
}
throw new HttpError(500, "Failed to send the invitation email. Check the SMTP settings and try again.");
}
return invitation;
}
/** The invitation behind a registration link, or null if the token is unknown or expired. */
export async function findValidInvitation(token: string): Promise<Invitation | null> {
const invitation = await prisma.invitation.findUnique({ where: { token } });
if (!invitation || invitation.expiresAt < new Date()) return null;
return invitation;
}
+28 -10
View File
@@ -2,28 +2,46 @@ import nodemailer from "nodemailer";
import { PUBLIC_URL } from "../config";
import { getSmtpSettings } from "./settingsService";
/** Sends the account-verification email a new registration (see routes/auth.ts) waits on before
* it can sign in. Only ever called once the caller has already confirmed SMTP is configured
* (isSmtpConfigured) -- throws if `host` somehow ends up unset here anyway, since silently
* dropping a verification email would leave the account permanently unreachable. */
export async function sendVerificationEmail(to: string, displayName: string, token: string): Promise<void> {
/** Sends one email through the instance's SMTP settings. Only ever called once the caller has
* already confirmed SMTP is configured (isSmtpConfigured) -- throws if `host` somehow ends up
* unset here anyway, since silently dropping one of these emails would leave its recipient with
* no way in. */
async function sendMail(message: { to: string; subject: string; text: string; html: string }): Promise<void> {
const smtp = await getSmtpSettings();
if (!smtp.host) throw new Error("SMTP is not configured");
const link = `${PUBLIC_URL}/verify-email?token=${encodeURIComponent(token)}`;
const transporter = nodemailer.createTransport({
host: smtp.host,
port: smtp.port,
secure: smtp.secure,
auth: smtp.user ? { user: smtp.user, pass: smtp.pass ?? undefined } : undefined,
});
await transporter.sendMail({ from: smtp.from, ...message });
}
await transporter.sendMail({
from: smtp.from,
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
}
/** Sends the account-verification email a new registration (see routes/auth.ts) waits on before
* it can sign in. */
export async function sendVerificationEmail(to: string, displayName: string, token: string): Promise<void> {
const link = `${PUBLIC_URL}/verify-email?token=${encodeURIComponent(token)}`;
await sendMail({
to,
subject: "Confirm your Thingport account",
text: `Hi ${displayName},\n\nConfirm your email address to finish creating your Thingport account:\n${link}\n\nThis link expires in 24 hours.`,
html: `<p>Hi ${displayName},</p><p>Confirm your email address to finish creating your Thingport account:</p><p><a href="${link}">${link}</a></p><p>This link expires in 24 hours.</p>`,
html: `<p>Hi ${escapeHtml(displayName)},</p><p>Confirm your email address to finish creating your Thingport account:</p><p><a href="${link}">${link}</a></p><p>This link expires in 24 hours.</p>`,
});
}
/** Sends an invitation to register while registrations are closed (see
* services/invitationService.ts). `link` is the full registration URL, token included. */
export async function sendInvitationEmail(to: string, inviterName: string, link: string, expiresInDays: number): Promise<void> {
await sendMail({
to,
subject: "You're invited to Thingport",
text: `${inviterName} invited you to create an account on their Thingport, a library for 3D-printing models.\n\nCreate your account here:\n${link}\n\nThe link works for ${expiresInDays} days, for this email address only.`,
html: `<p>${escapeHtml(inviterName)} invited you to create an account on their Thingport, a library for 3D-printing models.</p><p><a href="${link}">Create your account</a></p><p>Or open this link: ${link}</p><p>The link works for ${expiresInDays} days, for this email address only.</p>`,
});
}
+228
View File
@@ -0,0 +1,228 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import request from "supertest";
// No real email goes anywhere: the mailer is replaced, and each test reads the invitation link it
// was asked to send from the mock's calls.
vi.mock("../src/services/mailer", () => ({
sendVerificationEmail: vi.fn<(to: string, displayName: string, token: string) => Promise<void>>(async () => undefined),
sendInvitationEmail: vi.fn<(to: string, inviterName: string, link: string, expiresInDays: number) => Promise<void>>(
async () => undefined,
),
}));
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { sendInvitationEmail, sendVerificationEmail } from "../src/services/mailer";
import {
getAllowRegistrations,
getSmtpSettings,
setAllowRegistrations,
setSmtpSettings,
} from "../src/services/settingsService";
const app = createApp();
const sendInvitation = vi.mocked(sendInvitationEmail);
const sendVerification = vi.mocked(sendVerificationEmail);
let adminToken: string;
let memberToken: string;
// Every test file shares one database (see vitest.config.ts), so the instance-wide settings this
// file changes are put back afterwards.
let previousSmtpHost: string | null;
let previousAllowRegistrations: boolean;
const stamp = Date.now();
const email = (name: string) => `${name}-${stamp}@example.com`;
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
/** The token from the link in the most recent invitation email. */
function lastInvitationToken(): string {
const link = sendInvitation.mock.calls.at(-1)![2];
return new URL(link, "http://thingport.test").searchParams.get("invite")!;
}
async function closeRegistrationsWithSmtp() {
await setAllowRegistrations(false);
await setSmtpSettings({ host: "smtp.example.test" });
}
beforeAll(async () => {
previousSmtpHost = (await getSmtpSettings()).host;
previousAllowRegistrations = await getAllowRegistrations(true);
await setAllowRegistrations(true);
await setSmtpSettings({ host: null });
const adminRegister = await request(app)
.post("/api/register")
.send({ displayName: "Invite Admin", email: email("invite-admin"), password: "password123" });
await prisma.user.update({ where: { id: adminRegister.body.user.id }, data: { role: "ADMIN" } });
adminToken = (await request(app).post("/api/login").send({ email: email("invite-admin"), password: "password123" })).body.token;
const memberRegister = await request(app)
.post("/api/register")
.send({ displayName: "Invite Member", email: email("invite-member"), password: "password123" });
memberToken = memberRegister.body.token;
});
afterAll(async () => {
await setAllowRegistrations(previousAllowRegistrations);
await setSmtpSettings({ host: previousSmtpHost });
});
beforeEach(async () => {
sendInvitation.mockClear();
sendVerification.mockClear();
await setAllowRegistrations(true);
await setSmtpSettings({ host: null });
});
describe("closing registrations", () => {
it("refuses a plain registration and hides registration from the sign-in page", async () => {
await setAllowRegistrations(false);
const res = await request(app)
.post("/api/register")
.send({ displayName: "Nobody", email: email("closed"), password: "password123" });
expect(res.status).toBe(403);
expect((await request(app).get("/api/health")).body.allow_registrations).toBe(false);
});
it("only lets an admin change the setting", async () => {
const asMember = await request(app).post("/api/settings/registrations").set(auth(memberToken)).send({ allow_registrations: false });
expect(asMember.status).toBe(403);
const asAdmin = await request(app).post("/api/settings/registrations").set(auth(adminToken)).send({ allow_registrations: false });
expect(asAdmin.status).toBe(200);
expect(await getAllowRegistrations(true)).toBe(false);
});
});
describe("inviting", () => {
it("is admin-only", async () => {
await closeRegistrationsWithSmtp();
const res = await request(app).post("/api/admin/invitations").set(auth(memberToken)).send({ email: email("by-member") });
expect(res.status).toBe(403);
expect(sendInvitation).not.toHaveBeenCalled();
});
it("refuses while registrations are open", async () => {
await setSmtpSettings({ host: "smtp.example.test" });
const res = await request(app).post("/api/admin/invitations").set(auth(adminToken)).send({ email: email("open") });
expect(res.status).toBe(400);
expect(sendInvitation).not.toHaveBeenCalled();
});
it("refuses without SMTP", async () => {
await setAllowRegistrations(false);
const res = await request(app).post("/api/admin/invitations").set(auth(adminToken)).send({ email: email("no-smtp") });
expect(res.status).toBe(400);
expect(sendInvitation).not.toHaveBeenCalled();
});
it("refuses an address that already has an account", async () => {
await closeRegistrationsWithSmtp();
const res = await request(app).post("/api/admin/invitations").set(auth(adminToken)).send({ email: email("invite-member") });
expect(res.status).toBe(409);
});
it("emails a registration link carrying the email and a token, and logs it", async () => {
await closeRegistrationsWithSmtp();
const invitee = email("Invitee-Case");
const res = await request(app)
.post("/api/admin/invitations")
.set({ ...auth(adminToken), Origin: "https://thingport.example.test" })
.send({ email: invitee });
expect(res.status).toBe(200);
expect(res.body.email).toBe(invitee.toLowerCase());
expect(sendInvitation).toHaveBeenCalledTimes(1);
const [to, inviterName, link] = sendInvitation.mock.calls[0];
expect(to).toBe(invitee.toLowerCase());
expect(inviterName).toBe("Invite Admin");
const url = new URL(link);
expect(url.pathname).toBe("/register");
expect(url.searchParams.get("email")).toBe(invitee.toLowerCase());
expect(url.searchParams.get("invite")).toMatch(/^[0-9a-f]{64}$/);
const lookup = await request(app).get(`/api/invitations/${lastInvitationToken()}`);
expect(lookup.status).toBe(200);
expect(lookup.body.email).toBe(invitee.toLowerCase());
const log = await prisma.log.findFirst({ where: { action: "user_invited" }, orderBy: { createdAt: "desc" } });
expect(log?.details).toMatchObject({ email: invitee.toLowerCase() });
});
it("leaves no invitation behind when the email can't be sent", async () => {
await closeRegistrationsWithSmtp();
sendInvitation.mockRejectedValueOnce(new Error("SMTP down"));
const invitee = email("send-fails");
const res = await request(app).post("/api/admin/invitations").set(auth(adminToken)).send({ email: invitee });
expect(res.status).toBe(500);
expect(await prisma.invitation.findUnique({ where: { email: invitee } })).toBeNull();
});
});
describe("registering with an invitation", () => {
async function invite(address: string): Promise<string> {
await closeRegistrationsWithSmtp();
const res = await request(app).post("/api/admin/invitations").set(auth(adminToken)).send({ email: address });
expect(res.status).toBe(200);
return lastInvitationToken();
}
it("creates a verified account while registrations are closed, and the link works only once", async () => {
const invitee = email("accepts");
const token = await invite(invitee);
const res = await request(app)
.post("/api/register")
.send({ displayName: "Invited User", email: invitee, password: "password123", invite_token: token });
expect(res.status).toBe(200);
// Signed straight in: the invitation link already proved the mailbox, so no verification email.
expect(res.body.token).toBeTruthy();
expect(res.body.user.email).toBe(invitee);
expect(sendVerification).not.toHaveBeenCalled();
expect(await prisma.invitation.findUnique({ where: { email: invitee } })).toBeNull();
const again = await request(app)
.post("/api/register")
.send({ displayName: "Second Try", email: email("reuse"), password: "password123", invite_token: token });
expect(again.status).toBe(400);
expect((await request(app).get(`/api/invitations/${token}`)).status).toBe(404);
});
it("refuses the token for a different email", async () => {
const token = await invite(email("owner"));
const res = await request(app)
.post("/api/register")
.send({ displayName: "Someone Else", email: email("not-owner"), password: "password123", invite_token: token });
expect(res.status).toBe(400);
expect(await prisma.user.findUnique({ where: { email: email("not-owner") } })).toBeNull();
});
it("refuses an unknown token", async () => {
await closeRegistrationsWithSmtp();
const res = await request(app)
.post("/api/register")
.send({ displayName: "Guesser", email: email("guesser"), password: "password123", invite_token: "0".repeat(64) });
expect(res.status).toBe(400);
});
it("retires the old link when an address is invited again", async () => {
const invitee = email("reinvited");
const firstToken = await invite(invitee);
const secondToken = await invite(invitee);
expect(secondToken).not.toBe(firstToken);
expect((await request(app).get(`/api/invitations/${firstToken}`)).status).toBe(404);
expect((await request(app).get(`/api/invitations/${secondToken}`)).status).toBe(200);
});
it("refuses an expired invitation", async () => {
const invitee = email("expired");
const token = await invite(invitee);
await prisma.invitation.update({ where: { email: invitee }, data: { expiresAt: new Date(Date.now() - 1000) } });
expect((await request(app).get(`/api/invitations/${token}`)).status).toBe(404);
const res = await request(app)
.post("/api/register")
.send({ displayName: "Too Late", email: invitee, password: "password123", invite_token: token });
expect(res.status).toBe(400);
});
});
+10
View File
@@ -373,6 +373,15 @@ export default function App() {
// pre-login from the link in the verification email -- is checked directly against
// window.location rather than via a route.
const isVerifyEmailPath = typeof window !== "undefined" && window.location.pathname === "/verify-email";
// Same for /register?email=...&invite=... -- the link in an invitation email (see the backend's
// services/invitationService.ts), which opens the register form for that one address even
// while registrations are closed.
const inviteParams = typeof window !== "undefined" && window.location.pathname === "/register"
? new URLSearchParams(window.location.search)
: null;
const invite = inviteParams?.get("invite")
? { token: inviteParams.get("invite")!, email: inviteParams.get("email") ?? "" }
: null;
return (
<ThemeProvider theme={muiTheme}>
<CssBaseline />
@@ -393,6 +402,7 @@ export default function App() {
onSuccess={handleLogin}
apiUp={apiUp}
allowRegistrations={health?.allow_registrations ?? true}
invite={invite}
/>
)}
</Box>
+12
View File
@@ -15,6 +15,7 @@ export type AdminUser = {
export type LogAction =
| "user_logged_in"
| "user_logged_out"
| "user_invited"
| "model_uploaded"
| "model_imported"
| "import_completed"
@@ -49,6 +50,17 @@ export const adminApi = {
return res.json();
},
/** Emails an invitation to register (only while registrations are closed and SMTP is set up). */
inviteUser: async (email: string): Promise<{ email: string; expires_at: string; expires_in_days: number }> => {
const res = await fetch(`${apiBase()}/admin/invitations`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ email }),
});
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to send the invitation"));
return res.json();
},
listUsers: async (): Promise<AdminUser[]> => {
const res = await fetch(`${apiBase()}/admin/users`, { headers: authHeaders() });
assertOk(res, "Failed to load users");
+15 -2
View File
@@ -51,8 +51,21 @@ async function postAuth<T>(path: string, body: unknown): Promise<T> {
export const authApi = {
login: (email: string, password: string): Promise<AuthResult> => postAuth("/login", { email, password }),
register: (payload: { displayName: string; email: string; password: string }): Promise<RegisterResult> =>
postAuth("/register", { displayName: payload.displayName, email: payload.email, password: payload.password }),
// `inviteToken` comes from an invitation link -- the only way in while registrations are closed.
register: (payload: { displayName: string; email: string; password: string; inviteToken?: string }): Promise<RegisterResult> =>
postAuth("/register", {
displayName: payload.displayName,
email: payload.email,
password: payload.password,
...(payload.inviteToken ? { invite_token: payload.inviteToken } : {}),
}),
/** Checks an invitation link before its form is filled in -- rejects if it's invalid or expired. */
getInvitation: async (token: string): Promise<{ email: string; expires_at: string }> => {
const res = await fetch(`${apiBase()}/invitations/${encodeURIComponent(token)}`);
if (!res.ok) return readAuthError(res);
return res.json();
},
verifyEmail: (token: string): Promise<AuthResult> => postAuth("/verify-email", { token }),
+21
View File
@@ -114,6 +114,27 @@ export const settingsApi = {
return res.json();
},
// Instance-wide: whether anyone may register. Closed means only invited emails (adminApi.inviteUser)
// -- and the very first account -- can.
getRegistrations: async (): Promise<{ allow_registrations: boolean }> => {
const res = await fetch(`${apiBase()}/settings/registrations`, { headers: authHeaders() });
assertOk(res, "Failed to load registration settings");
return res.json();
},
updateRegistrations: async (allowRegistrations: boolean): Promise<{ allow_registrations: boolean }> => {
const res = await fetch(`${apiBase()}/settings/registrations`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ allow_registrations: allowRegistrations }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) {
throw new Error(await readErrorMessage(res, "Failed to update registration settings"));
}
return res.json();
},
// Instance-wide Thingiverse Developer API Access Token, shared by every user's Thingiverse
// imports -- write-only like any other API secret: GET only ever reports whether one is
// configured, never the value itself.
+23 -1
View File
@@ -29,7 +29,9 @@
"failed": "Registration failed",
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 8 characters",
"disabled": "New registrations are currently disabled."
"invited": "You've been invited to Thingport. Pick a display name and password to create your account.",
"inviteEmailLocked": "Your invitation is for this address.",
"inviteInvalid": "This invitation link is invalid or has expired. Ask whoever invited you for a new one."
},
"checkEmail": {
"heading": "Check your email",
@@ -297,6 +299,25 @@
"savedNoApply": "Storage structure saved for new and updated models.",
"failed": "Failed to save storage settings."
},
"registrations": {
"allowLabel": "Allow new registrations",
"openHelp": "Anyone who can reach this instance can create an account from the sign-in page.",
"closedHelp": "The sign-in page has no Register option, and new accounts can only be created from an invitation.",
"inviteButton": "Invite users",
"inviteHelp": "Sends a registration link by email. It works for one address, once, for 7 days.",
"failed": "Failed to update registration settings",
"invite": {
"title": "Invite a user",
"description": "They'll get an email with a link to create their account. Only this address can use it.",
"emailLabel": "Email address",
"send": "Send invitation",
"sending": "Sending...",
"close": "Close",
"sent_one": "Invitation sent to {{email}}. The link works for {{count}} day.",
"sent_other": "Invitation sent to {{email}}. The link works for {{count}} days.",
"failed": "Failed to send the invitation"
}
},
"previews": {
"heading": "Model Previews",
"subtitle": "Control background preview generation for the whole instance.",
@@ -446,6 +467,7 @@
"action": {
"user_logged_in": "Logged in",
"user_logged_out": "Logged out",
"user_invited": "Invited a user",
"model_uploaded": "Model uploaded",
"model_imported": "Model imported",
"import_completed": "Import completed",
+24 -1
View File
@@ -29,7 +29,9 @@
"failed": "Registracija nepavyko",
"passwordMismatch": "Slaptažodžiai nesutampa",
"passwordTooShort": "Slaptažodis turi būti bent 8 simbolių",
"disabled": "Šiuo metu naujos registracijos yra išjungtos."
"invited": "Jūs pakviesti į Thingport. Pasirinkite rodomą vardą ir slaptažodį, kad sukurtumėte paskyrą.",
"inviteEmailLocked": "Kvietimas skirtas šiam adresui.",
"inviteInvalid": "Ši kvietimo nuoroda negalioja arba jos galiojimas baigėsi. Paprašykite jus pakvietusio žmogaus naujos."
},
"checkEmail": {
"heading": "Patikrinkite savo el. paštą",
@@ -298,6 +300,26 @@
"savedNoApply": "Saugojimo struktūra išsaugota naujiems ir atnaujintiems modeliams.",
"failed": "Nepavyko išsaugoti saugojimo nustatymų."
},
"registrations": {
"allowLabel": "Leisti naujas registracijas",
"openHelp": "Bet kas, pasiekiantis šį serverį, gali susikurti paskyrą prisijungimo puslapyje.",
"closedHelp": "Prisijungimo puslapyje nėra registracijos, o naujas paskyras galima sukurti tik gavus kvietimą.",
"inviteButton": "Kviesti naudotojus",
"inviteHelp": "El. paštu išsiunčia registracijos nuorodą. Ją vieną kartą gali panaudoti tik vienas adresas, 7 dienas.",
"failed": "Nepavyko atnaujinti registracijos nustatymų",
"invite": {
"title": "Pakviesti naudotoją",
"description": "Gavėjas gaus el. laišką su nuoroda paskyrai sukurti. Ja gali pasinaudoti tik šis adresas.",
"emailLabel": "El. pašto adresas",
"send": "Siųsti kvietimą",
"sending": "Siunčiama...",
"close": "Uždaryti",
"sent_one": "Kvietimas išsiųstas adresu {{email}}. Nuoroda galios {{count}} dieną.",
"sent_few": "Kvietimas išsiųstas adresu {{email}}. Nuoroda galios {{count}} dienas.",
"sent_other": "Kvietimas išsiųstas adresu {{email}}. Nuoroda galios {{count}} dienų.",
"failed": "Nepavyko išsiųsti kvietimo"
}
},
"previews": {
"heading": "Modelių peržiūros",
"subtitle": "Valdykite fono peržiūrų generavimą visai sistemai.",
@@ -447,6 +469,7 @@
"action": {
"user_logged_in": "Prisijungė",
"user_logged_out": "Atsijungė",
"user_invited": "Pakvietė naudotoją",
"model_uploaded": "Modelis įkeltas",
"model_imported": "Modelis importuotas",
"import_completed": "Importas baigtas",
+50 -10
View File
@@ -5,21 +5,29 @@ import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Alert from "@mui/material/Alert";
import CircularProgress from "@mui/material/CircularProgress";
import Typography from "@mui/material/Typography";
import { authApi, type AuthUser } from "../../api/auth";
import CheckEmailPanel from "./CheckEmailPanel";
const MIN_PASSWORD_LENGTH = 8;
/** From an invitation link's query string (see App.tsx). */
export type Invite = { token: string; email: string };
type Props = {
onSuccess: (token: string, expires_in: number, user: AuthUser) => void;
allowRegistrations: boolean;
invite?: Invite | null;
};
export default function RegisterPanel({ onSuccess, allowRegistrations }: Props) {
export default function RegisterPanel({ onSuccess, invite = null }: Props) {
const { t } = useTranslation("app");
const [displayName, setDisplayName] = React.useState("");
const [email, setEmail] = React.useState("");
const [email, setEmail] = React.useState(invite?.email ?? "");
// An invitation is checked with the backend before the form opens: "checking" until then,
// "valid" (email locked to the invited address) or "invalid" (form stays closed).
const [inviteState, setInviteState] = React.useState<"checking" | "valid" | "invalid">(invite ? "checking" : "valid");
const [inviteError, setInviteError] = React.useState<string | null>(null);
const [password, setPassword] = React.useState("");
const [confirmPassword, setConfirmPassword] = React.useState("");
const [loading, setLoading] = React.useState(false);
@@ -28,6 +36,25 @@ export default function RegisterPanel({ onSuccess, allowRegistrations }: Props)
// form with CheckEmailPanel instead of ever calling onSuccess.
const [pendingEmail, setPendingEmail] = React.useState<string | null>(null);
// Keyed on the token string, not the invite object -- App.tsx builds that afresh every render.
const inviteToken = invite?.token ?? null;
React.useEffect(() => {
if (!inviteToken) return;
let active = true;
authApi.getInvitation(inviteToken)
.then(res => {
if (!active) return;
setEmail(res.email);
setInviteState("valid");
})
.catch(err => {
if (!active) return;
setInviteError(err instanceof Error ? err.message : t("auth.register.inviteInvalid"));
setInviteState("invalid");
});
return () => { active = false; };
}, [inviteToken, t]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
@@ -41,10 +68,13 @@ export default function RegisterPanel({ onSuccess, allowRegistrations }: Props)
}
setLoading(true);
try {
const res = await authApi.register({ displayName, email, password });
const res = await authApi.register({ displayName, email, password, inviteToken: invite?.token });
if ("email_verification_required" in res) {
setPendingEmail(res.email);
} else {
// Leave /register?invite=... behind: the signed-in app doesn't have that route, and the
// one-time link shouldn't linger in the address bar or history.
if (invite) window.history.replaceState(null, "", "/");
onSuccess(res.token, res.expires_in, res.user);
}
} catch (err) {
@@ -59,10 +89,22 @@ export default function RegisterPanel({ onSuccess, allowRegistrations }: Props)
return <CheckEmailPanel email={pendingEmail} />;
}
if (inviteState === "checking") {
return (
<Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
<CircularProgress size={28} />
</Box>
);
}
if (inviteState === "invalid") {
return <Alert severity="error">{inviteError ?? t("auth.register.inviteInvalid")}</Alert>;
}
return (
<Box component="form" onSubmit={handleSubmit}>
<Stack spacing={2}>
{!allowRegistrations && <Alert severity="info">{t("auth.register.disabled")}</Alert>}
{invite && <Alert severity="info">{t("auth.register.invited")}</Alert>}
{error && <Alert severity="error">{error}</Alert>}
<TextField
label={t("auth.register.displayNameLabel")}
@@ -72,7 +114,6 @@ export default function RegisterPanel({ onSuccess, allowRegistrations }: Props)
required
fullWidth
size="small"
disabled={!allowRegistrations}
/>
<TextField
type="email"
@@ -83,7 +124,8 @@ export default function RegisterPanel({ onSuccess, allowRegistrations }: Props)
required
fullWidth
size="small"
disabled={!allowRegistrations}
disabled={invite !== null}
helperText={invite ? t("auth.register.inviteEmailLocked") : undefined}
/>
<Stack spacing={0.5}>
<TextField
@@ -95,7 +137,6 @@ export default function RegisterPanel({ onSuccess, allowRegistrations }: Props)
required
fullWidth
size="small"
disabled={!allowRegistrations}
/>
<Typography variant="caption" color="text.secondary">
{t("auth.register.passwordHelp")}
@@ -110,9 +151,8 @@ export default function RegisterPanel({ onSuccess, allowRegistrations }: Props)
required
fullWidth
size="small"
disabled={!allowRegistrations}
/>
<Button type="submit" variant="contained" disabled={loading || !allowRegistrations} fullWidth size="large">
<Button type="submit" variant="contained" disabled={loading} fullWidth size="large">
{loading ? t("auth.register.submitting") : t("auth.register.submit")}
</Button>
</Stack>
+23 -15
View File
@@ -10,17 +10,23 @@ import Alert from "@mui/material/Alert";
import type { AuthUser } from "../../api/auth";
import Wordmark from "../../components/Wordmark";
import SignInPanel from "./SignInPanel";
import RegisterPanel from "./RegisterPanel";
import RegisterPanel, { type Invite } from "./RegisterPanel";
type Props = {
onSuccess: (token: string, expires_in: number, user: AuthUser) => void;
apiUp: boolean | null;
allowRegistrations: boolean;
/** From an invitation link -- opens on the register form for that address, even while
* registrations are closed. */
invite?: Invite | null;
};
export default function AuthPage({ onSuccess, apiUp, allowRegistrations }: Props) {
export default function AuthPage({ onSuccess, apiUp, allowRegistrations, invite = null }: Props) {
const { t } = useTranslation("app");
const [tab, setTab] = React.useState<"signIn" | "register">("signIn");
const [tab, setTab] = React.useState<"signIn" | "register">(invite ? "register" : "signIn");
// Closed registrations drop the Register tab altogether -- there's nothing to fill in -- unless
// this visit came from an invitation.
const canRegister = allowRegistrations || invite !== null;
return (
<Paper
@@ -49,20 +55,22 @@ export default function AuthPage({ onSuccess, apiUp, allowRegistrations }: Props
</Alert>
)}
<Tabs
value={tab}
onChange={(_e, value) => setTab(value)}
variant="fullWidth"
sx={{ mb: 3, borderBottom: "1px solid", borderColor: "divider" }}
>
<Tab value="signIn" label={t("auth.signInTab")} />
<Tab value="register" label={t("auth.registerTab")} />
</Tabs>
{canRegister && (
<Tabs
value={tab}
onChange={(_e, value) => setTab(value)}
variant="fullWidth"
sx={{ mb: 3, borderBottom: "1px solid", borderColor: "divider" }}
>
<Tab value="signIn" label={t("auth.signInTab")} />
<Tab value="register" label={t("auth.registerTab")} />
</Tabs>
)}
{tab === "signIn" ? (
<SignInPanel onSuccess={onSuccess} />
{tab === "register" && canRegister ? (
<RegisterPanel onSuccess={onSuccess} invite={invite} />
) : (
<RegisterPanel onSuccess={onSuccess} allowRegistrations={allowRegistrations} />
<SignInPanel onSuccess={onSuccess} />
)}
</Paper>
);
+3
View File
@@ -30,6 +30,7 @@ type ActionColor = "success" | "error" | "info" | "warning" | "default";
const ACTION_COLORS: Record<LogAction, ActionColor> = {
user_logged_in: "success",
user_logged_out: "default",
user_invited: "info",
model_uploaded: "info",
model_imported: "info",
import_completed: "info",
@@ -102,6 +103,8 @@ export default function LogsPage({ onUnauthorized }: Props) {
case "collection_item_added":
case "collection_item_removed":
return typeof d.name === "string" ? d.name : "";
case "user_invited":
return typeof d.email === "string" ? d.email : "";
case "model_edited":
return typeof d.field === "string" ? t("adminSettings.logs.editedField", { field: d.field }) : "";
case "import_completed": {
@@ -0,0 +1,82 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import { adminApi } from "../../api/admin";
type Props = {
open: boolean;
onClose: () => void;
};
/** Emails an invitation to register while registrations are closed (see RegistrationsPanel).
* Stays open after a send, with the field cleared, so several people can be invited in a row. */
export default function InviteUsersDialog({ open, onClose }: Props) {
const { t } = useTranslation("app");
const [email, setEmail] = React.useState("");
const [sending, setSending] = React.useState(false);
const [sent, setSent] = React.useState<{ email: string; days: number } | null>(null);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
if (!open) return;
setEmail("");
setSent(null);
setError(null);
}, [open]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim() || sending) return;
setSending(true);
setError(null);
setSent(null);
try {
const res = await adminApi.inviteUser(email.trim());
setSent({ email: res.email, days: res.expires_in_days });
setEmail("");
} catch (err) {
setError(err instanceof Error ? err.message : t("adminSettings.registrations.invite.failed"));
} finally {
setSending(false);
}
};
return (
<Dialog open={open} onClose={sending ? undefined : onClose} fullWidth maxWidth="xs">
<form onSubmit={handleSubmit}>
<DialogTitle>{t("adminSettings.registrations.invite.title")}</DialogTitle>
<DialogContent>
<Stack spacing={2}>
<DialogContentText>{t("adminSettings.registrations.invite.description")}</DialogContentText>
{sent && <Alert severity="success">{t("adminSettings.registrations.invite.sent", { email: sent.email, count: sent.days })}</Alert>}
{error && <Alert severity="error">{error}</Alert>}
<TextField
type="email"
label={t("adminSettings.registrations.invite.emailLabel")}
value={email}
onChange={e => setEmail(e.target.value)}
required
fullWidth
size="small"
disabled={sending}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={sending}>{t("adminSettings.registrations.invite.close")}</Button>
<Button type="submit" variant="contained" disabled={sending || !email.trim()}>
{sending ? t("adminSettings.registrations.invite.sending") : t("adminSettings.registrations.invite.send")}
</Button>
</DialogActions>
</form>
</Dialog>
);
}
@@ -0,0 +1,113 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import FormControlLabel from "@mui/material/FormControlLabel";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Switch from "@mui/material/Switch";
import Typography from "@mui/material/Typography";
import PersonAddIcon from "@mui/icons-material/PersonAddAlt1";
import { UnauthorizedError } from "../../api/client";
import { settingsApi } from "../../api/settings";
import InviteUsersDialog from "./InviteUsersDialog";
type Props = {
onUnauthorized?: () => void;
};
/** Instance-wide: whether anyone may create an account, shown above the Users table. Turned off,
* the sign-in page drops its Register tab and the backend refuses registrations -- except the very
* first account, and people invited from here. Inviting needs SMTP (Connections > SMTP), since
* the link travels by email, so the button only appears once registrations are closed and SMTP is
* set up. */
export default function RegistrationsPanel({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
const [allow, setAllow] = React.useState(true);
const [smtpConfigured, setSmtpConfigured] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [inviteOpen, setInviteOpen] = React.useState(false);
React.useEffect(() => {
let active = true;
void (async () => {
try {
const [registrations, smtp] = await Promise.all([settingsApi.getRegistrations(), settingsApi.getSmtp()]);
if (!active) return;
setAllow(registrations.allow_registrations);
setSmtpConfigured(smtp.configured);
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
} finally {
if (active) setLoading(false);
}
})();
return () => { active = false; };
}, [onUnauthorized]);
const handleChange = async (next: boolean) => {
const previous = allow;
setAllow(next);
setSaving(true);
setError(null);
try {
const res = await settingsApi.updateRegistrations(next);
setAllow(res.allow_registrations);
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
setAllow(previous);
setError(err instanceof Error ? err.message : t("adminSettings.registrations.failed"));
} finally {
setSaving(false);
}
};
const canInvite = !allow && smtpConfigured;
return (
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={1.5}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ sm: "center" }} justifyContent="space-between">
<Stack spacing={0.5}>
<Stack direction="row" spacing={1.5} alignItems="center">
<FormControlLabel
control={
<Switch
checked={allow}
disabled={loading || saving}
onChange={e => void handleChange(e.target.checked)}
/>
}
label={t("adminSettings.registrations.allowLabel")}
/>
{saving && <CircularProgress size={16} />}
</Stack>
<Typography variant="body2" color="text.secondary">
{allow ? t("adminSettings.registrations.openHelp") : t("adminSettings.registrations.closedHelp")}
</Typography>
</Stack>
{canInvite && (
<Stack spacing={0.5} alignItems={{ xs: "flex-start", sm: "flex-end" }} sx={{ flexShrink: 0 }}>
<Button variant="contained" startIcon={<PersonAddIcon />} onClick={() => setInviteOpen(true)}>
{t("adminSettings.registrations.inviteButton")}
</Button>
<Typography variant="caption" color="text.secondary">
{t("adminSettings.registrations.inviteHelp")}
</Typography>
</Stack>
)}
</Stack>
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
</Stack>
<InviteUsersDialog open={inviteOpen} onClose={() => setInviteOpen(false)} />
</Paper>
);
}
+5 -1
View File
@@ -13,12 +13,14 @@ import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import { UnauthorizedError } from "../../api/client";
import { adminApi, type AdminUser } from "../../api/admin";
import RegistrationsPanel from "./RegistrationsPanel";
type Props = {
onUnauthorized?: () => void;
};
/** Admin-only, read-only roster of every account on the instance. Models/Collections are
/** Admin-only roster of every account on the instance, under the instance-wide registration
* switch and invitations (RegistrationsPanel). The roster itself is read-only. Models/Collections are
* per-user counts; MakerWorld is a connected/not-connected flag (each user's own MakerWorld
* session cookie, saved server-side from Settings -> Imports -- see
* services/makerworldCookieService.ts). The "delete all models for a user" action lives on the
@@ -53,6 +55,8 @@ export default function UsersPage({ onUnauthorized }: Props) {
return (
<Stack spacing={3}>
<RegistrationsPanel onUnauthorized={onUnauthorized} />
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
{loading ? (