mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-22 22:52:25 -05:00
feat: admin panel remake
This commit is contained in:
1 parent
fa8ba2f627
commit
2bce5a2049
48 files changed
+1905
-191
No files matched your search
@@ -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>
|
||||
Generated
+21
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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" });
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -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>`,
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+1
-10
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user