diff --git a/backend/prisma/migrations/20260927150000_add_invitations/migration.sql b/backend/prisma/migrations/20260927150000_add_invitations/migration.sql new file mode 100644 index 0000000..47578b8 --- /dev/null +++ b/backend/prisma/migrations/20260927150000_add_invitations/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index eac0245..b64bcb9 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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]) } diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 027d1d6..acc8bbb 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -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. diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 83da311..d6d6ec1 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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( diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts index 64ba433..5438958 100644 --- a/backend/src/routes/health.ts +++ b/backend/src/routes/health.ts @@ -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)) }); }), ); diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 521e7f2..af1c462 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -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", diff --git a/backend/src/services/auditLog.ts b/backend/src/services/auditLog.ts index ce65744..afb3586 100644 --- a/backend/src/services/auditLog.ts +++ b/backend/src/services/auditLog.ts @@ -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" diff --git a/backend/src/services/invitationService.ts b/backend/src/services/invitationService.ts new file mode 100644 index 0000000..1b0d09b --- /dev/null +++ b/backend/src/services/invitationService.ts @@ -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 { + 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 { + const invitation = await prisma.invitation.findUnique({ where: { token } }); + if (!invitation || invitation.expiresAt < new Date()) return null; + return invitation; +} diff --git a/backend/src/services/mailer.ts b/backend/src/services/mailer.ts index fa63aea..4a62a55 100644 --- a/backend/src/services/mailer.ts +++ b/backend/src/services/mailer.ts @@ -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 { +/** 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 { 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 { + 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: `

Hi ${displayName},

Confirm your email address to finish creating your Thingport account:

${link}

This link expires in 24 hours.

`, + html: `

Hi ${escapeHtml(displayName)},

Confirm your email address to finish creating your Thingport account:

${link}

This link expires in 24 hours.

`, + }); +} + +/** 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 { + 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: `

${escapeHtml(inviterName)} invited you to create an account on their Thingport, a library for 3D-printing models.

Create your account

Or open this link: ${link}

The link works for ${expiresInDays} days, for this email address only.

`, }); } diff --git a/backend/tests/invitations.test.ts b/backend/tests/invitations.test.ts new file mode 100644 index 0000000..7c8b193 --- /dev/null +++ b/backend/tests/invitations.test.ts @@ -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>(async () => undefined), + sendInvitationEmail: vi.fn<(to: string, inviterName: string, link: string, expiresInDays: number) => Promise>( + 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 { + 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); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0827570..cf8df50 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( @@ -393,6 +402,7 @@ export default function App() { onSuccess={handleLogin} apiUp={apiUp} allowRegistrations={health?.allow_registrations ?? true} + invite={invite} /> )} diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 70f92bb..af2a107 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -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 => { const res = await fetch(`${apiBase()}/admin/users`, { headers: authHeaders() }); assertOk(res, "Failed to load users"); diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index ba69d95..0d03c08 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -51,8 +51,21 @@ async function postAuth(path: string, body: unknown): Promise { export const authApi = { login: (email: string, password: string): Promise => postAuth("/login", { email, password }), - register: (payload: { displayName: string; email: string; password: string }): Promise => - 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 => + 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 => postAuth("/verify-email", { token }), diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 608eede..20e9f39 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -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. diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 19dc7dc..6bbb551 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index b042ab5..a5ab985 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -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", diff --git a/frontend/src/pages/AuthPage/RegisterPanel.tsx b/frontend/src/pages/AuthPage/RegisterPanel.tsx index a2b0957..13ce6ab 100644 --- a/frontend/src/pages/AuthPage/RegisterPanel.tsx +++ b/frontend/src/pages/AuthPage/RegisterPanel.tsx @@ -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(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(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 ; } + if (inviteState === "checking") { + return ( + + + + ); + } + + if (inviteState === "invalid") { + return {inviteError ?? t("auth.register.inviteInvalid")}; + } + return ( - {!allowRegistrations && {t("auth.register.disabled")}} + {invite && {t("auth.register.invited")}} {error && {error}} {t("auth.register.passwordHelp")} @@ -110,9 +151,8 @@ export default function RegisterPanel({ onSuccess, allowRegistrations }: Props) required fullWidth size="small" - disabled={!allowRegistrations} /> - diff --git a/frontend/src/pages/AuthPage/index.tsx b/frontend/src/pages/AuthPage/index.tsx index e15699d..77c54ec 100644 --- a/frontend/src/pages/AuthPage/index.tsx +++ b/frontend/src/pages/AuthPage/index.tsx @@ -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 ( )} - setTab(value)} - variant="fullWidth" - sx={{ mb: 3, borderBottom: "1px solid", borderColor: "divider" }} - > - - - + {canRegister && ( + setTab(value)} + variant="fullWidth" + sx={{ mb: 3, borderBottom: "1px solid", borderColor: "divider" }} + > + + + + )} - {tab === "signIn" ? ( - + {tab === "register" && canRegister ? ( + ) : ( - + )} ); diff --git a/frontend/src/pages/LogsPage/index.tsx b/frontend/src/pages/LogsPage/index.tsx index 56f96f2..ec57711 100644 --- a/frontend/src/pages/LogsPage/index.tsx +++ b/frontend/src/pages/LogsPage/index.tsx @@ -30,6 +30,7 @@ type ActionColor = "success" | "error" | "info" | "warning" | "default"; const ACTION_COLORS: Record = { 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": { diff --git a/frontend/src/pages/UsersPage/InviteUsersDialog.tsx b/frontend/src/pages/UsersPage/InviteUsersDialog.tsx new file mode 100644 index 0000000..358d35e --- /dev/null +++ b/frontend/src/pages/UsersPage/InviteUsersDialog.tsx @@ -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(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 ( + +
+ {t("adminSettings.registrations.invite.title")} + + + {t("adminSettings.registrations.invite.description")} + {sent && {t("adminSettings.registrations.invite.sent", { email: sent.email, count: sent.days })}} + {error && {error}} + setEmail(e.target.value)} + required + fullWidth + size="small" + disabled={sending} + /> + + + + + + +
+
+ ); +} diff --git a/frontend/src/pages/UsersPage/RegistrationsPanel.tsx b/frontend/src/pages/UsersPage/RegistrationsPanel.tsx new file mode 100644 index 0000000..65093f6 --- /dev/null +++ b/frontend/src/pages/UsersPage/RegistrationsPanel.tsx @@ -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(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 ( + + + + + + void handleChange(e.target.checked)} + /> + } + label={t("adminSettings.registrations.allowLabel")} + /> + {saving && } + + + {allow ? t("adminSettings.registrations.openHelp") : t("adminSettings.registrations.closedHelp")} + + + + {canInvite && ( + + + + {t("adminSettings.registrations.inviteHelp")} + + + )} + + + {error && setError(null)}>{error}} + + setInviteOpen(false)} /> + + ); +} diff --git a/frontend/src/pages/UsersPage/index.tsx b/frontend/src/pages/UsersPage/index.tsx index 79d89c6..dc04f59 100644 --- a/frontend/src/pages/UsersPage/index.tsx +++ b/frontend/src/pages/UsersPage/index.tsx @@ -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 ( + + {error && setError(null)}>{error}} {loading ? (