feat: admin panel remake

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-09 10:22:34 +02:00
1 parent fa8ba2f627
commit 2bce5a2049
48 files changed
+1905 -191

No files matched your search

+9
View File
@@ -10,3 +10,12 @@ INITIAL_ADMIN_EMAIL=you@example.com
AUTH_SECRET=replace-with-random-secret
AUTH_TOKEN_TTL=43200
FLARESOLVERR_URL=
# Optional: leave SMTP_HOST blank to skip email verification on registration entirely (new
# accounts are verified and signed in immediately). Once set, these only seed the admin-editable
# Connections > SMTP settings on first boot -- later changes are saved via that page, not here.
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=PrintStash <no-reply@localhost>
+21
View File
@@ -16,6 +16,7 @@
"jsonwebtoken": "^9.0.2",
"mime-types": "^2.1.35",
"multer": "^2.0.1",
"nodemailer": "^10.0.1",
"prisma": "^5.20.0",
"sharp": "^0.35.4",
"three": "^0.160.1",
@@ -29,6 +30,7 @@
"@types/mime-types": "^2.1.4",
"@types/multer": "^2.2.0",
"@types/node": "^20.14.0",
"@types/nodemailer": "^8.0.1",
"@types/supertest": "^7.2.1",
"@types/three": "^0.160.0",
"@types/yauzl": "^2.10.3",
@@ -1844,6 +1846,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
@@ -3547,6 +3559,15 @@
"node": ">= 0.6"
}
},
"node_modules/nodemailer": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.1.tgz",
"integrity": "sha512-c+gU9cL9HLDax3vjxL88kW+6NOgdtEUWaZ+AUtxdJR6LLhf0kGdCLExof7yiKW7zdO9EfXCSIgmhGyFmUM0mYQ==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+2
View File
@@ -25,6 +25,7 @@
"jsonwebtoken": "^9.0.2",
"mime-types": "^2.1.35",
"multer": "^2.0.1",
"nodemailer": "^10.0.1",
"prisma": "^5.20.0",
"sharp": "^0.35.4",
"three": "^0.160.1",
@@ -38,6 +39,7 @@
"@types/mime-types": "^2.1.4",
"@types/multer": "^2.2.0",
"@types/node": "^20.14.0",
"@types/nodemailer": "^8.0.1",
"@types/supertest": "^7.2.1",
"@types/three": "^0.160.0",
"@types/yauzl": "^2.10.3",
@@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "Log" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"targetId" TEXT,
"details" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Log_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Log_userId_createdAt_idx" ON "Log"("userId", "createdAt");
-- CreateIndex
CREATE INDEX "Log_createdAt_idx" ON "Log"("createdAt");
-- AddForeignKey
ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "emailVerified" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "emailVerificationToken" TEXT,
ADD COLUMN "emailVerificationExpires" TIMESTAMP(3);
-- CreateIndex
CREATE UNIQUE INDEX "User_emailVerificationToken_key" ON "User"("emailVerificationToken");
+24
View File
@@ -18,11 +18,18 @@ model User {
passwordHash String
displayName String
role Role @default(MEMBER)
// Defaults to true so the migration doesn't retroactively lock out every existing account --
// new registrations explicitly set this false when SMTP is configured (see routes/auth.ts's
// isSmtpConfigured branch), true (no verification needed) otherwise.
emailVerified Boolean @default(true)
emailVerificationToken String? @unique
emailVerificationExpires DateTime?
folders Folder[]
prints Print[]
collections Collection[]
importJobs ImportJob[]
notifications Notification[]
logs Log[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -288,3 +295,20 @@ model Setting {
key String @id
value Json
}
/// An admin-facing audit trail entry -- logins/logouts and every mutation an admin should be
/// able to trace back to a user (uploads, imports, model edits/deletes, collection
/// creates/edits/deletes). Written fire-and-forget via services/auditLog.ts's createLog() so a
/// logging failure never breaks the user-facing action it's recording. Read by GET /admin/logs.
model Log {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
action String
targetId String?
details Json @default("{}")
createdAt DateTime @default(now())
@@index([userId, createdAt])
@@index([createdAt])
}
+1 -1
View File
@@ -52,7 +52,7 @@ export function createApp(): Express {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
if (err instanceof HttpError) {
res.status(err.status).json({ detail: err.message });
res.status(err.status).json({ detail: err.message, ...(err.code ? { code: err.code } : {}) });
return;
}
console.error(err);
+5
View File
@@ -15,6 +15,11 @@ export const PREVIEWS = path.join(STORAGE, "previews");
// generated once per .3mf Plate so the viewer never has to re-parse a huge raw 3MF on every open.
export const MODEL_PREVIEWS = path.join(STORAGE, "model-previews");
// Base URL this instance is publicly reachable at -- needed to build absolute links in outgoing
// emails (e.g. the email-verification link), which unlike API responses can't rely on the
// request's own Origin. Left blank in single-machine/local setups where no email is ever sent.
export const PUBLIC_URL = (process.env.PUBLIC_URL || "").trim().replace(/\/+$/, "");
export const AUTH_SECRET = process.env.AUTH_SECRET || "changeme-secret";
export const AUTH_TOKEN_TTL = envInt("AUTH_TOKEN_TTL", 43200);
export const AUTH_ALGO = "HS256" as const;
+33 -1
View File
@@ -3,7 +3,7 @@ import { prisma } from "../db";
import { requireAdmin, requireAuth } from "../auth";
import { HttpError } from "../utils/fileUtils";
import { asyncHandler } from "../utils/asyncHandler";
import { deleteAllPrintsForUser, listUsersWithPrintCounts } from "../services/adminService";
import { deleteAllPrintsForUser, listLogs, listUsersWithPrintCounts } from "../services/adminService";
const router = Router();
router.use(requireAuth);
@@ -20,6 +20,38 @@ router.get(
display_name: u.displayName,
role: u.role,
print_count: u.printCount,
collection_count: u.collectionCount,
thingiverse_count: u.thingiverseCount,
created_at: u.createdAt,
})),
);
}),
);
function parseDateParam(raw: unknown): Date | undefined {
if (typeof raw !== "string" || !raw) return undefined;
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) throw new HttpError(400, "Invalid date");
return parsed;
}
router.get(
"/admin/logs",
asyncHandler(async (req, res) => {
const userId = typeof req.query.user_id === "string" && req.query.user_id ? req.query.user_id : undefined;
const from = parseDateParam(req.query.from);
const to = parseDateParam(req.query.to);
const logs = await listLogs({ userId, from, to });
res.json(
logs.map((l) => ({
id: l.id,
user_id: l.userId,
user_display_name: l.userDisplayName,
user_email: l.userEmail,
action: l.action,
target_id: l.targetId,
details: l.details,
created_at: l.createdAt,
})),
);
}),
+103 -2
View File
@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import { Router } from "express";
import bcrypt from "bcryptjs";
import { z } from "zod";
@@ -7,13 +8,20 @@ import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import { getAllowRegistrations } from "../services/settingsService";
import { getAllowRegistrations, isSmtpConfigured } from "../services/settingsService";
import { sendVerificationEmail } from "../services/mailer";
import { createLog } from "../services/auditLog";
import { toUserOut } from "../dto";
import type { Role } from "@prisma/client";
const router = Router();
const PASSWORD_HASH_COST = 12;
const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000;
function newVerificationToken(): { token: string; expires: Date } {
return { token: crypto.randomBytes(32).toString("hex"), expires: new Date(Date.now() + EMAIL_VERIFICATION_TTL_MS) };
}
const registerSchema = z.object({
displayName: z.string().trim().min(1, "Display name is required"),
@@ -48,10 +56,38 @@ router.post(
const passwordHash = await bcrypt.hash(body.password, PASSWORD_HASH_COST);
const role: Role = isInitialAdminEmail ? "ADMIN" : "MEMBER";
// Bootstrapping the very first (admin) account always skips verification -- SMTP can't have
// been configured yet by an operator who isn't able to sign in until this account exists.
// 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());
const verification = smtpConfigured ? newVerificationToken() : null;
const user = await prisma.user.create({
data: { email, passwordHash, displayName: body.displayName, role },
data: {
email,
passwordHash,
displayName: body.displayName,
role,
emailVerified: !smtpConfigured,
emailVerificationToken: verification?.token ?? null,
emailVerificationExpires: verification?.expires ?? null,
},
});
if (smtpConfigured && verification) {
try {
await sendVerificationEmail(user.email, user.displayName, verification.token);
} catch {
// Don't leave an unverifiable account behind if the email never went out.
await prisma.user.delete({ where: { id: user.id } });
throw new HttpError(500, "Failed to send verification email. Please try again.");
}
res.json({ email_verification_required: true, email: user.email });
return;
}
const token = createToken(user.id, user.role);
res.json({ token, expires_in: AUTH_TOKEN_TTL, user: toUserOut(user) });
}),
@@ -81,8 +117,73 @@ router.post(
}
}
if (!user.emailVerified) {
throw new HttpError(403, "Please verify your email before signing in.", "EMAIL_NOT_VERIFIED");
}
const token = createToken(user.id, user.role);
res.json({ token, expires_in: AUTH_TOKEN_TTL, user: toUserOut(user) });
void createLog({ userId: user.id, action: "user_logged_in", details: { email: user.email } });
}),
);
const verifyEmailSchema = z.object({ token: z.string().min(1) });
router.post(
"/verify-email",
asyncHandler(async (req, res) => {
const body = parseBody(verifyEmailSchema, req.body);
const user = await prisma.user.findUnique({ where: { emailVerificationToken: body.token } });
if (!user || !user.emailVerificationExpires || user.emailVerificationExpires < new Date()) {
throw new HttpError(400, "This verification link is invalid or has expired.");
}
const verified = await prisma.user.update({
where: { id: user.id },
data: { emailVerified: true, emailVerificationToken: null, emailVerificationExpires: null },
});
// Verifying doubles as signing in -- the user just proved control of the mailbox, and
// making them turn around and log in again with a password they only just typed is friction
// with no security benefit.
const token = createToken(verified.id, verified.role);
res.json({ token, expires_in: AUTH_TOKEN_TTL, user: toUserOut(verified) });
void createLog({ userId: verified.id, action: "user_logged_in", details: { email: verified.email } });
}),
);
const resendVerificationSchema = z.object({ email: z.string().trim().email() });
router.post(
"/resend-verification",
asyncHandler(async (req, res) => {
const body = parseBody(resendVerificationSchema, req.body);
const email = body.email.toLowerCase();
// Same response whether the account doesn't exist, is already verified, or SMTP isn't
// configured -- don't let this endpoint be used to enumerate registered addresses either.
const user = await prisma.user.findUnique({ where: { email } });
if (user && !user.emailVerified && (await isSmtpConfigured())) {
const verification = newVerificationToken();
await prisma.user.update({
where: { id: user.id },
data: { emailVerificationToken: verification.token, emailVerificationExpires: verification.expires },
});
try {
await sendVerificationEmail(user.email, user.displayName, verification.token);
} catch (err) {
console.error("[auth] Failed to resend verification email:", err);
}
}
res.json({ message: "If that account needs verification, we've sent a new email." });
}),
);
// Logins are stateless JWTs, so there's nothing server-side to invalidate here -- this endpoint
// exists purely to record the audit-log entry before the frontend clears its local token.
router.post(
"/logout",
requireAuth,
asyncHandler(async (req, res) => {
res.json({ ok: true });
void createLog({ userId: req.userId!, action: "user_logged_out" });
}),
);
+19
View File
@@ -14,6 +14,7 @@ import {
systemCollectionKeyForId,
} from "../services/collectionService";
import { printOutsByIds } from "../services/printLoader";
import { createLog } from "../services/auditLog";
import { toCollectionOut, type PrintOut } from "../dto";
const router = Router();
@@ -71,6 +72,12 @@ router.post(
},
});
res.json(toCollectionOut(collection, 0, []));
void createLog({
userId: req.userId!,
action: "collection_created",
targetId: collection.id,
details: { name: collection.name },
});
}),
);
@@ -112,6 +119,12 @@ router.patch(
});
const itemCount = await prisma.collectionItem.count({ where: { collectionId: updated.id } });
res.json(toCollectionOut(updated, itemCount, []));
void createLog({
userId: req.userId!,
action: "collection_edited",
targetId: updated.id,
details: { name: updated.name },
});
}),
);
@@ -125,6 +138,12 @@ router.delete(
if (!collection) throw new HttpError(404, "Collection not found");
await prisma.collection.delete({ where: { id: collection.id } });
res.json({ ok: true });
void createLog({
userId: req.userId!,
action: "collection_deleted",
targetId: collection.id,
details: { name: collection.name },
});
}),
);
+2
View File
@@ -28,6 +28,7 @@ import {
runThingiverseLikesImportJob,
runZipImportJob,
} from "../services/importJobRunner";
import { createLog } from "../services/auditLog";
import { toImportJobOut, toPrintOut } from "../dto";
const router = Router();
@@ -50,6 +51,7 @@ router.post(
const url = await normalizeImportUrl(body.url);
const { print, plates, author, previewImages } = await importPrintFromUrl(req.userId!, url, body);
res.json(toPrintOut(print, plates, [], null, author, previewImages));
void createLog({ userId: req.userId!, action: "model_imported", targetId: print.id, details: { name: print.name, url } });
}),
);
+7
View File
@@ -16,6 +16,7 @@ import { loadFullPrint, printOutById } from "../services/printLoader";
import { deleteAllPrintFiles } from "../services/printFileService";
import { sendPrintsZip } from "../services/downloadZip";
import { systemCollectionKeyForId } from "../services/collectionService";
import { createLog } from "../services/auditLog";
import type { Prisma } from "@prisma/client";
const router = Router();
@@ -91,6 +92,7 @@ router.post(
{ filename: safeName, mime, tempFilePath: file.path },
]);
printsOut.push(toPrintOut(print, plates, [], null));
void createLog({ userId: req.userId!, action: "model_uploaded", targetId: print.id, details: { name: print.name } });
}
} else {
const plateInputs: NewPlateInput[] = files.map((f) => {
@@ -100,6 +102,7 @@ router.post(
const nameHint = path.parse(plateInputs[0].filename).name;
const { print, plates } = await createPrint(req.userId!, meta, nameHint, plateInputs);
printsOut.push(toPrintOut(print, plates, [], null));
void createLog({ userId: req.userId!, action: "model_uploaded", targetId: print.id, details: { name: print.name } });
}
res.json({ prints: printsOut });
} finally {
@@ -377,6 +380,7 @@ router.post(
const plates = await prisma.plate.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } });
await relocatePrint(updated, plates);
res.json({ print: await printOutById(req.userId!, print.id) });
void createLog({ userId: req.userId!, action: "model_edited", targetId: print.id, details: { field: "tags", name: updated.name } });
}),
);
@@ -412,6 +416,7 @@ router.post(
const plates = await prisma.plate.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } });
await relocatePrint(updated, plates);
res.json({ print: await printOutById(req.userId!, print.id) });
void createLog({ userId: req.userId!, action: "model_edited", targetId: print.id, details: { field: "meta", name: updated.name } });
}),
);
@@ -437,6 +442,7 @@ router.post(
const plates = await prisma.plate.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } });
await relocatePrint(updated, plates);
res.json({ print: await printOutById(req.userId!, print.id) });
void createLog({ userId: req.userId!, action: "model_edited", targetId: print.id, details: { field: "folder", name: updated.name } });
}),
);
@@ -452,6 +458,7 @@ router.delete(
await fs.promises.rm(plateThumbPath(plate.id), { force: true }).catch(() => undefined);
}
res.json({ ok: true });
void createLog({ userId: req.userId!, action: "model_deleted", targetId: req.params.id, details: { name: full.print.name } });
}),
);
+55
View File
@@ -5,11 +5,15 @@ import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import {
getAllowRegistrations,
getDatabaseInfo,
getPreviewMode,
getSmtpSettings,
getThingiverseAccessToken,
setAllowRegistrations,
setPreviewMode,
setSmtpSettings,
setThingiverseAccessToken,
type SmtpSettings,
} from "../services/settingsService";
import {
DEFAULT_STORAGE_TEMPLATE,
@@ -124,4 +128,55 @@ router.post(
}),
);
// Admin-only "Connections" page: SMTP (editable, used by registration's email-verification
// flow -- see routes/auth.ts) and Database (read-only, always reflects the live DATABASE_URL).
function smtpSettingsOut(smtp: SmtpSettings) {
// pass is never echoed back, same write-only convention as the Thingiverse token above.
return { host: smtp.host, port: smtp.port, secure: smtp.secure, user: smtp.user, from: smtp.from, configured: Boolean(smtp.host) };
}
router.get(
"/settings/smtp",
requireAdmin,
asyncHandler(async (_req, res) => {
res.json(smtpSettingsOut(await getSmtpSettings()));
}),
);
const smtpSettingsSchema = z.object({
host: z.string().nullable().optional(),
port: z.number().int().min(1).max(65535).optional(),
secure: z.boolean().optional(),
user: z.string().nullable().optional(),
// Omitted entirely (not just empty-string) means "keep the current password" -- the frontend
// only ever sends this key when the admin actually typed a new one.
pass: z.string().nullable().optional(),
from: z.string().optional(),
});
router.patch(
"/settings/smtp",
requireAdmin,
asyncHandler(async (req, res) => {
const body = parseBody(smtpSettingsSchema, req.body);
const patch: Partial<SmtpSettings> = {};
if (body.host !== undefined) patch.host = body.host?.trim() || null;
if (body.port !== undefined) patch.port = body.port;
if (body.secure !== undefined) patch.secure = body.secure;
if (body.user !== undefined) patch.user = body.user?.trim() || null;
if (body.pass !== undefined) patch.pass = body.pass?.trim() || null;
if (body.from?.trim()) patch.from = body.from.trim();
const next = await setSmtpSettings(patch);
res.json(smtpSettingsOut(next));
}),
);
router.get(
"/settings/database",
requireAdmin,
asyncHandler(async (_req, res) => {
res.json(getDatabaseInfo());
}),
);
export default router;
+70 -10
View File
@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import type { Prisma } from "@prisma/client";
import { prisma } from "../db";
import { deleteAllPrintFiles } from "./printFileService";
import { deleteAllPreviewImages } from "./previewImageService";
@@ -12,25 +13,84 @@ export type UserWithPrintCount = {
displayName: string;
role: "ADMIN" | "MEMBER";
printCount: number;
collectionCount: number;
thingiverseCount: number;
createdAt: Date;
};
export async function listUsersWithPrintCounts(): Promise<UserWithPrintCount[]> {
const users = await prisma.user.findMany({
orderBy: { createdAt: "asc" },
select: {
id: true,
email: true,
displayName: true,
role: true,
_count: { select: { prints: true } },
},
});
const [users, thingiverseCounts] = await Promise.all([
prisma.user.findMany({
orderBy: { createdAt: "asc" },
select: {
id: true,
email: true,
displayName: true,
role: true,
createdAt: true,
_count: { select: { prints: true, collections: true } },
},
}),
// A per-user count of prints sourced from Thingiverse -- the closest analog print-stash has
// to youtube-mp3-vault's per-user "Scrobbling" column, since Thingiverse integration here is
// an import provider (Print.sourceProvider) rather than a per-user toggle.
prisma.print.groupBy({
by: ["userId"],
where: { sourceProvider: "thingiverse" },
_count: true,
}),
]);
const thingiverseByUser = new Map(thingiverseCounts.map((t) => [t.userId, t._count]));
return users.map((u) => ({
id: u.id,
email: u.email,
displayName: u.displayName,
role: u.role,
createdAt: u.createdAt,
printCount: u._count.prints,
collectionCount: u._count.collections,
thingiverseCount: thingiverseByUser.get(u.id) ?? 0,
}));
}
export type LogEntry = {
id: string;
userId: string;
userDisplayName: string;
userEmail: string;
action: string;
targetId: string | null;
details: Record<string, unknown>;
createdAt: Date;
};
const LOG_LIST_LIMIT = 500;
/** Backs GET /admin/logs. `from`/`to` are inclusive date bounds; omitting both returns the most
* recent entries up to LOG_LIST_LIMIT. */
export async function listLogs(filter: { userId?: string; from?: Date; to?: Date }): Promise<LogEntry[]> {
const where: Prisma.LogWhereInput = {};
if (filter.userId) where.userId = filter.userId;
if (filter.from || filter.to) {
where.createdAt = {};
if (filter.from) where.createdAt.gte = filter.from;
if (filter.to) where.createdAt.lte = filter.to;
}
const logs = await prisma.log.findMany({
where,
orderBy: { createdAt: "desc" },
take: LOG_LIST_LIMIT,
include: { user: { select: { displayName: true, email: true } } },
});
return logs.map((l) => ({
id: l.id,
userId: l.userId,
userDisplayName: l.user.displayName,
userEmail: l.user.email,
action: l.action,
targetId: l.targetId,
details: l.details as Record<string, unknown>,
createdAt: l.createdAt,
}));
}
+37
View File
@@ -0,0 +1,37 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "../db";
export type LogAction =
| "user_logged_in"
| "user_logged_out"
| "model_uploaded"
| "model_imported"
| "import_completed"
| "model_edited"
| "model_deleted"
| "collection_created"
| "collection_edited"
| "collection_deleted";
/** Writes one admin-audit-trail entry (see the Log model's schema.prisma doc comment). Always
* fire-and-forget (`void createLog(...)`, never awaited by the caller) -- a logging failure must
* never break the user-facing action it's recording. */
export async function createLog(params: {
userId: string;
action: LogAction;
targetId?: string | null;
details?: Record<string, unknown>;
}): Promise<void> {
try {
await prisma.log.create({
data: {
userId: params.userId,
action: params.action,
targetId: params.targetId ?? null,
details: (params.details ?? {}) as Prisma.InputJsonValue,
},
});
} catch (err) {
console.error("[auditLog] Failed to write log entry:", err);
}
}
+18
View File
@@ -13,6 +13,7 @@ import { upsertAuthorFromImport } from "./authorService";
import { extractZipEntriesToPrints } from "./zipService";
import { fetchThingiverseCollectionTitle } from "./thingiverseApi";
import { getThingiverseAccessToken } from "./settingsService";
import { createLog } from "./auditLog";
import { HttpError } from "../utils/fileUtils";
// Deliberately sequential (not a handful in parallel) -- firing several designs' worth of
@@ -130,6 +131,12 @@ export async function runCollectionImportJob(jobId: string, userId: string, body
alreadyInLibrary,
failedCount: failed.length,
});
void createLog({
userId,
action: "import_completed",
targetId: resultCollectionId,
details: { provider: "makerworld", sourceLabel: collectionTitle, imported, alreadyInLibrary, failed: failed.length },
});
const label = collectionTitle ? `"${collectionTitle}"` : "a MakerWorld collection";
const bodyParts: string[] = [];
@@ -235,6 +242,12 @@ async function runThingiverseThingsImportJob(
alreadyInLibrary,
failedCount: failed.length,
});
void createLog({
userId,
action: "import_completed",
targetId: resultCollectionId,
details: { provider: "thingiverse", sourceLabel: collectionTitle, imported, alreadyInLibrary, failed: failed.length },
});
const bodyParts: string[] = [];
if (alreadyInLibrary) bodyParts.push(`${alreadyInLibrary} already in your library`);
@@ -335,6 +348,11 @@ export async function runZipImportJob(jobId: string, userId: string, body: ZipIm
imported: prints.length,
failedCount: failed.length,
});
void createLog({
userId,
action: "import_completed",
details: { provider: "zip", sourceLabel: filename, imported: prints.length, failed: failed.length },
});
const bodyParts: string[] = [];
if (failed.length) bodyParts.push(`${failed.length} failed`);
+29
View File
@@ -0,0 +1,29 @@
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> {
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,
to,
subject: "Confirm your PrintStash account",
text: `Hi ${displayName},\n\nConfirm your email address to finish creating your PrintStash account:\n${link}\n\nThis link expires in 24 hours.`,
html: `<p>Hi ${displayName},</p><p>Confirm your email address to finish creating your PrintStash account:</p><p><a href="${link}">${link}</a></p><p>This link expires in 24 hours.</p>`,
});
}
+83
View File
@@ -1,3 +1,4 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "../db";
// Storage-template settings (get/setStorageTemplate) live in printService.ts — not duplicated here.
@@ -74,3 +75,85 @@ export async function setThingiverseAccessToken(value: string | null): Promise<v
update: { value: trimmed },
});
}
export type SmtpSettings = {
host: string | null;
port: number;
secure: boolean;
user: string | null;
pass: string | null;
from: string;
};
const SMTP_SETTINGS_KEY = "smtp_settings";
const DEFAULT_SMTP_FROM = "PrintStash <no-reply@localhost>";
function envInt(name: string, fallback: number): number {
const parsed = Number.parseInt(process.env[name] || "", 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
// Seeds from SMTP_HOST/SMTP_PORT/SMTP_SECURE/SMTP_USER/SMTP_PASS/SMTP_FROM the first time this is
// read on a fresh instance -- once an admin saves anything via PATCH /settings/smtp, the DB row
// becomes the sole source of truth (see setSmtpSettings) and these env vars are no longer
// consulted, same pattern as the storage template / preview mode / Thingiverse token above.
function smtpSeedFromEnv(): SmtpSettings {
return {
host: (process.env.SMTP_HOST || "").trim() || null,
port: envInt("SMTP_PORT", 587),
secure: process.env.SMTP_SECURE === "true",
user: (process.env.SMTP_USER || "").trim() || null,
pass: (process.env.SMTP_PASS || "").trim() || null,
from: (process.env.SMTP_FROM || "").trim() || DEFAULT_SMTP_FROM,
};
}
export async function getSmtpSettings(): Promise<SmtpSettings> {
const row = await prisma.setting.findUnique({ where: { key: SMTP_SETTINGS_KEY } });
if (row && typeof row.value === "object" && row.value !== null && !Array.isArray(row.value)) {
return { ...smtpSeedFromEnv(), ...(row.value as Partial<SmtpSettings>) };
}
return smtpSeedFromEnv();
}
export async function isSmtpConfigured(): Promise<boolean> {
return Boolean((await getSmtpSettings()).host);
}
export async function setSmtpSettings(patch: Partial<SmtpSettings>): Promise<SmtpSettings> {
const next = { ...(await getSmtpSettings()), ...patch };
await prisma.setting.upsert({
where: { key: SMTP_SETTINGS_KEY },
create: { key: SMTP_SETTINGS_KEY, value: next as unknown as Prisma.InputJsonValue },
update: { value: next as unknown as Prisma.InputJsonValue },
});
return next;
}
export type DatabaseInfo = {
provider: string;
host: string | null;
port: number | null;
database: string | null;
user: string | null;
};
// Read-only: reflects whatever DATABASE_URL the process was actually started with. Unlike the
// settings above, there's no DB-backed override -- switching databases means restarting the
// process against a different env var, not something an admin UI action could safely do (the
// row it would need to read that override from lives in the database being switched away from).
export function getDatabaseInfo(): DatabaseInfo {
const raw = process.env.DATABASE_URL || "";
try {
const url = new URL(raw);
return {
provider: url.protocol.replace(/:$/, "") || "postgresql",
host: url.hostname || null,
port: url.port ? Number(url.port) : null,
database: url.pathname.replace(/^\//, "") || null,
user: url.username ? decodeURIComponent(url.username) : null,
};
} catch {
return { provider: "postgresql", host: null, port: null, database: null, user: null };
}
}
+6 -1
View File
@@ -19,9 +19,14 @@ export function parseContentDisposition(cd: string | null | undefined): string |
export class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
// Machine-readable discriminator for the rare case a frontend needs to branch on *why* a
// request failed rather than just show `message` -- e.g. EMAIL_NOT_VERIFIED, which needs a
// "resend verification email" affordance instead of a plain error toast.
code?: string;
constructor(status: number, message: string, code?: string) {
super(message);
this.status = status;
this.code = code;
}
}
+85
View File
@@ -43,6 +43,22 @@ function auth(token: string) {
return { Authorization: `Bearer ${token}` };
}
// Log writes are fire-and-forget (see services/auditLog.ts) so the HTTP response can return
// before the row lands -- poll briefly instead of asserting immediately after the triggering call.
async function waitForLog(
token: string,
predicate: (log: { action: string; target_id: string | null; details: Record<string, unknown> }) => boolean,
timeoutMs = 2000,
): Promise<{ action: string; target_id: string | null; details: Record<string, unknown> } | undefined> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const res = await request(app).get("/api/admin/logs").set(auth(token));
const found = (res.body as Array<{ action: string; target_id: string | null; details: Record<string, unknown> }>).find(predicate);
if (found || Date.now() > deadline) return found;
await new Promise((r) => setTimeout(r, 50));
}
}
describe("admin user management", () => {
it("rejects a non-admin from listing users", async () => {
const res = await request(app).get("/api/admin/users").set(auth(memberToken));
@@ -69,6 +85,9 @@ describe("admin user management", () => {
const member = res.body.find((u: { id: string }) => u.id === memberUserId);
expect(member).toBeTruthy();
expect(member.print_count).toBe(2);
expect(member.collection_count).toBe(0);
expect(member.thingiverse_count).toBe(0);
expect(typeof member.created_at).toBe("string");
});
it("404s for deleting prints of a nonexistent user", async () => {
@@ -91,3 +110,69 @@ describe("admin user management", () => {
expect(member.print_count).toBe(0);
});
});
describe("admin audit logs", () => {
it("rejects a non-admin from listing logs", async () => {
const res = await request(app).get("/api/admin/logs").set(auth(memberToken));
expect(res.status).toBe(403);
});
it("logs a login", async () => {
const found = await waitForLog(adminToken, (l) => l.action === "user_logged_in" && l.details.email !== undefined);
expect(found).toBeTruthy();
});
it("logs a logout", async () => {
const res = await request(app).post("/api/logout").set(auth(memberToken));
expect(res.status).toBe(200);
const found = await waitForLog(adminToken, (l) => l.action === "user_logged_out");
expect(found).toBeTruthy();
});
it("logs model upload, edit, and delete", async () => {
const uploadRes = await request(app)
.post("/api/upload")
.set(auth(memberToken))
.attach("files", tmpFile("audit-log-test.stl", "solid a endsolid"));
const printId = uploadRes.body.prints[0].id;
const uploaded = await waitForLog(adminToken, (l) => l.action === "model_uploaded" && l.target_id === printId);
expect(uploaded).toBeTruthy();
await request(app).post(`/api/print/${printId}/meta`).set(auth(memberToken)).send({ notes: "updated" });
const edited = await waitForLog(adminToken, (l) => l.action === "model_edited" && l.target_id === printId);
expect(edited).toBeTruthy();
await request(app).delete(`/api/print/${printId}`).set(auth(memberToken));
const deleted = await waitForLog(adminToken, (l) => l.action === "model_deleted" && l.target_id === printId);
expect(deleted).toBeTruthy();
});
it("logs collection create, edit, and delete", async () => {
const createRes = await request(app)
.post("/api/collections")
.set(auth(memberToken))
.send({ name: "Audit Log Test Collection", tags: [] });
const collectionId = createRes.body.id;
const created = await waitForLog(adminToken, (l) => l.action === "collection_created" && l.target_id === collectionId);
expect(created).toBeTruthy();
await request(app)
.patch(`/api/collection/${collectionId}`)
.set(auth(memberToken))
.send({ name: "Audit Log Test Collection Renamed", tags: [] });
const edited = await waitForLog(adminToken, (l) => l.action === "collection_edited" && l.target_id === collectionId);
expect(edited).toBeTruthy();
await request(app).delete(`/api/collection/${collectionId}`).set(auth(memberToken));
const deleted = await waitForLog(adminToken, (l) => l.action === "collection_deleted" && l.target_id === collectionId);
expect(deleted).toBeTruthy();
});
it("filters logs by user", async () => {
const res = await request(app).get(`/api/admin/logs?user_id=${memberUserId}`).set(auth(adminToken));
expect(res.status).toBe(200);
expect((res.body as Array<{ user_id: string }>).every((l) => l.user_id === memberUserId)).toBe(true);
});
});
+7
View File
@@ -36,6 +36,13 @@ services:
- INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-}
- AUTH_SECRET=${AUTH_SECRET:-changeme-secret}
- AUTH_TOKEN_TTL=${AUTH_TOKEN_TTL:-43200}
- PUBLIC_URL=${PUBLIC_URL:-}
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_SECURE=${SMTP_SECURE:-false}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASS=${SMTP_PASS:-}
- SMTP_FROM=${SMTP_FROM:-PrintStash <no-reply@localhost>}
- FILE_STORAGE=/app/storage
- DATABASE_URL=postgresql://${POSTGRES_USER:-printstash}:${POSTGRES_PASSWORD:-printstash}@db:5432/${POSTGRES_DB:-printstash}?schema=public
- CORS_ORIGINS=${CORS_ORIGINS:-}
+7
View File
@@ -39,6 +39,13 @@ services:
- INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-}
- AUTH_SECRET=${AUTH_SECRET:-changeme-secret}
- AUTH_TOKEN_TTL=${AUTH_TOKEN_TTL:-43200}
- PUBLIC_URL=${PUBLIC_URL:-}
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_SECURE=${SMTP_SECURE:-false}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASS=${SMTP_PASS:-}
- SMTP_FROM=${SMTP_FROM:-PrintStash <no-reply@localhost>}
- FLARESOLVERR_URL=${FLARESOLVERR_URL:-http://flaresolverr:8191/v1}
depends_on:
db:
+37 -5
View File
@@ -12,8 +12,13 @@ import CollectionsPage from "./pages/CollectionsPage";
import CollectionDetailPage from "./pages/CollectionDetailPage";
import AuthorPage from "./pages/AuthorPage";
import AuthPage from "./pages/AuthPage";
import VerifyEmailPage from "./pages/VerifyEmailPage";
import SettingsPage from "./pages/SettingsPage";
import AdminSettingsPage from "./pages/AdminSettingsPage";
import UsersPage from "./pages/UsersPage";
import LogsPage from "./pages/LogsPage";
import TriggersPage from "./pages/TriggersPage";
import ConnectionsPage from "./pages/ConnectionsPage";
import { healthApi, type HealthInfo } from "./api/health";
import { authApi, type AuthUser } from "./api/auth";
import { settingsApi, type PreviewMode } from "./api/settings";
@@ -138,6 +143,22 @@ function AppShell({
: <Navigate to="/" replace />
}
/>
<Route
path="/admin-users"
element={isAdmin ? <UsersPage onUnauthorized={onUnauthorized} /> : <Navigate to="/" replace />}
/>
<Route
path="/admin-logs"
element={isAdmin ? <LogsPage onUnauthorized={onUnauthorized} /> : <Navigate to="/" replace />}
/>
<Route
path="/admin-triggers"
element={isAdmin ? <TriggersPage onUnauthorized={onUnauthorized} /> : <Navigate to="/" replace />}
/>
<Route
path="/admin-connections"
element={isAdmin ? <ConnectionsPage onUnauthorized={onUnauthorized} /> : <Navigate to="/" replace />}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppLayout>
@@ -192,6 +213,9 @@ export default function App() {
}, [sessionExpired, t]);
const handleLogout = () => {
// Fire-and-forget: the audit-log entry is recorded server-side before the token that
// identifies it is gone, but local logout must proceed immediately either way.
void authApi.logout();
clearToken();
clearUser();
setToken(null);
@@ -220,6 +244,10 @@ export default function App() {
}, [token, tokenTtl, handleUnauthorized]);
if (authRequired && !token) {
// No BrowserRouter wraps this branch (see AppShell's comment), so /verify-email -- reached
// 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";
return (
<ThemeProvider theme={muiTheme}>
<CssBaseline />
@@ -233,11 +261,15 @@ export default function App() {
p: 2,
}}
>
<AuthPage
onSuccess={handleLogin}
apiUp={apiUp}
allowRegistrations={health?.allow_registrations ?? true}
/>
{isVerifyEmailPath ? (
<VerifyEmailPage onSuccess={handleLogin} />
) : (
<AuthPage
onSuccess={handleLogin}
apiUp={apiUp}
allowRegistrations={health?.allow_registrations ?? true}
/>
)}
</Box>
</ThemeProvider>
);
+37
View File
@@ -7,6 +7,32 @@ export type AdminUser = {
display_name: string;
role: "ADMIN" | "MEMBER";
print_count: number;
collection_count: number;
thingiverse_count: number;
created_at: string;
};
export type LogAction =
| "user_logged_in"
| "user_logged_out"
| "model_uploaded"
| "model_imported"
| "import_completed"
| "model_edited"
| "model_deleted"
| "collection_created"
| "collection_edited"
| "collection_deleted";
export type LogEntry = {
id: string;
user_id: string;
user_display_name: string;
user_email: string;
action: LogAction;
target_id: string | null;
details: Record<string, unknown>;
created_at: string;
};
export const adminApi = {
@@ -24,4 +50,15 @@ export const adminApi = {
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to delete models"));
return res.json();
},
listLogs: async (filter: { userId?: string; from?: string; to?: string }): Promise<LogEntry[]> => {
const params = new URLSearchParams();
if (filter.userId) params.set("user_id", filter.userId);
if (filter.from) params.set("from", filter.from);
if (filter.to) params.set("to", filter.to);
const qs = params.toString();
const res = await fetch(`${apiBase()}/admin/logs${qs ? `?${qs}` : ""}`, { headers: authHeaders() });
assertOk(res, "Failed to load logs");
return res.json();
},
};
+35 -13
View File
@@ -1,34 +1,46 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk } from "./client";
import { apiBase, assertOk, EmailNotVerifiedError } from "./client";
export type AuthUser = { id: string; email: string; display_name: string; role: "ADMIN" | "MEMBER" };
export type AuthResult = { token: string; expires_in: number; user: AuthUser };
// /register returns this instead of AuthResult when SMTP is configured -- the account exists but
// isn't signed in yet, pending the link in the verification email.
export type RegisterResult = AuthResult | { email_verification_required: true; email: string };
async function postAuth(path: string, body: unknown): Promise<AuthResult> {
async function readAuthError(res: Response): Promise<never> {
let message = "Request failed";
let code: string | undefined;
try {
const data = await res.json();
if (typeof data?.detail === "string") message = data.detail;
if (typeof data?.code === "string") code = data.code;
} catch {
// ignore
}
if (code === "EMAIL_NOT_VERIFIED") throw new EmailNotVerifiedError(message);
throw new Error(message);
}
async function postAuth<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${apiBase()}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
let message = "Request failed";
try {
const data = await res.json();
if (typeof data?.detail === "string") message = data.detail;
} catch {
// ignore
}
throw new Error(message);
}
if (!res.ok) return readAuthError(res);
return res.json();
}
export const authApi = {
login: (email: string, password: string): Promise<AuthResult> => postAuth("/login", { email, password }),
register: (payload: { displayName: string; email: string; password: string }): Promise<AuthResult> =>
register: (payload: { displayName: string; email: string; password: string }): Promise<RegisterResult> =>
postAuth("/register", { displayName: payload.displayName, email: payload.email, password: payload.password }),
verifyEmail: (token: string): Promise<AuthResult> => postAuth("/verify-email", { token }),
resendVerification: (email: string): Promise<{ message: string }> => postAuth("/resend-verification", { email }),
refresh: async (): Promise<{ token: string; expires_in: number }> => {
const res = await fetch(`${apiBase()}/refresh`, {
method: "POST",
@@ -37,4 +49,14 @@ export const authApi = {
assertOk(res, "Token refresh failed");
return res.json();
},
// Best-effort: this only exists to record the audit-log entry before the frontend clears its
// local token, so a failure here (offline, already-expired token) must never block logout.
logout: async (): Promise<void> => {
try {
await fetch(`${apiBase()}/logout`, { method: "POST", headers: authHeaders() });
} catch {
// ignore
}
},
};
+9
View File
@@ -21,6 +21,15 @@ export class LastPlateError extends Error {
}
}
// Thrown for /login's 403 EMAIL_NOT_VERIFIED so SignInPanel can show a "resend verification
// email" affordance instead of a plain error message.
export class EmailNotVerifiedError extends Error {
constructor(message = "Please verify your email before signing in.") {
super(message);
this.name = "EmailNotVerifiedError";
}
}
export function assertOk(res: Response, message: string) {
if (res.status === 401) {
throw new UnauthorizedError();
+56
View File
@@ -12,6 +12,34 @@ export type StorageSettings = {
export type PreviewMode = "automatic" | "on-demand" | "disabled";
export type SmtpSettings = {
host: string | null;
port: number;
secure: boolean;
user: string | null;
from: string;
configured: boolean;
};
// `pass` is only ever sent, never received back (write-only, like the Thingiverse token) --
// omit it entirely to leave the stored password untouched.
export type SmtpSettingsInput = {
host?: string | null;
port?: number;
secure?: boolean;
user?: string | null;
pass?: string | null;
from?: string;
};
export type DatabaseInfo = {
provider: string;
host: string | null;
port: number | null;
database: string | null;
user: string | null;
};
export const settingsApi = {
getStorage: async (): Promise<StorageSettings> => {
const res = await fetch(`${apiBase()}/settings/storage`, { headers: authHeaders() });
@@ -77,4 +105,32 @@ export const settingsApi = {
}
return res.json();
},
// SMTP is used to send the account-verification email on registration (see backend's
// routes/auth.ts) -- write-only for the password like the Thingiverse token above.
getSmtp: async (): Promise<SmtpSettings> => {
const res = await fetch(`${apiBase()}/settings/smtp`, { headers: authHeaders() });
assertOk(res, "Failed to load SMTP settings");
return res.json();
},
updateSmtp: async (payload: SmtpSettingsInput): Promise<SmtpSettings> => {
const res = await fetch(`${apiBase()}/settings/smtp`, {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(payload),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) {
throw new Error(await readErrorMessage(res, "Failed to update SMTP settings"));
}
return res.json();
},
// Read-only: always reflects the live DATABASE_URL the backend process was started with.
getDatabase: async (): Promise<DatabaseInfo> => {
const res = await fetch(`${apiBase()}/settings/database`, { headers: authHeaders() });
assertOk(res, "Failed to load database info");
return res.json();
},
};
@@ -71,6 +71,18 @@ function useRouteChrome() {
} else if (path.startsWith("/admin-settings")) {
title = t("adminSettings.pageTitle");
onBack = () => navigate(-1);
} else if (path.startsWith("/admin-users")) {
title = t("adminSettings.users.heading");
onBack = () => navigate(-1);
} else if (path.startsWith("/admin-logs")) {
title = t("adminSettings.logs.heading");
onBack = () => navigate(-1);
} else if (path.startsWith("/admin-triggers")) {
title = t("adminSettings.triggers.heading");
onBack = () => navigate(-1);
} else if (path.startsWith("/admin-connections")) {
title = t("adminSettings.connections.heading");
onBack = () => navigate(-1);
}
return { title, onBack };
@@ -16,6 +16,10 @@ import ViewInArIcon from "@mui/icons-material/ViewInAr";
import CollectionsIcon from "@mui/icons-material/Collections";
import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
import SettingsIcon from "@mui/icons-material/Settings";
import PeopleIcon from "@mui/icons-material/People";
import HistoryIcon from "@mui/icons-material/History";
import BoltIcon from "@mui/icons-material/Bolt";
import CableIcon from "@mui/icons-material/Cable";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
@@ -68,7 +72,12 @@ export default function Sidebar({ isAdmin }: Props) {
const onDashboard = location.pathname === "/";
const onCollections = location.pathname.startsWith("/models/collections");
const onModels = (location.pathname.startsWith("/models") && !onCollections) || location.pathname.startsWith("/authors");
const onAdmin = location.pathname.startsWith("/admin-settings");
const onAdminSettings = location.pathname.startsWith("/admin-settings");
const onAdminUsers = location.pathname.startsWith("/admin-users");
const onAdminLogs = location.pathname.startsWith("/admin-logs");
const onAdminTriggers = location.pathname.startsWith("/admin-triggers");
const onAdminConnections = location.pathname.startsWith("/admin-connections");
const onAdmin = onAdminSettings || onAdminUsers || onAdminLogs || onAdminTriggers || onAdminConnections;
const adminOpen = adminExpanded || onAdmin;
const toggleCollapsed = () => {
@@ -179,12 +188,38 @@ export default function Sidebar({ isAdmin }: Props) {
<>
<Divider sx={{ my: 1 }} />
{collapsed ? (
<CollapsedNavIcon
icon={<AdminPanelSettingsIcon fontSize="small" />}
label={t("sidebar.administration")}
selected={onAdmin}
onClick={() => navigate("/admin-settings")}
/>
<>
<CollapsedNavIcon
icon={<SettingsIcon fontSize="small" />}
label={t("common:settings")}
selected={onAdminSettings}
onClick={() => navigate("/admin-settings")}
/>
<CollapsedNavIcon
icon={<PeopleIcon fontSize="small" />}
label={t("adminSettings.users.heading")}
selected={onAdminUsers}
onClick={() => navigate("/admin-users")}
/>
<CollapsedNavIcon
icon={<HistoryIcon fontSize="small" />}
label={t("adminSettings.logs.heading")}
selected={onAdminLogs}
onClick={() => navigate("/admin-logs")}
/>
<CollapsedNavIcon
icon={<BoltIcon fontSize="small" />}
label={t("adminSettings.triggers.heading")}
selected={onAdminTriggers}
onClick={() => navigate("/admin-triggers")}
/>
<CollapsedNavIcon
icon={<CableIcon fontSize="small" />}
label={t("adminSettings.connections.heading")}
selected={onAdminConnections}
onClick={() => navigate("/admin-connections")}
/>
</>
) : (
<>
<ListItemButton
@@ -201,7 +236,7 @@ export default function Sidebar({ isAdmin }: Props) {
<Collapse in={adminOpen}>
<List disablePadding>
<ListItemButton
selected={onAdmin}
selected={onAdminSettings}
onClick={() => navigate("/admin-settings")}
sx={{ borderRadius: 1, mb: 0.5, pl: 4 }}
>
@@ -210,6 +245,46 @@ export default function Sidebar({ isAdmin }: Props) {
</ListItemIcon>
<ListItemText primary={t("common:settings")} primaryTypographyProps={{ variant: "body2" }} />
</ListItemButton>
<ListItemButton
selected={onAdminUsers}
onClick={() => navigate("/admin-users")}
sx={{ borderRadius: 1, mb: 0.5, pl: 4 }}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<PeopleIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t("adminSettings.users.heading")} primaryTypographyProps={{ variant: "body2" }} />
</ListItemButton>
<ListItemButton
selected={onAdminLogs}
onClick={() => navigate("/admin-logs")}
sx={{ borderRadius: 1, mb: 0.5, pl: 4 }}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<HistoryIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t("adminSettings.logs.heading")} primaryTypographyProps={{ variant: "body2" }} />
</ListItemButton>
<ListItemButton
selected={onAdminTriggers}
onClick={() => navigate("/admin-triggers")}
sx={{ borderRadius: 1, mb: 0.5, pl: 4 }}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<BoltIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t("adminSettings.triggers.heading")} primaryTypographyProps={{ variant: "body2" }} />
</ListItemButton>
<ListItemButton
selected={onAdminConnections}
onClick={() => navigate("/admin-connections")}
sx={{ borderRadius: 1, mb: 0.5, pl: 4 }}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<CableIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t("adminSettings.connections.heading")} primaryTypographyProps={{ variant: "body2" }} />
</ListItemButton>
</List>
</Collapse>
</>
+7 -5
View File
@@ -7,12 +7,14 @@ import Button from "@mui/material/Button";
type Props = {
title: string;
subtitle: string;
onBack: () => void;
backLabel: string;
onBack?: () => void;
backLabel?: string;
};
/** Shared heading row for every Settings (and Admin Settings) section: title/subtitle on the
* left, a "Back" button on the right that returns to the root menu grid. */
/** Shared heading row for a Settings (or Admin Settings) section: title/subtitle on the left,
* and -- only when the section is reached by drilling into a root menu grid -- a "Back" button
* on the right that returns to it. Sections rendered together on one page (nothing to go back
* to) simply omit onBack. */
export default function SectionHeader({ title, subtitle, onBack, backLabel }: Props) {
return (
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" gap={2} flexWrap="wrap">
@@ -20,7 +22,7 @@ export default function SectionHeader({ title, subtitle, onBack, backLabel }: Pr
<Typography variant="h5" fontWeight={600}>{title}</Typography>
<Typography variant="body2" color="text.secondary">{subtitle}</Typography>
</Box>
<Button variant="outlined" size="small" onClick={onBack}>{backLabel}</Button>
{onBack && <Button variant="outlined" size="small" onClick={onBack}>{backLabel}</Button>}
</Stack>
);
}
+94 -17
View File
@@ -29,6 +29,20 @@
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 8 characters",
"disabled": "New registrations are currently disabled."
},
"checkEmail": {
"heading": "Check your email",
"body": "We sent a confirmation link to {{email}}. Click it to finish creating your account.",
"resendButton": "Resend email",
"resending": "Sending…",
"resent": "Sent! Check your inbox.",
"resendFailed": "Failed to resend — try again"
},
"verifyEmail": {
"verifying": "Confirming your email…",
"missingToken": "This verification link is missing its token.",
"failed": "This verification link is invalid or has expired.",
"backToSignIn": "Back to sign in"
}
},
"sidebar": {
@@ -241,21 +255,6 @@
},
"adminSettings": {
"pageTitle": "Admin Settings",
"back": "Back",
"root": {
"storageTitle": "Files",
"storageHeading": "Storage Structure",
"storageDesc": "Organize files with folders, model names, creators, tags, and collections.",
"previewsTitle": "Performance",
"previewsHeading": "Model Previews",
"previewsDesc": "Choose automatic, on-demand, or disabled preview generation.",
"triggersTitle": "Actions",
"triggersHeading": "Triggers",
"triggersDesc": "One-off actions that operate on another user's data.",
"thingiverseTitle": "Imports",
"thingiverseHeading": "Thingiverse Access Token",
"thingiverseDesc": "Configure the shared Access Token used for every user's Thingiverse imports."
},
"storage": {
"heading": "Storage Structure",
"subtitle": "Choose how managed model files are organized on disk.",
@@ -277,18 +276,21 @@
"previews": {
"heading": "Model Previews",
"subtitle": "Control background preview generation for the whole instance.",
"infoTitle": "What this controls",
"infoScope": "This only affects the small 3D thumbnail shown on model cards (Models, Collections) — never the full model viewer. It also only applies to files with no thumbnail yet: an embedded thumbnail from a sliced .3mf file, or one already generated before, is always reused automatically regardless of this setting. This choice only decides what happens for the rest — typically .stl/.obj files, or a .3mf with no embedded thumbnail:",
"infoAutomatic": "Automatic — a visitor's browser renders and saves the missing thumbnail as soon as its card scrolls into view.",
"infoOnDemand": "On demand — a card shows a \"Generate preview\" button instead; nothing renders until someone clicks it.",
"infoDisabled": "Disabled — cards always show a placeholder; a thumbnail is never rendered.",
"automaticLabel": "Automatic",
"automaticDesc": "Load missing previews only as cards enter view, then save them for future visits.",
"onDemandLabel": "On demand",
"onDemandDesc": "Show saved previews immediately and generate missing ones only when requested.",
"disabledLabel": "Disabled",
"disabledDesc": "Never generate card previews. Interactive viewing remains available by opening a model.",
"footerNote": "Embedded 3MF thumbnails and previously generated server thumbnails are always used when available.",
"failed": "Failed to save preview settings."
},
"triggers": {
"heading": "Triggers",
"subtitle": "Irreversible, one-off actions run against another user's account. Use with care.",
"deleteAllTitle": "Delete all models for a user",
"deleteAllDesc": "Permanently deletes every model a user created, uploaded, or imported — files included. Their categories and collections are left in place, just empty.",
"userLabel": "User",
@@ -318,6 +320,81 @@
"saved": "Saved.",
"cleared": "Access Token removed.",
"failed": "Failed to save Thingiverse settings."
},
"connections": {
"heading": "Connections"
},
"smtp": {
"tabLabel": "SMTP",
"helpText": "Used to send the account-verification email on registration. Leave Host blank to turn email verification off entirely — new accounts are then verified and signed in immediately.",
"statusConfigured": "SMTP is configured — new accounts must verify their email before signing in.",
"statusNotConfigured": "SMTP is not configured — new accounts are verified and signed in immediately.",
"hostLabel": "Host",
"hostPlaceholder": "smtp.example.com",
"portLabel": "Port",
"secureLabel": "Use implicit TLS",
"secureHint": "Enable for port 465. Leave off for STARTTLS on port 587.",
"userLabel": "Username",
"passwordLabel": "Password",
"passwordPlaceholder": "Leave blank to keep the current password",
"fromLabel": "From address",
"fromPlaceholder": "PrintStash <no-reply@example.com>",
"save": "Save",
"saving": "Saving…",
"saved": "Saved.",
"loadFailed": "Unable to load SMTP settings.",
"failed": "Failed to save SMTP settings."
},
"database": {
"tabLabel": "Database",
"helpText": "Read-only — always reflects the DATABASE_URL this instance was started with. Switching databases means restarting the backend with a different value, not something changeable here.",
"providerLabel": "Provider",
"hostLabel": "Host",
"portLabel": "Port",
"nameLabel": "Database",
"userLabel": "User",
"unset": "—",
"loadFailed": "Unable to load database info."
},
"users": {
"heading": "Users",
"columnEmail": "Email",
"columnDisplayName": "Display name",
"columnRole": "Role",
"columnModels": "Models",
"columnThingiverse": "Thingiverse",
"columnCollections": "Collections",
"columnCreated": "Created",
"roleAdmin": "Admin",
"roleMember": "Member",
"loadFailed": "Unable to load users."
},
"logs": {
"heading": "Logs",
"userLabel": "User",
"allUsers": "All users",
"fromLabel": "From",
"toLabel": "To",
"columnDate": "Date",
"columnUser": "User",
"columnAction": "Action",
"columnDetails": "Details",
"loadFailed": "Unable to load logs.",
"empty": "No log entries for this filter.",
"editedField": "Edited {{field}}",
"importSummary": "{{label}} — {{imported}} imported, {{failed}} failed",
"action": {
"user_logged_in": "Logged in",
"user_logged_out": "Logged out",
"model_uploaded": "Model uploaded",
"model_imported": "Model imported",
"import_completed": "Import completed",
"model_edited": "Model edited",
"model_deleted": "Model deleted",
"collection_created": "Collection created",
"collection_edited": "Collection edited",
"collection_deleted": "Collection deleted"
}
}
}
},
+94 -17
View File
@@ -29,6 +29,20 @@
"passwordMismatch": "Slaptažodžiai nesutampa",
"passwordTooShort": "Slaptažodis turi būti bent 8 simbolių",
"disabled": "Šiuo metu naujos registracijos yra išjungtos."
},
"checkEmail": {
"heading": "Patikrinkite savo el. paštą",
"body": "Išsiuntėme patvirtinimo nuorodą adresu {{email}}. Paspauskite ją, kad baigtumėte kurti paskyrą.",
"resendButton": "Siųsti dar kartą",
"resending": "Siunčiama…",
"resent": "Išsiųsta! Patikrinkite pašto dėžutę.",
"resendFailed": "Nepavyko išsiųsti iš naujo — bandykite dar kartą"
},
"verifyEmail": {
"verifying": "Patvirtinamas el. paštas…",
"missingToken": "Šioje patvirtinimo nuorodoje trūksta rakto.",
"failed": "Ši patvirtinimo nuoroda negalioja arba jos galiojimas baigėsi.",
"backToSignIn": "Grįžti į prisijungimą"
}
},
"sidebar": {
@@ -241,21 +255,6 @@
},
"adminSettings": {
"pageTitle": "Administravimo nustatymai",
"back": "Atgal",
"root": {
"storageTitle": "Failai",
"storageHeading": "Saugojimo struktūra",
"storageDesc": "Tvarkykite failus pagal aplankus, modelių pavadinimus, autorius, žymas ir kolekcijas.",
"previewsTitle": "Našumas",
"previewsHeading": "Modelių peržiūros",
"previewsDesc": "Pasirinkite automatinį, pagal poreikį arba išjungtą peržiūrų generavimą.",
"triggersTitle": "Veiksmai",
"triggersHeading": "Trigeriai",
"triggersDesc": "Vienkartiniai veiksmai, veikiantys kito naudotojo duomenis.",
"thingiverseTitle": "Importas",
"thingiverseHeading": "Thingiverse prieigos raktas",
"thingiverseDesc": "Sukonfigūruokite bendrą prieigos raktą, naudojamą visų naudotojų Thingiverse importui."
},
"storage": {
"heading": "Saugojimo struktūra",
"subtitle": "Pasirinkite, kaip valdomi modelių failai tvarkomi diske.",
@@ -277,18 +276,21 @@
"previews": {
"heading": "Modelių peržiūros",
"subtitle": "Valdykite fono peržiūrų generavimą visai sistemai.",
"infoTitle": "Ką tai valdo",
"infoScope": "Tai veikia tik mažą 3D miniatiūrą, rodomą modelio kortelėje (Modeliai, Kolekcijos) — niekada pilną modelio peržiūros langą. Taip pat tai galioja tik failams, kurie dar neturi miniatiūros: įtaisytoji suskaidyto .3mf failo miniatiūra arba anksčiau jau sugeneruota miniatiūra visada naudojama automatiškai, nepriklausomai nuo šio nustatymo. Šis pasirinkimas nulemia tik likusius atvejus — dažniausiai .stl/.obj failus arba .3mf be įtaisytos miniatiūros:",
"infoAutomatic": "Automatinis — lankytojo naršyklė sugeneruoja ir išsaugo trūkstamą miniatiūrą, vos tik kortelė atsiranda ekrane.",
"infoOnDemand": "Pagal poreikį — kortelėje rodomas mygtukas „Generuoti peržiūrą“; niekas negeneruojama, kol jo nepaspaudžiama.",
"infoDisabled": "Išjungta — kortelėse visada rodomas rezervuotas vaizdas; miniatiūra niekada negeneruojama.",
"automaticLabel": "Automatinis",
"automaticDesc": "Trūkstamas peržiūras įkelti tik joms atsiradus ekrane, tada išsaugoti ateičiai.",
"onDemandLabel": "Pagal poreikį",
"onDemandDesc": "Iš karto rodyti išsaugotas peržiūras, o trūkstamas generuoti tik paprašius.",
"disabledLabel": "Išjungta",
"disabledDesc": "Niekada negeneruoti kortelių peržiūrų. Interaktyvi peržiūra vis tiek pasiekiama atidarius modelį.",
"footerNote": "Įtaisytosios 3MF miniatiūros ir anksčiau sugeneruotos serverio miniatiūros visada naudojamos, kai jos yra.",
"failed": "Nepavyko išsaugoti peržiūrų nustatymų."
},
"triggers": {
"heading": "Trigeriai",
"subtitle": "Negrįžtami, vienkartiniai veiksmai, atliekami su kito naudotojo paskyra. Naudokite atsargiai.",
"deleteAllTitle": "Ištrinti visus naudotojo modelius",
"deleteAllDesc": "Negrįžtamai ištrina visus naudotojo sukurtus, įkeltus ar importuotus modelius — kartu su failais. Jo kategorijos ir kolekcijos lieka, tik tuščios.",
"userLabel": "Naudotojas",
@@ -318,6 +320,81 @@
"saved": "Išsaugota.",
"cleared": "Prieigos raktas pašalintas.",
"failed": "Nepavyko išsaugoti Thingiverse nustatymų."
},
"connections": {
"heading": "Ryšiai"
},
"smtp": {
"tabLabel": "SMTP",
"helpText": "Naudojama siunčiant el. pašto patvirtinimo laišką registruojantis. Palikite Serverį (Host) tuščią, kad visiškai išjungtumėte el. pašto patvirtinimą — tuomet naujos paskyros iš karto patvirtinamos ir prijungiamos.",
"statusConfigured": "SMTP sukonfigūruotas — naujos paskyros privalo patvirtinti el. paštą prieš prisijungdamos.",
"statusNotConfigured": "SMTP nesukonfigūruotas — naujos paskyros iš karto patvirtinamos ir prijungiamos.",
"hostLabel": "Serveris",
"hostPlaceholder": "smtp.example.com",
"portLabel": "Prievadas",
"secureLabel": "Naudoti numanomą TLS",
"secureHint": "Įjunkite 465 prievadui. Palikite išjungtą STARTTLS su 587 prievadu.",
"userLabel": "Naudotojo vardas",
"passwordLabel": "Slaptažodis",
"passwordPlaceholder": "Palikite tuščią, kad išsaugotumėte dabartinį slaptažodį",
"fromLabel": "Siuntėjo adresas",
"fromPlaceholder": "PrintStash <no-reply@example.com>",
"save": "Išsaugoti",
"saving": "Saugoma…",
"saved": "Išsaugota.",
"loadFailed": "Nepavyko įkelti SMTP nustatymų.",
"failed": "Nepavyko išsaugoti SMTP nustatymų."
},
"database": {
"tabLabel": "Duomenų bazė",
"helpText": "Tik skaitymui — visada rodo DATABASE_URL reikšmę, su kuria paleistas šis serveris. Norint pakeisti duomenų bazę, reikia paleisti serverį iš naujo su kita reikšme — čia to pakeisti negalima.",
"providerLabel": "Tiekėjas",
"hostLabel": "Serveris",
"portLabel": "Prievadas",
"nameLabel": "Duomenų bazė",
"userLabel": "Naudotojas",
"unset": "—",
"loadFailed": "Nepavyko įkelti duomenų bazės informacijos."
},
"users": {
"heading": "Naudotojai",
"columnEmail": "El. paštas",
"columnDisplayName": "Rodomas vardas",
"columnRole": "Vaidmuo",
"columnModels": "Modeliai",
"columnThingiverse": "Thingiverse",
"columnCollections": "Kolekcijos",
"columnCreated": "Sukurta",
"roleAdmin": "Administratorius",
"roleMember": "Narys",
"loadFailed": "Nepavyko įkelti naudotojų."
},
"logs": {
"heading": "Žurnalai",
"userLabel": "Naudotojas",
"allUsers": "Visi naudotojai",
"fromLabel": "Nuo",
"toLabel": "Iki",
"columnDate": "Data",
"columnUser": "Naudotojas",
"columnAction": "Veiksmas",
"columnDetails": "Detalės",
"loadFailed": "Nepavyko įkelti žurnalų.",
"empty": "Pagal šį filtrą įrašų nėra.",
"editedField": "Redaguota: {{field}}",
"importSummary": "{{label}} — importuota {{imported}}, nepavyko {{failed}}",
"action": {
"user_logged_in": "Prisijungė",
"user_logged_out": "Atsijungė",
"model_uploaded": "Modelis įkeltas",
"model_imported": "Modelis importuotas",
"import_completed": "Importas baigtas",
"model_edited": "Modelis redaguotas",
"model_deleted": "Modelis ištrintas",
"collection_created": "Kolekcija sukurta",
"collection_edited": "Kolekcija redaguota",
"collection_deleted": "Kolekcija ištrinta"
}
}
}
},
@@ -7,6 +7,8 @@ import Radio from "@mui/material/Radio";
import RadioGroup from "@mui/material/RadioGroup";
import FormControlLabel from "@mui/material/FormControlLabel";
import CircularProgress from "@mui/material/CircularProgress";
import Alert from "@mui/material/Alert";
import AlertTitle from "@mui/material/AlertTitle";
import { UnauthorizedError } from "../../api/client";
import { settingsApi, type PreviewMode } from "../../api/settings";
import SectionHeader from "../../components/SectionHeader";
@@ -14,12 +16,11 @@ import SectionHeader from "../../components/SectionHeader";
type Props = {
onUnauthorized?: () => void;
onSaved?: (mode: PreviewMode) => void;
onBack: () => void;
};
// Instance-wide, not per-user -- every browser hitting this API generates/serves previews
// against the same storage, so this is admin-configured like Storage Structure.
export default function PreviewsSection({ onUnauthorized, onSaved, onBack }: Props) {
export default function PreviewsSection({ onUnauthorized, onSaved }: Props) {
const { t } = useTranslation("app");
const [mode, setMode] = React.useState<PreviewMode>("automatic");
const [loading, setLoading] = React.useState(false);
@@ -86,9 +87,16 @@ export default function PreviewsSection({ onUnauthorized, onSaved, onBack }: Pro
<SectionHeader
title={t("adminSettings.previews.heading")}
subtitle={t("adminSettings.previews.subtitle")}
onBack={onBack}
backLabel={t("adminSettings.back")}
/>
<Alert severity="info">
<AlertTitle>{t("adminSettings.previews.infoTitle")}</AlertTitle>
{t("adminSettings.previews.infoScope")}
<Box component="ul" sx={{ mt: 1, mb: 0, pl: 2.5 }}>
<li><Typography variant="body2">{t("adminSettings.previews.infoAutomatic")}</Typography></li>
<li><Typography variant="body2">{t("adminSettings.previews.infoOnDemand")}</Typography></li>
<li><Typography variant="body2">{t("adminSettings.previews.infoDisabled")}</Typography></li>
</Box>
</Alert>
<RadioGroup value={mode} onChange={e => selectMode(e.target.value as PreviewMode)}>
<Stack spacing={1.5}>
{options.map(option => {
@@ -119,12 +127,12 @@ export default function PreviewsSection({ onUnauthorized, onSaved, onBack }: Pro
})}
</Stack>
</RadioGroup>
<Stack direction="row" alignItems="center" spacing={1}>
{saving && <CircularProgress size={14} />}
<Typography variant="caption" color="text.secondary">
{status || t("adminSettings.previews.footerNote")}
</Typography>
</Stack>
{(saving || status) && (
<Stack direction="row" alignItems="center" spacing={1}>
{saving && <CircularProgress size={14} />}
{status && <Typography variant="caption" color="text.secondary">{status}</Typography>}
</Stack>
)}
</Stack>
);
}
@@ -14,7 +14,6 @@ import SectionHeader from "../../components/SectionHeader";
type Props = {
onUnauthorized?: () => void;
onBack: () => void;
};
// Two example plates of the same print, to show that sibling plates
@@ -46,7 +45,7 @@ const PLATE_PREVIEW_VALUES: Record<string, string>[] = [
const DEFAULT_TEMPLATE = "{folder}/{model}/{filename}";
export default function StorageSection({ onUnauthorized, onBack }: Props) {
export default function StorageSection({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
const [storageTemplate, setStorageTemplate] = React.useState(DEFAULT_TEMPLATE);
const [storageInitial, setStorageInitial] = React.useState(DEFAULT_TEMPLATE);
@@ -128,8 +127,6 @@ export default function StorageSection({ onUnauthorized, onBack }: Props) {
<SectionHeader
title={t("adminSettings.storage.heading")}
subtitle={t("adminSettings.storage.subtitle")}
onBack={onBack}
backLabel={t("adminSettings.back")}
/>
<Paper variant="outlined" sx={{ p: 2.5 }}>
@@ -12,14 +12,13 @@ import SectionHeader from "../../components/SectionHeader";
type Props = {
onUnauthorized?: () => void;
onBack: () => void;
};
/** Instance-wide, not per-user: a Thingiverse Developer API Access Token shared by every user's
* Thingiverse imports (see backend's thingiverseApi.ts) -- admin-configured like Storage
* Structure and Model Previews. Write-only like any other API secret: the current token is
* never sent back from the server, only whether one is configured. */
export default function ThingiverseSection({ onUnauthorized, onBack }: Props) {
export default function ThingiverseSection({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
const [configured, setConfigured] = React.useState(false);
const [loading, setLoading] = React.useState(false);
@@ -91,8 +90,6 @@ export default function ThingiverseSection({ onUnauthorized, onBack }: Props) {
<SectionHeader
title={t("adminSettings.thingiverse.heading")}
subtitle={t("adminSettings.thingiverse.subtitle")}
onBack={onBack}
backLabel={t("adminSettings.back")}
/>
<Typography variant="caption" color="text.secondary">
+10 -81
View File
@@ -1,14 +1,8 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Grid from "@mui/material/Grid";
import Card from "@mui/material/Card";
import CardActionArea from "@mui/material/CardActionArea";
import CardContent from "@mui/material/CardContent";
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import Divider from "@mui/material/Divider";
import type { PreviewMode } from "../../api/settings";
import StorageSection from "./StorageSection";
import PreviewsSection from "./PreviewsSection";
import TriggersSection from "./TriggersSection";
import ThingiverseSection from "./ThingiverseSection";
type Props = {
@@ -16,81 +10,16 @@ type Props = {
onPreviewModeChanged?: (mode: PreviewMode) => void;
};
type Section = "root" | "storage" | "previews" | "triggers" | "thingiverse";
// Instance-wide config, gated to admins by the sidebar link that opens this page -- unlike
// SettingsPage, nothing here is a per-user preference.
// SettingsPage, nothing here is a per-user preference. All three settings are shown together on
// one page rather than behind separate click-through sections, since there are only three and
// none of them is long enough to need its own screen.
export default function AdminSettingsPage({ onUnauthorized, onPreviewModeChanged }: Props) {
const { t } = useTranslation("app");
const [section, setSection] = React.useState<Section>("root");
const backToRoot = () => setSection("root");
if (section === "storage") {
return <StorageSection onUnauthorized={onUnauthorized} onBack={backToRoot} />;
}
if (section === "previews") {
return (
<PreviewsSection
onUnauthorized={onUnauthorized}
onSaved={onPreviewModeChanged}
onBack={backToRoot}
/>
);
}
if (section === "triggers") {
return <TriggersSection onUnauthorized={onUnauthorized} onBack={backToRoot} />;
}
if (section === "thingiverse") {
return <ThingiverseSection onUnauthorized={onUnauthorized} onBack={backToRoot} />;
}
const rootCards: Array<{ eyebrow: string; heading: string; desc: string; onClick: () => void }> = [
{
eyebrow: t("adminSettings.root.storageTitle"),
heading: t("adminSettings.root.storageHeading"),
desc: t("adminSettings.root.storageDesc"),
onClick: () => setSection("storage"),
},
{
eyebrow: t("adminSettings.root.previewsTitle"),
heading: t("adminSettings.root.previewsHeading"),
desc: t("adminSettings.root.previewsDesc"),
onClick: () => setSection("previews"),
},
{
eyebrow: t("adminSettings.root.triggersTitle"),
heading: t("adminSettings.root.triggersHeading"),
desc: t("adminSettings.root.triggersDesc"),
onClick: () => setSection("triggers"),
},
{
eyebrow: t("adminSettings.root.thingiverseTitle"),
heading: t("adminSettings.root.thingiverseHeading"),
desc: t("adminSettings.root.thingiverseDesc"),
onClick: () => setSection("thingiverse"),
},
];
return (
<Grid container spacing={2}>
{rootCards.map(card => (
<Grid item xs={12} sm={6} key={card.heading}>
<Card variant="outlined">
<CardActionArea onClick={card.onClick}>
<CardContent>
<Typography variant="caption" color="text.secondary" sx={{ textTransform: "uppercase", letterSpacing: 0.5 }}>
{card.eyebrow}
</Typography>
<Typography variant="h6">{card.heading}</Typography>
<Typography variant="body2" color="text.secondary">{card.desc}</Typography>
</CardContent>
</CardActionArea>
</Card>
</Grid>
))}
</Grid>
<Stack spacing={4} divider={<Divider />}>
<StorageSection onUnauthorized={onUnauthorized} />
<PreviewsSection onUnauthorized={onUnauthorized} onSaved={onPreviewModeChanged} />
<ThingiverseSection onUnauthorized={onUnauthorized} />
</Stack>
);
}
@@ -0,0 +1,26 @@
import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import MarkEmailReadIcon from "@mui/icons-material/MarkEmailRead";
import ResendVerificationButton from "./ResendVerificationButton";
type Props = {
email: string;
};
/** Shown in place of the registration form right after signing up on an instance with SMTP
* configured -- the account exists but can't sign in until the emailed link is clicked. */
export default function CheckEmailPanel({ email }: Props) {
const { t } = useTranslation("app");
return (
<Stack spacing={2} alignItems="center" sx={{ textAlign: "center", py: 1 }}>
<MarkEmailReadIcon sx={{ fontSize: 40, color: "primary.main" }} />
<Typography variant="h6">{t("auth.checkEmail.heading")}</Typography>
<Typography variant="body2" color="text.secondary">
{t("auth.checkEmail.body", { email })}
</Typography>
<ResendVerificationButton email={email} />
</Stack>
);
}
+13 -1
View File
@@ -7,6 +7,7 @@ import Button from "@mui/material/Button";
import Alert from "@mui/material/Alert";
import Typography from "@mui/material/Typography";
import { authApi, type AuthUser } from "../../api/auth";
import CheckEmailPanel from "./CheckEmailPanel";
const MIN_PASSWORD_LENGTH = 8;
@@ -23,6 +24,9 @@ export default function RegisterPanel({ onSuccess, allowRegistrations }: Props)
const [confirmPassword, setConfirmPassword] = React.useState("");
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
// Set once the backend confirms this instance requires email verification -- replaces the
// form with CheckEmailPanel instead of ever calling onSuccess.
const [pendingEmail, setPendingEmail] = React.useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -38,7 +42,11 @@ export default function RegisterPanel({ onSuccess, allowRegistrations }: Props)
setLoading(true);
try {
const res = await authApi.register({ displayName, email, password });
onSuccess(res.token, res.expires_in, res.user);
if ("email_verification_required" in res) {
setPendingEmail(res.email);
} else {
onSuccess(res.token, res.expires_in, res.user);
}
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : t("auth.register.failed"));
@@ -47,6 +55,10 @@ export default function RegisterPanel({ onSuccess, allowRegistrations }: Props)
}
};
if (pendingEmail) {
return <CheckEmailPanel email={pendingEmail} />;
}
return (
<Box component="form" onSubmit={handleSubmit}>
<Stack spacing={2}>
@@ -0,0 +1,52 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import Typography from "@mui/material/Typography";
import { authApi } from "../../api/auth";
type Props = {
email: string;
};
type State = "idle" | "sending" | "sent" | "error";
/** Shared "Resend verification email" affordance -- used both right after registering
* (CheckEmailPanel) and when a sign-in attempt is blocked because the account isn't verified
* yet (SignInPanel). The backend's response is always the same generic message regardless of
* whether the account exists or is already verified, so there's nothing more specific to show
* here even on failure. */
export default function ResendVerificationButton({ email }: Props) {
const { t } = useTranslation("app");
const [state, setState] = React.useState<State>("idle");
const handleClick = async () => {
setState("sending");
try {
await authApi.resendVerification(email);
setState("sent");
} catch {
setState("error");
}
};
if (state === "sent") {
return <Typography variant="body2" color="success.main">{t("auth.checkEmail.resent")}</Typography>;
}
return (
<Button
variant="outlined"
size="small"
onClick={handleClick}
disabled={state === "sending"}
startIcon={state === "sending" ? <CircularProgress size={14} /> : undefined}
>
{state === "sending"
? t("auth.checkEmail.resending")
: state === "error"
? t("auth.checkEmail.resendFailed")
: t("auth.checkEmail.resendButton")}
</Button>
);
}
+11 -1
View File
@@ -6,6 +6,8 @@ import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Alert from "@mui/material/Alert";
import { authApi, type AuthUser } from "../../api/auth";
import { EmailNotVerifiedError } from "../../api/client";
import ResendVerificationButton from "./ResendVerificationButton";
type Props = {
onSuccess: (token: string, expires_in: number, user: AuthUser) => void;
@@ -17,17 +19,24 @@ export default function SignInPanel({ onSuccess }: Props) {
const [password, setPassword] = React.useState("");
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [needsVerification, setNeedsVerification] = React.useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setNeedsVerification(false);
setLoading(true);
try {
const res = await authApi.login(email, password);
onSuccess(res.token, res.expires_in, res.user);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : t("auth.signIn.failed"));
if (err instanceof EmailNotVerifiedError) {
setNeedsVerification(true);
setError(err.message);
} else {
setError(err instanceof Error ? err.message : t("auth.signIn.failed"));
}
} finally {
setLoading(false);
}
@@ -37,6 +46,7 @@ export default function SignInPanel({ onSuccess }: Props) {
<Box component="form" onSubmit={handleSubmit}>
<Stack spacing={2}>
{error && <Alert severity="error">{error}</Alert>}
{needsVerification && <ResendVerificationButton email={email} />}
<TextField
type="email"
label={t("auth.signIn.emailLabel")}
@@ -0,0 +1,83 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
import CircularProgress from "@mui/material/CircularProgress";
import Alert from "@mui/material/Alert";
import { UnauthorizedError } from "../../api/client";
import { settingsApi, type DatabaseInfo } from "../../api/settings";
type Props = {
onUnauthorized?: () => void;
};
function Row({ label, value }: { label: string; value: string }) {
return (
<Stack direction="row" justifyContent="space-between" alignItems="baseline" spacing={2}>
<Typography variant="body2" color="text.secondary">{label}</Typography>
<Typography variant="body2" sx={{ fontFamily: "monospace" }}>{value}</Typography>
</Stack>
);
}
/** Read-only: this always reflects the DATABASE_URL environment variable the backend process was
* actually started with -- there's no admin-editable override, since switching databases means
* restarting the process against a different env var, not something safe to do from a running
* app talking to the database it would be switching away from. The password is never sent to
* the frontend at all, not even masked. */
export default function DatabaseTab({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
const [info, setInfo] = React.useState<DatabaseInfo | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let active = true;
setLoading(true);
void (async () => {
try {
const data = await settingsApi.getDatabase();
if (active) setInfo(data);
} catch (err) {
if (!active) return;
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(t("adminSettings.database.loadFailed"));
} finally {
if (active) setLoading(false);
}
})();
return () => { active = false; };
}, [onUnauthorized, t]);
if (loading) {
return (
<Stack alignItems="center" sx={{ py: 4 }}>
<CircularProgress size={20} />
</Stack>
);
}
if (error || !info) {
return <Alert severity="error">{error || t("adminSettings.database.loadFailed")}</Alert>;
}
const unset = t("adminSettings.database.unset");
return (
<Stack spacing={3}>
<Typography variant="caption" color="text.secondary">
{t("adminSettings.database.helpText")}
</Typography>
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={1.5}>
<Row label={t("adminSettings.database.providerLabel")} value={info.provider} />
<Row label={t("adminSettings.database.hostLabel")} value={info.host ?? unset} />
<Row label={t("adminSettings.database.portLabel")} value={info.port ? String(info.port) : unset} />
<Row label={t("adminSettings.database.nameLabel")} value={info.database ?? unset} />
<Row label={t("adminSettings.database.userLabel")} value={info.user ?? unset} />
</Stack>
</Paper>
</Stack>
);
}
@@ -0,0 +1,167 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Switch from "@mui/material/Switch";
import FormControlLabel from "@mui/material/FormControlLabel";
import Alert from "@mui/material/Alert";
import CircularProgress from "@mui/material/CircularProgress";
import { UnauthorizedError } from "../../api/client";
import { settingsApi, type SmtpSettings } from "../../api/settings";
type Props = {
onUnauthorized?: () => void;
};
const EMPTY: SmtpSettings = { host: null, port: 587, secure: false, user: null, from: "", configured: false };
/** Instance-wide SMTP credentials used to send the account-verification email on registration
* (see backend's routes/auth.ts and mailer.ts). Leaving Host blank turns email verification off
* entirely -- new accounts are then verified and signed in immediately. */
export default function SmtpTab({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
const [settings, setSettings] = React.useState<SmtpSettings>(EMPTY);
const [password, setPassword] = React.useState("");
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [status, setStatus] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
setLoading(true);
try {
setSettings(await settingsApi.getSmtp());
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(t("adminSettings.smtp.loadFailed"));
} finally {
setLoading(false);
}
}, [onUnauthorized, t]);
React.useEffect(() => { void load(); }, [load]);
const save = async () => {
setSaving(true);
setStatus(null);
setError(null);
try {
const next = await settingsApi.updateSmtp({
host: settings.host,
port: settings.port,
secure: settings.secure,
user: settings.user,
from: settings.from,
...(password.trim() ? { pass: password.trim() } : {}),
});
setSettings(next);
setPassword("");
setStatus(t("adminSettings.smtp.saved"));
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : t("adminSettings.smtp.failed"));
} finally {
setSaving(false);
}
};
if (loading) {
return (
<Stack alignItems="center" sx={{ py: 4 }}>
<CircularProgress size={20} />
</Stack>
);
}
const disabled = saving;
return (
<Stack spacing={3}>
<Typography variant="caption" color="text.secondary">
{t("adminSettings.smtp.helpText")}
</Typography>
<Alert severity={settings.configured ? "success" : "warning"}>
{settings.configured ? t("adminSettings.smtp.statusConfigured") : t("adminSettings.smtp.statusNotConfigured")}
</Alert>
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
<TextField
label={t("adminSettings.smtp.hostLabel")}
value={settings.host ?? ""}
onChange={e => setSettings(s => ({ ...s, host: e.target.value }))}
placeholder={t("adminSettings.smtp.hostPlaceholder") ?? undefined}
disabled={disabled}
sx={{ flex: "2 1 260px" }}
/>
<TextField
type="number"
label={t("adminSettings.smtp.portLabel")}
value={settings.port}
onChange={e => setSettings(s => ({ ...s, port: Number(e.target.value) || s.port }))}
disabled={disabled}
sx={{ flex: "1 1 120px" }}
/>
</Stack>
<FormControlLabel
control={
<Switch
checked={settings.secure}
onChange={e => setSettings(s => ({ ...s, secure: e.target.checked }))}
disabled={disabled}
/>
}
label={
<Box>
<Typography variant="body2">{t("adminSettings.smtp.secureLabel")}</Typography>
<Typography variant="caption" color="text.secondary">{t("adminSettings.smtp.secureHint")}</Typography>
</Box>
}
/>
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
<TextField
label={t("adminSettings.smtp.userLabel")}
value={settings.user ?? ""}
onChange={e => setSettings(s => ({ ...s, user: e.target.value }))}
disabled={disabled}
autoComplete="off"
sx={{ flex: "1 1 220px" }}
/>
<TextField
type="password"
label={t("adminSettings.smtp.passwordLabel")}
value={password}
onChange={e => setPassword(e.target.value)}
placeholder={t("adminSettings.smtp.passwordPlaceholder") ?? undefined}
disabled={disabled}
autoComplete="new-password"
sx={{ flex: "1 1 220px" }}
/>
</Stack>
<TextField
label={t("adminSettings.smtp.fromLabel")}
value={settings.from}
onChange={e => setSettings(s => ({ ...s, from: e.target.value }))}
placeholder={t("adminSettings.smtp.fromPlaceholder") ?? undefined}
disabled={disabled}
fullWidth
/>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap">
<Button variant="contained" onClick={save} disabled={disabled}>
{saving ? t("adminSettings.smtp.saving") : t("adminSettings.smtp.save")}
</Button>
{saving && <CircularProgress size={14} />}
{status && <Typography variant="caption" color="text.secondary">{status}</Typography>}
</Stack>
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
</Stack>
);
}
@@ -0,0 +1,28 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import SmtpTab from "./SmtpTab";
import DatabaseTab from "./DatabaseTab";
type Props = {
onUnauthorized?: () => void;
};
type TabKey = "smtp" | "database";
export default function ConnectionsPage({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
const [tab, setTab] = React.useState<TabKey>("smtp");
return (
<Stack spacing={3}>
<Tabs value={tab} onChange={(_e, value) => setTab(value)} sx={{ borderBottom: "1px solid", borderColor: "divider" }}>
<Tab value="smtp" label={t("adminSettings.smtp.tabLabel")} />
<Tab value="database" label={t("adminSettings.database.tabLabel")} />
</Tabs>
{tab === "smtp" ? <SmtpTab onUnauthorized={onUnauthorized} /> : <DatabaseTab onUnauthorized={onUnauthorized} />}
</Stack>
);
}
+201
View File
@@ -0,0 +1,201 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import Paper from "@mui/material/Paper";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import Alert from "@mui/material/Alert";
import Chip from "@mui/material/Chip";
import FormControl from "@mui/material/FormControl";
import InputLabel from "@mui/material/InputLabel";
import Select from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import { UnauthorizedError } from "../../api/client";
import { adminApi, type AdminUser, type LogAction, type LogEntry } from "../../api/admin";
type Props = {
onUnauthorized?: () => void;
};
type ActionColor = "success" | "error" | "info" | "warning" | "default";
const ACTION_COLORS: Record<LogAction, ActionColor> = {
user_logged_in: "success",
user_logged_out: "default",
model_uploaded: "info",
model_imported: "info",
import_completed: "info",
model_edited: "warning",
model_deleted: "error",
collection_created: "success",
collection_edited: "warning",
collection_deleted: "error",
};
function isoDateOnly(d: Date): string {
return d.toISOString().slice(0, 10);
}
function defaultFromDate(): string {
const d = new Date();
d.setDate(d.getDate() - 7);
return isoDateOnly(d);
}
/** Admin-only audit-trail viewer -- logins/logouts, uploads, imports, model edits/deletes, and
* collection creates/edits/deletes. Filters mirror the write side's granularity: one entry per
* action, not per file, so a batch import shows as a single "import completed" row (see
* services/importJobRunner.ts) rather than one row per model. */
export default function LogsPage({ onUnauthorized }: Props) {
const { t, i18n } = useTranslation(["app", "common"]);
const [users, setUsers] = React.useState<AdminUser[]>([]);
const [logs, setLogs] = React.useState<LogEntry[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [userId, setUserId] = React.useState("");
const [from, setFrom] = React.useState(defaultFromDate());
const [to, setTo] = React.useState("");
React.useEffect(() => {
adminApi.listUsers().then(setUsers).catch(() => undefined);
}, []);
const loadLogs = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await adminApi.listLogs({
userId: userId || undefined,
from: from || undefined,
to: to || undefined,
});
setLogs(result);
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(t("adminSettings.logs.loadFailed"));
} finally {
setLoading(false);
}
}, [userId, from, to, onUnauthorized, t]);
React.useEffect(() => { void loadLogs(); }, [loadLogs]);
const formatDetails = (log: LogEntry): string => {
const d = log.details;
switch (log.action) {
case "model_uploaded":
case "model_imported":
case "model_deleted":
case "collection_created":
case "collection_edited":
case "collection_deleted":
return typeof d.name === "string" ? d.name : "";
case "model_edited":
return typeof d.field === "string" ? t("adminSettings.logs.editedField", { field: d.field }) : "";
case "import_completed": {
const provider = typeof d.provider === "string" ? d.provider : "";
const label = typeof d.sourceLabel === "string" && d.sourceLabel ? d.sourceLabel : provider;
const imported = typeof d.imported === "number" ? d.imported : 0;
const failed = typeof d.failed === "number" ? d.failed : 0;
return t("adminSettings.logs.importSummary", { label, imported, failed });
}
default:
return "";
}
};
return (
<Stack spacing={3}>
<Paper variant="outlined" sx={{ p: 2 }}>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel id="logs-user-select-label">{t("adminSettings.logs.userLabel")}</InputLabel>
<Select
labelId="logs-user-select-label"
label={t("adminSettings.logs.userLabel")}
value={userId}
onChange={(e) => setUserId(e.target.value)}
>
<MenuItem value="">{t("adminSettings.logs.allUsers")}</MenuItem>
{users.map((u) => (
<MenuItem key={u.id} value={u.id}>{u.display_name} ({u.email})</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
type="date"
label={t("adminSettings.logs.fromLabel")}
value={from}
onChange={(e) => setFrom(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
size="small"
type="date"
label={t("adminSettings.logs.toLabel")}
value={to}
onChange={(e) => setTo(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
</Stack>
</Paper>
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
{loading ? (
<Stack alignItems="center" sx={{ py: 2 }}>
<CircularProgress size={20} />
</Stack>
) : logs.length === 0 ? (
<Paper variant="outlined" sx={{ p: 3 }}>
<Typography variant="body2" color="text.secondary">{t("adminSettings.logs.empty")}</Typography>
</Paper>
) : (
<Paper variant="outlined">
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t("adminSettings.logs.columnDate")}</TableCell>
<TableCell>{t("adminSettings.logs.columnUser")}</TableCell>
<TableCell>{t("adminSettings.logs.columnAction")}</TableCell>
<TableCell>{t("adminSettings.logs.columnDetails")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{logs.map((log) => (
<TableRow key={log.id} hover>
<TableCell>{new Date(log.created_at).toLocaleString(i18n.language)}</TableCell>
<TableCell>
<Box>
<Typography variant="body2">{log.user_display_name}</Typography>
<Typography variant="caption" color="text.secondary">{log.user_email}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={t(`adminSettings.logs.action.${log.action}`)}
size="small"
color={ACTION_COLORS[log.action] ?? "default"}
/>
</TableCell>
<TableCell>{formatDetails(log)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
)}
</Stack>
);
}
@@ -19,18 +19,16 @@ import DialogActions from "@mui/material/DialogActions";
import TextField from "@mui/material/TextField";
import { UnauthorizedError } from "../../api/client";
import { adminApi, type AdminUser } from "../../api/admin";
import SectionHeader from "../../components/SectionHeader";
type Props = {
onUnauthorized?: () => void;
onBack: () => void;
};
/** Admin-only "trigger" actions -- one-off, high-blast-radius operations run against another
* user's data. Today there's a single trigger (delete all of a user's models); this file
* intentionally stays a plain list of Paper sections rather than a generic "trigger registry"
* until there's a second one to justify the abstraction. */
export default function TriggersSection({ onUnauthorized, onBack }: Props) {
export default function TriggersPage({ onUnauthorized }: Props) {
const { t } = useTranslation(["app", "common"]);
const [users, setUsers] = React.useState<AdminUser[]>([]);
const [loading, setLoading] = React.useState(true);
@@ -93,13 +91,6 @@ export default function TriggersSection({ onUnauthorized, onBack }: Props) {
return (
<Stack spacing={3}>
<SectionHeader
title={t("adminSettings.triggers.heading")}
subtitle={t("adminSettings.triggers.subtitle")}
onBack={onBack}
backLabel={t("adminSettings.back")}
/>
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={2}>
<Box>
+102
View File
@@ -0,0 +1,102 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import Paper from "@mui/material/Paper";
import CircularProgress from "@mui/material/CircularProgress";
import Alert from "@mui/material/Alert";
import Chip from "@mui/material/Chip";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import { UnauthorizedError } from "../../api/client";
import { adminApi, type AdminUser } from "../../api/admin";
type Props = {
onUnauthorized?: () => void;
};
/** Admin-only, read-only roster of every account on the instance -- Models/Thingiverse/
* Collections are per-user counts, not toggles, since those are library contents here rather
* than account settings. The "delete all models for a user" action lives on the Settings ->
* Triggers page, not here. */
export default function UsersPage({ onUnauthorized }: Props) {
const { t, i18n } = useTranslation(["app", "common"]);
const [users, setUsers] = React.useState<AdminUser[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
try {
const result = await adminApi.listUsers();
if (!cancelled) setUsers(result);
} catch (err) {
if (cancelled) return;
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(t("adminSettings.users.loadFailed"));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [onUnauthorized, t]);
const formatDate = (iso: string) => new Date(iso).toLocaleDateString(i18n.language);
return (
<Stack spacing={3}>
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
{loading ? (
<Stack alignItems="center" sx={{ py: 2 }}>
<CircularProgress size={20} />
</Stack>
) : (
<Paper variant="outlined">
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t("adminSettings.users.columnEmail")}</TableCell>
<TableCell>{t("adminSettings.users.columnDisplayName")}</TableCell>
<TableCell>{t("adminSettings.users.columnRole")}</TableCell>
<TableCell align="right">{t("adminSettings.users.columnModels")}</TableCell>
<TableCell align="right">{t("adminSettings.users.columnThingiverse")}</TableCell>
<TableCell align="right">{t("adminSettings.users.columnCollections")}</TableCell>
<TableCell>{t("adminSettings.users.columnCreated")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((u) => (
<TableRow key={u.id} hover>
<TableCell>{u.email}</TableCell>
<TableCell>{u.display_name}</TableCell>
<TableCell>
<Chip
label={u.role === "ADMIN" ? t("adminSettings.users.roleAdmin") : t("adminSettings.users.roleMember")}
size="small"
color={u.role === "ADMIN" ? "primary" : "default"}
variant={u.role === "ADMIN" ? "filled" : "outlined"}
/>
</TableCell>
<TableCell align="right">{u.print_count}</TableCell>
<TableCell align="right">{u.thingiverse_count}</TableCell>
<TableCell align="right">{u.collection_count}</TableCell>
<TableCell>{formatDate(u.created_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
)}
</Stack>
);
}
@@ -0,0 +1,77 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Paper from "@mui/material/Paper";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import CircularProgress from "@mui/material/CircularProgress";
import Alert from "@mui/material/Alert";
import Button from "@mui/material/Button";
import Wordmark from "../../components/Wordmark";
import { authApi, type AuthUser } from "../../api/auth";
type Props = {
onSuccess: (token: string, expires_in: number, user: AuthUser) => void;
};
/** Reached from the link in the verification email (see backend's mailer.ts) -- rendered by
* App.tsx in place of AuthPage whenever the pre-login browser is at /verify-email. There's no
* router at that point in the tree (see App.tsx's comment), so this reads `token` straight off
* `window.location` rather than via react-router. On success it logs the user straight in --
* see routes/auth.ts's POST /verify-email, which issues a token the same way /login does. */
export default function VerifyEmailPage({ onSuccess }: Props) {
const { t } = useTranslation("app");
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
const token = new URLSearchParams(window.location.search).get("token");
if (!token) {
setError(t("auth.verifyEmail.missingToken"));
return;
}
(async () => {
try {
const res = await authApi.verifyEmail(token);
window.history.replaceState(null, "", "/");
onSuccess(res.token, res.expires_in, res.user);
} catch (err) {
setError(err instanceof Error ? err.message : t("auth.verifyEmail.failed"));
}
})();
// Runs once against whatever token was in the URL on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Paper
elevation={8}
sx={{
width: "100%",
maxWidth: 420,
borderRadius: 3,
p: 4,
border: "1px solid",
borderColor: "divider",
}}
>
<Stack spacing={3} sx={{ textAlign: "center" }}>
<Box sx={{ display: "flex", justifyContent: "center" }}>
<Wordmark size="lg" />
</Box>
{error ? (
<Stack spacing={2} alignItems="center">
<Alert severity="error" sx={{ width: "100%" }}>{error}</Alert>
<Button variant="contained" onClick={() => { window.history.replaceState(null, "", "/"); window.location.reload(); }}>
{t("auth.verifyEmail.backToSignIn")}
</Button>
</Stack>
) : (
<Stack spacing={2} alignItems="center">
<CircularProgress size={28} />
<Typography variant="body2" color="text.secondary">{t("auth.verifyEmail.verifying")}</Typography>
</Stack>
)}
</Stack>
</Paper>
);
}