fix: minor fixes along admin

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-09 11:24:26 +02:00
1 parent 2bce5a2049
commit 75de79c4bc
20 files changed
+558 -188

No files matched your search

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "makerworldCookie" TEXT;
+4
View File
@@ -24,6 +24,10 @@ model User {
emailVerified Boolean @default(true)
emailVerificationToken String? @unique
emailVerificationExpires DateTime?
// Each user's own MakerWorld session cookie, used when *they* import from MakerWorld -- unlike
// the Thingiverse Access Token (instance-wide, see Setting), MakerWorld imports run as this
// user's own logged-in session. See services/makerworldCookieService.ts.
makerworldCookie String?
folders Folder[]
prints Print[]
collections Collection[]
+22 -1
View File
@@ -6,4 +6,25 @@ for (const dir of [STORAGE, THUMBS, BUNDLES, PREVIEWS, MODEL_PREVIEWS]) {
fs.mkdirSync(dir, { recursive: true });
}
export const prisma = new PrismaClient();
let client = new PrismaClient();
// Every other module does `import { prisma } from "../db"` once and calls methods on it
// directly, so services/databaseSettingsService.ts's live "Test & Save" database switch can't
// just reassign a plain export -- every existing import would keep pointing at the old client
// object. This Proxy forwards every property access to whichever client is current, so the
// swap is invisible to all ~25 files that already import `prisma` this way.
export const prisma = new Proxy({} as PrismaClient, {
get(_target, prop, _receiver) {
return Reflect.get(client as object, prop, client);
},
});
/** Swaps the client every `prisma.*` call resolves to from this point on. Only called by
* databaseSettingsService.ts, and only after it has already verified the new connection works.
* The outgoing client is disconnected in the background -- nothing references it anymore, so a
* failure there doesn't matter. */
export function setActiveClient(next: PrismaClient): void {
const old = client;
client = next;
void old.$disconnect().catch(() => undefined);
}
+1 -1
View File
@@ -21,7 +21,7 @@ router.get(
role: u.role,
print_count: u.printCount,
collection_count: u.collectionCount,
thingiverse_count: u.thingiverseCount,
makerworld_connected: u.makerworldConnected,
created_at: u.createdAt,
})),
);
+16 -5
View File
@@ -20,6 +20,7 @@ import {
parseThingiverseLikesUrl,
} from "../services/thingiverseApi";
import { getThingiverseAccessToken } from "../services/settingsService";
import { getUserMakerworldCookie } from "../services/makerworldCookieService";
import { listZipEntries } from "../services/zipService";
import { createJob, getActiveJob, getJob } from "../services/importJobService";
import {
@@ -44,10 +45,20 @@ const importRequestSchema = z.object({
makerworld_cookie: z.string().nullable().optional(),
});
// The frontend normally sends the browser's own locally-stored MakerWorld cookie on every
// import request (see utils/settings.ts) -- this only kicks in when that's missing (a different
// browser/device, or the request just didn't include one), falling back to whatever the user
// last saved via Settings > Imports (see routes/settings.ts's PATCH /settings/makerworld).
async function withStoredMakerworldCookie<T extends { makerworld_cookie?: string | null }>(userId: string, body: T): Promise<T> {
if (body.makerworld_cookie && body.makerworld_cookie.trim()) return body;
const stored = await getUserMakerworldCookie(userId);
return stored ? { ...body, makerworld_cookie: stored } : body;
}
router.post(
"/import",
asyncHandler(async (req, res) => {
const body = parseBody(importRequestSchema, req.body);
const body = await withStoredMakerworldCookie(req.userId!, parseBody(importRequestSchema, req.body));
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));
@@ -58,7 +69,7 @@ router.post(
router.post(
"/import/inspect",
asyncHandler(async (req, res) => {
const body = parseBody(importRequestSchema, req.body);
const body = await withStoredMakerworldCookie(req.userId!, parseBody(importRequestSchema, req.body));
const url = await normalizeImportUrl(body.url);
const result = await inspectImportLink(url, body);
res.json(result);
@@ -68,7 +79,7 @@ router.post(
router.post(
"/import/zip/entries",
asyncHandler(async (req, res) => {
const body = parseBody(importRequestSchema, req.body);
const body = await withStoredMakerworldCookie(req.userId!, parseBody(importRequestSchema, req.body));
const url = await normalizeImportUrl(body.url);
const { tempPath, filename } = await downloadImportToTemp(url, body);
try {
@@ -85,7 +96,7 @@ router.post(
router.post(
"/import/collection/entries",
asyncHandler(async (req, res) => {
const body = parseBody(importRequestSchema, req.body);
const body = await withStoredMakerworldCookie(req.userId!, parseBody(importRequestSchema, req.body));
const url = await normalizeImportUrl(body.url);
const parsed = parseMakerworldCollectionUrl(url);
if (!parsed) throw new HttpError(400, "Not a MakerWorld collection URL");
@@ -226,7 +237,7 @@ const collectionImportRequestSchema = importRequestSchema.extend({ design_ids: z
router.post(
"/import/collection",
asyncHandler(async (req, res) => {
const body = parseBody(collectionImportRequestSchema, req.body);
const body = await withStoredMakerworldCookie(req.userId!, parseBody(collectionImportRequestSchema, req.body));
await assertNoActiveJob(req.userId!);
const url = await normalizeImportUrl(body.url);
const job = await createJob(req.userId!, "COLLECTION", {
+51 -3
View File
@@ -1,11 +1,11 @@
import { Router } from "express";
import { z } from "zod";
import { requireAdmin, requireAuth } from "../auth";
import { HttpError } from "../utils/fileUtils";
import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import {
getAllowRegistrations,
getDatabaseInfo,
getPreviewMode,
getSmtpSettings,
getThingiverseAccessToken,
@@ -15,6 +15,7 @@ import {
setThingiverseAccessToken,
type SmtpSettings,
} from "../services/settingsService";
import { getDatabaseInfo, testAndSwitchDatabase } from "../services/databaseSettingsService";
import {
DEFAULT_STORAGE_TEMPLATE,
STORAGE_TEMPLATE_TOKENS,
@@ -24,6 +25,7 @@ import {
setStorageTemplate,
validateStorageTemplate,
} from "../services/printService";
import { getUserMakerworldCookie, setUserMakerworldCookie } from "../services/makerworldCookieService";
const router = Router();
router.use(requireAuth);
@@ -128,8 +130,8 @@ 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).
// Admin-only "Connections" page: SMTP (editable, used by registration's email-verification flow
// -- see routes/auth.ts) and Database (editable "Test & Save", see databaseSettingsService.ts).
function smtpSettingsOut(smtp: SmtpSettings) {
// pass is never echoed back, same write-only convention as the Thingiverse token above.
@@ -179,4 +181,50 @@ router.get(
}),
);
// "Test & Save": connects with the candidate credentials and runs a sanity query *before*
// touching anything live -- on failure the app keeps running against whatever database it's
// currently on, same as before the request. Only on success does it hot-swap every `prisma.*`
// call in this process over to the new database. See databaseSettingsService.ts for why this
// does NOT persist across a restart -- host/port can't be changed here either, only
// database/user/password on the same Postgres server this process was started against.
const databaseSettingsSchema = z.object({
database: z.string().trim().min(1, "Database name is required"),
user: z.string().trim().min(1, "User is required"),
password: z.string().min(1, "Password is required"),
});
router.post(
"/settings/database",
requireAdmin,
asyncHandler(async (req, res) => {
const body = parseBody(databaseSettingsSchema, req.body);
let info;
try {
info = await testAndSwitchDatabase(body);
} catch (err) {
throw new HttpError(422, err instanceof Error ? err.message : "Failed to connect to that database.");
}
res.json(info);
}),
);
// Per-user, not admin-only: each user brings their own MakerWorld login for their own imports
// (see services/makerworldCookieService.ts), unlike the instance-wide Thingiverse token above.
// Write-only like the other credentials here -- GET only ever reports whether one is set.
router.get(
"/settings/makerworld",
asyncHandler(async (req, res) => {
res.json({ configured: Boolean(await getUserMakerworldCookie(req.userId!)) });
}),
);
const makerworldSettingsSchema = z.object({ cookie: z.string().nullable() });
router.patch(
"/settings/makerworld",
asyncHandler(async (req, res) => {
const body = parseBody(makerworldSettingsSchema, req.body);
const configured = await setUserMakerworldCookie(req.userId!, body.cookie);
res.json({ configured });
}),
);
export default router;
+14 -24
View File
@@ -14,33 +14,23 @@ export type UserWithPrintCount = {
role: "ADMIN" | "MEMBER";
printCount: number;
collectionCount: number;
thingiverseCount: number;
makerworldConnected: boolean;
createdAt: Date;
};
export async function listUsersWithPrintCounts(): Promise<UserWithPrintCount[]> {
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]));
const users = await prisma.user.findMany({
orderBy: { createdAt: "asc" },
select: {
id: true,
email: true,
displayName: true,
role: true,
createdAt: true,
makerworldCookie: true,
_count: { select: { prints: true, collections: true } },
},
});
return users.map((u) => ({
id: u.id,
email: u.email,
@@ -49,7 +39,7 @@ export async function listUsersWithPrintCounts(): Promise<UserWithPrintCount[]>
createdAt: u.createdAt,
printCount: u._count.prints,
collectionCount: u._count.collections,
thingiverseCount: thingiverseByUser.get(u.id) ?? 0,
makerworldConnected: Boolean(u.makerworldCookie),
}));
}
@@ -0,0 +1,97 @@
import { PrismaClient } from "@prisma/client";
import { setActiveClient } from "../db";
export type PostgresCredentials = {
database: string;
user: string;
password: string;
};
export type DatabaseInfo = {
host: string | null;
port: number | null;
database: string | null;
user: string | null;
};
type ParsedUrl = { host: string; port: string; database: string; user: string; password: string };
function parseDatabaseUrl(raw: string): ParsedUrl | null {
try {
const url = new URL(raw);
return {
host: url.hostname,
port: url.port,
database: url.pathname.replace(/^\//, ""),
user: decodeURIComponent(url.username),
password: decodeURIComponent(url.password),
};
} catch {
return null;
}
}
// Host/port are frozen at process start -- never admin-editable. "Test & Save" below can only
// repoint at a different database/user/password on the SAME Postgres server this process was
// started against; reaching a different host/port means changing DATABASE_URL and restarting,
// same as any other env-derived setting in this app.
const original = parseDatabaseUrl(process.env.DATABASE_URL || "");
// In-memory only, not persisted -- a successful "Test & Save" swap affects this running process
// for as long as it keeps running, but does NOT survive a restart/redeploy. A restart always
// boots from DATABASE_URL as configured in the environment, exactly as it did before this
// feature existed. Persisting the override so it also survived a restart would need to write it
// into whatever database ends up live, but a fresh boot reads DATABASE_URL before it can know to
// look anywhere else -- a chicken-and-egg the process can't resolve on its own. If a switch made
// here should stick, update DATABASE_URL itself.
let active: PostgresCredentials | null = original
? { database: original.database, user: original.user, password: original.password }
: null;
export function getDatabaseInfo(): DatabaseInfo {
return {
host: original?.host ?? null,
port: original?.port ? Number(original.port) : null,
database: active?.database ?? null,
user: active?.user ?? null,
};
}
function buildCandidateUrl(creds: PostgresCredentials): string | null {
if (!original) return null;
const auth = `${encodeURIComponent(creds.user)}:${encodeURIComponent(creds.password)}`;
return `postgresql://${auth}@${original.host}:${original.port}/${creds.database}?schema=public`;
}
function friendlyConnectionError(err: unknown): string {
const message = err instanceof Error ? err.message : String(err);
if (/password authentication failed/i.test(message)) return "Authentication failed for that user/password.";
if (/database .* does not exist/i.test(message)) return "That database does not exist.";
if (/relation .?"?User"?.? does not exist/i.test(message)) {
return "Connected, but that database has no PrintStash tables yet -- run migrations against it first.";
}
return `Could not connect: ${message}`;
}
/** Tests a candidate database/user/password against the live Postgres server (same host/port
* this process was started with) before touching anything -- only on success does it hot-swap
* every `prisma.*` call in the running process over to it. Mirrors youtube-mp3-vault's
* "Test & Save" Postgres tab; see the module comment above for why the switch is in-memory only,
* not persisted across a restart. */
export async function testAndSwitchDatabase(creds: PostgresCredentials): Promise<DatabaseInfo> {
const url = buildCandidateUrl(creds);
if (!url) throw new Error("DATABASE_URL is not set for this instance -- nothing to switch relative to.");
const candidate = new PrismaClient({ datasources: { db: { url } } });
try {
await candidate.$connect();
await candidate.$queryRawUnsafe('SELECT 1 FROM "User" LIMIT 1');
} catch (err) {
await candidate.$disconnect().catch(() => undefined);
throw new Error(friendlyConnectionError(err), { cause: err });
}
setActiveClient(candidate);
active = creds;
return getDatabaseInfo();
}
@@ -0,0 +1,19 @@
import { prisma } from "../db";
/** Per-user MakerWorld session cookie -- unlike the Thingiverse Access Token (instance-wide,
* admin-configured, see settingsService.ts), a MakerWorld import runs as *that user's own*
* MakerWorld login, so each user brings their own cookie. Stored directly on User rather than
* the generic Setting table since it's scoped to one user, not the whole instance. Read by
* routes/imports.ts as a fallback whenever a request doesn't carry its own makerworld_cookie
* (see withStoredMakerworldCookie there), and by adminService.ts to show connection status in
* the admin Users table. */
export async function getUserMakerworldCookie(userId: string): Promise<string | null> {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { makerworldCookie: true } });
return user?.makerworldCookie ?? null;
}
export async function setUserMakerworldCookie(userId: string, cookie: string | null): Promise<boolean> {
const trimmed = (cookie ?? "").trim();
await prisma.user.update({ where: { id: userId }, data: { makerworldCookie: trimmed || null } });
return Boolean(trimmed);
}
+3 -27
View File
@@ -130,30 +130,6 @@ export async function setSmtpSettings(patch: Partial<SmtpSettings>): Promise<Smt
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 };
}
}
// Database connection info + the live "Test & Save" switch live in databaseSettingsService.ts,
// not here -- unlike everything else in this file, it isn't just a Setting-table row; it needs
// db.ts's client-swap primitive and its own Postgres-specific connection testing.
+1 -1
View File
@@ -86,7 +86,7 @@ describe("admin user management", () => {
expect(member).toBeTruthy();
expect(member.print_count).toBe(2);
expect(member.collection_count).toBe(0);
expect(member.thingiverse_count).toBe(0);
expect(member.makerworld_connected).toBe(false);
expect(typeof member.created_at).toBe("string");
});
+1 -1
View File
@@ -8,7 +8,7 @@ export type AdminUser = {
role: "ADMIN" | "MEMBER";
print_count: number;
collection_count: number;
thingiverse_count: number;
makerworld_connected: boolean;
created_at: string;
};
+48 -2
View File
@@ -33,13 +33,18 @@ export type SmtpSettingsInput = {
};
export type DatabaseInfo = {
provider: string;
host: string | null;
port: number | null;
database: string | null;
user: string | null;
};
export type DatabaseCredentialsInput = {
database: string;
user: string;
password: string;
};
export const settingsApi = {
getStorage: async (): Promise<StorageSettings> => {
const res = await fetch(`${apiBase()}/settings/storage`, { headers: authHeaders() });
@@ -127,10 +132,51 @@ export const settingsApi = {
return res.json();
},
// Read-only: always reflects the live DATABASE_URL the backend process was started with.
// Host/port always reflect the live DATABASE_URL the backend process was started with --
// database/user reflect whatever's currently active, which a "Test & Save" switch below may
// have changed for this running process.
getDatabase: async (): Promise<DatabaseInfo> => {
const res = await fetch(`${apiBase()}/settings/database`, { headers: authHeaders() });
assertOk(res, "Failed to load database info");
return res.json();
},
// Tests the candidate database/user/password against the live Postgres server before applying
// anything -- see backend's databaseSettingsService.ts. On success, hot-swaps every database
// call in the running backend process over to it; does NOT persist across a restart.
testAndSaveDatabase: async (payload: DatabaseCredentialsInput): Promise<DatabaseInfo> => {
const res = await fetch(`${apiBase()}/settings/database`, {
method: "POST",
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 switch database"));
}
return res.json();
},
// Per-user (not admin-only, unlike Thingiverse above): each user's own MakerWorld session
// cookie, saved server-side so it (a) follows them across devices and (b) makes "connected"
// a real fact the admin Users table can show -- see services/makerworldCookieService.ts.
// Write-only like the other credentials here.
getMakerworld: async (): Promise<{ configured: boolean }> => {
const res = await fetch(`${apiBase()}/settings/makerworld`, { headers: authHeaders() });
assertOk(res, "Failed to load MakerWorld settings");
return res.json();
},
updateMakerworld: async (cookie: string | null): Promise<{ configured: boolean }> => {
const res = await fetch(`${apiBase()}/settings/makerworld`, {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ cookie }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) {
throw new Error(await readErrorMessage(res, "Failed to update MakerWorld settings"));
}
return res.json();
},
};
+17 -6
View File
@@ -213,9 +213,10 @@
"rootFolderCreateFailed": "Failed to create the top-level folder.",
"makerworldHeading": "MakerWorld",
"makerworldDesc": "Optional auth for importing MakerWorld models.",
"makerworldHelp": "Paste the Cookie header from a logged-in makerworld.com request to lift download limits. This value is stored only in your browser.",
"makerworldHelp": "Paste the Cookie header from a logged-in makerworld.com request to lift download limits. Saved to your account, so it follows you across devices -- not just this browser.",
"sessionCookieLabel": "Session cookie",
"makerworldPlaceholder": "example: mw_session=...; mw_token=...;"
"makerworldPlaceholder": "example: mw_session=...; mw_token=...;",
"makerworldSyncFailed": "Saved for this browser, but couldn't sync it to your account -- try again shortly."
},
"language": {
"heading": "Interface Language",
@@ -347,14 +348,22 @@
},
"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",
"helpText": "Host and port always reflect the DATABASE_URL this instance was started with and can't be changed here. Database, user, and password can be switched live — the new credentials are tested before anything is applied, so a typo can't take the app down, but the switch only affects this running process and won't survive a restart.",
"hostLabel": "Host",
"portLabel": "Port",
"nameLabel": "Database",
"userLabel": "User",
"passwordLabel": "Password",
"passwordPlaceholder": "Required to test and apply a switch",
"unset": "—",
"loadFailed": "Unable to load database info."
"loadFailed": "Unable to load database info.",
"testAndSave": "Test & Save",
"saving": "Testing…",
"saved": "Connected and switched.",
"failed": "Failed to switch database.",
"confirmTitle": "Switch the live database?",
"confirmWarning": "This repoints every database call this running instance makes at a different database. If the new credentials are wrong or unreachable, the switch is rejected and nothing changes.",
"confirmBody": "Switch to database \"{{database}}\" as user \"{{user}}\"? This does not persist across a restart — update DATABASE_URL if you want it to stick."
},
"users": {
"heading": "Users",
@@ -362,11 +371,13 @@
"columnDisplayName": "Display name",
"columnRole": "Role",
"columnModels": "Models",
"columnThingiverse": "Thingiverse",
"columnMakerworld": "MakerWorld",
"columnCollections": "Collections",
"columnCreated": "Created",
"roleAdmin": "Admin",
"roleMember": "Member",
"makerworldConnected": "Connected",
"makerworldNotConnected": "Not connected",
"loadFailed": "Unable to load users."
},
"logs": {
+17 -6
View File
@@ -213,9 +213,10 @@
"rootFolderCreateFailed": "Nepavyko sukurti aukščiausio lygio aplanko.",
"makerworldHeading": "MakerWorld",
"makerworldDesc": "Neprivaloma autentifikacija MakerWorld modelių importui.",
"makerworldHelp": "Įklijuokite Cookie antraštę iš prisijungusio makerworld.com užklausos, kad panaikintumėte atsisiuntimo limitus. Ši reikšmė saugoma tik jūsų naršyklėje.",
"makerworldHelp": "Įklijuokite Cookie antraštę iš prisijungusio makerworld.com užklausos, kad panaikintumėte atsisiuntimo limitus. Išsaugoma jūsų paskyroje, todėl seka jus tarp įrenginių -- ne tik šioje naršyklėje.",
"sessionCookieLabel": "Sesijos slapukas",
"makerworldPlaceholder": "pavyzdys: mw_session=...; mw_token=...;"
"makerworldPlaceholder": "pavyzdys: mw_session=...; mw_token=...;",
"makerworldSyncFailed": "Išsaugota šiai naršyklei, bet nepavyko sinchronizuoti su jūsų paskyra -- pabandykite dar kartą netrukus."
},
"language": {
"heading": "Sąsajos kalba",
@@ -347,14 +348,22 @@
},
"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",
"helpText": "Serveris ir prievadas visada rodo DATABASE_URL reikšmę, su kuria paleistas šis serveris, ir čia jų pakeisti negalima. Duomenų bazę, naudotoją ir slaptažodį galima keisti gyvai — nauji duomenys patikrinami prieš juos pritaikant, todėl klaida neišjungs programos, tačiau pakeitimas galioja tik šiam veikiančiam procesui ir neišliks po perkrovimo.",
"hostLabel": "Serveris",
"portLabel": "Prievadas",
"nameLabel": "Duomenų bazė",
"userLabel": "Naudotojas",
"passwordLabel": "Slaptažodis",
"passwordPlaceholder": "Reikalingas norint patikrinti ir pritaikyti pakeitimą",
"unset": "—",
"loadFailed": "Nepavyko įkelti duomenų bazės informacijos."
"loadFailed": "Nepavyko įkelti duomenų bazės informacijos.",
"testAndSave": "Patikrinti ir išsaugoti",
"saving": "Tikrinama…",
"saved": "Prisijungta ir perjungta.",
"failed": "Nepavyko perjungti duomenų bazės.",
"confirmTitle": "Perjungti veikiančią duomenų bazę?",
"confirmWarning": "Tai nukreipia visus šio veikiančio serverio duomenų bazės užklausas į kitą duomenų bazę. Jei nauji duomenys neteisingi arba nepasiekiami, pakeitimas atmetamas ir niekas nesikeičia.",
"confirmBody": "Perjungti į duomenų bazę „{{database}}“ naudotoju „{{user}}“? Tai neišlieka po perkrovimo — jei norite, kad pakeitimas išliktų, atnaujinkite DATABASE_URL."
},
"users": {
"heading": "Naudotojai",
@@ -362,11 +371,13 @@
"columnDisplayName": "Rodomas vardas",
"columnRole": "Vaidmuo",
"columnModels": "Modeliai",
"columnThingiverse": "Thingiverse",
"columnMakerworld": "MakerWorld",
"columnCollections": "Kolekcijos",
"columnCreated": "Sukurta",
"roleAdmin": "Administratorius",
"roleMember": "Narys",
"makerworldConnected": "Prijungta",
"makerworldNotConnected": "Neprijungta",
"loadFailed": "Nepavyko įkelti naudotojų."
},
"logs": {
@@ -2,6 +2,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
import Radio from "@mui/material/Radio";
import RadioGroup from "@mui/material/RadioGroup";
@@ -88,51 +89,55 @@ export default function PreviewsSection({ onUnauthorized, onSaved }: Props) {
title={t("adminSettings.previews.heading")}
subtitle={t("adminSettings.previews.subtitle")}
/>
<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 => {
const selected = mode === option.id;
return (
<FormControlLabel
key={option.id}
value={option.id}
control={<Radio />}
disabled={loading || saving}
sx={{
alignItems: "flex-start",
m: 0,
borderRadius: 2,
border: "1px solid",
borderColor: selected ? "primary.main" : "divider",
bgcolor: selected ? "action.selected" : "transparent",
p: 2,
}}
label={
<Box>
<Typography variant="body2" fontWeight={600}>{option.label}</Typography>
<Typography variant="caption" color="text.secondary">{option.description}</Typography>
</Box>
}
/>
);
})}
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={2}>
<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 => {
const selected = mode === option.id;
return (
<FormControlLabel
key={option.id}
value={option.id}
control={<Radio />}
disabled={loading || saving}
sx={{
alignItems: "flex-start",
m: 0,
borderRadius: 2,
border: "1px solid",
borderColor: selected ? "primary.main" : "divider",
bgcolor: selected ? "action.selected" : "transparent",
p: 2,
}}
label={
<Box>
<Typography variant="body2" fontWeight={600}>{option.label}</Typography>
<Typography variant="caption" color="text.secondary">{option.description}</Typography>
</Box>
}
/>
);
})}
</Stack>
</RadioGroup>
{(saving || status) && (
<Stack direction="row" alignItems="center" spacing={1}>
{saving && <CircularProgress size={14} />}
{status && <Typography variant="caption" color="text.secondary">{status}</Typography>}
</Stack>
)}
</Stack>
</RadioGroup>
{(saving || status) && (
<Stack direction="row" alignItems="center" spacing={1}>
{saving && <CircularProgress size={14} />}
{status && <Typography variant="caption" color="text.secondary">{status}</Typography>}
</Stack>
)}
</Paper>
</Stack>
);
}
@@ -1,6 +1,7 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import Paper from "@mui/material/Paper";
import Alert from "@mui/material/Alert";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
@@ -92,41 +93,45 @@ export default function ThingiverseSection({ onUnauthorized }: Props) {
subtitle={t("adminSettings.thingiverse.subtitle")}
/>
<Typography variant="caption" color="text.secondary">
{t("adminSettings.thingiverse.helpText")}
</Typography>
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={2}>
<Typography variant="caption" color="text.secondary">
{t("adminSettings.thingiverse.helpText")}
</Typography>
<Alert severity={configured ? "success" : "warning"}>
{configured
? t("adminSettings.thingiverse.statusConfigured")
: t("adminSettings.thingiverse.statusNotConfigured")}
</Alert>
<Alert severity={configured ? "success" : "warning"}>
{configured
? t("adminSettings.thingiverse.statusConfigured")
: t("adminSettings.thingiverse.statusNotConfigured")}
</Alert>
<TextField
label={t("adminSettings.thingiverse.tokenLabel")}
value={draft}
onChange={e => setDraft(e.target.value)}
placeholder={t("adminSettings.thingiverse.tokenPlaceholder") ?? undefined}
helperText={configured ? t("adminSettings.thingiverse.tokenHelperConfigured") : undefined}
disabled={loading || saving}
fullWidth
autoComplete="off"
/>
<TextField
label={t("adminSettings.thingiverse.tokenLabel")}
value={draft}
onChange={e => setDraft(e.target.value)}
placeholder={t("adminSettings.thingiverse.tokenPlaceholder") ?? undefined}
helperText={configured ? t("adminSettings.thingiverse.tokenHelperConfigured") : undefined}
disabled={loading || saving}
fullWidth
autoComplete="off"
/>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
<Button variant="contained" onClick={save} disabled={loading || saving || !draft.trim()}>
{saving ? t("adminSettings.thingiverse.saving") : t("adminSettings.thingiverse.save")}
</Button>
{configured && (
<Button variant="outlined" color="error" onClick={clear} disabled={loading || saving}>
{t("adminSettings.thingiverse.clear")}
</Button>
)}
{saving && <CircularProgress size={14} />}
</Stack>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
<Button variant="contained" onClick={save} disabled={loading || saving || !draft.trim()}>
{saving ? t("adminSettings.thingiverse.saving") : t("adminSettings.thingiverse.save")}
</Button>
{configured && (
<Button variant="outlined" color="error" onClick={clear} disabled={loading || saving}>
{t("adminSettings.thingiverse.clear")}
</Button>
)}
{saving && <CircularProgress size={14} />}
</Stack>
{status && <Alert severity="success">{status}</Alert>}
{error && <Alert severity="error">{error}</Alert>}
{status && <Alert severity="success">{status}</Alert>}
{error && <Alert severity="error">{error}</Alert>}
</Stack>
</Paper>
</Stack>
);
}
@@ -3,8 +3,15 @@ import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import Alert from "@mui/material/Alert";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogActions from "@mui/material/DialogActions";
import { UnauthorizedError } from "../../api/client";
import { settingsApi, type DatabaseInfo } from "../../api/settings";
@@ -21,35 +28,61 @@ function Row({ label, value }: { label: string; value: string }) {
);
}
/** 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. */
/** Host/port always reflect the live DATABASE_URL this process was started with -- not editable
* here, since reaching a different server means changing that env var and restarting. Database/
* user/password can be switched live: "Test & Save" connects with the candidate credentials and
* runs a sanity query before applying anything, so a typo can't take the app down. The switch
* only affects this running process -- it does not survive a restart (see backend's
* databaseSettingsService.ts for why). */
export default function DatabaseTab({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
const { t } = useTranslation(["app", "common"]);
const [info, setInfo] = React.useState<DatabaseInfo | null>(null);
const [loading, setLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState<string | null>(null);
const [database, setDatabase] = React.useState("");
const [user, setUser] = React.useState("");
const [password, setPassword] = React.useState("");
const [confirmOpen, setConfirmOpen] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [status, setStatus] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let active = true;
const load = React.useCallback(async () => {
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; };
try {
const data = await settingsApi.getDatabase();
setInfo(data);
setDatabase(data.database ?? "");
setUser(data.user ?? "");
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setLoadError(t("adminSettings.database.loadFailed"));
} finally {
setLoading(false);
}
}, [onUnauthorized, t]);
React.useEffect(() => { void load(); }, [load]);
const confirmSwitch = async () => {
setConfirmOpen(false);
setSaving(true);
setStatus(null);
setError(null);
try {
const next = await settingsApi.testAndSaveDatabase({ database: database.trim(), user: user.trim(), password });
setInfo(next);
setPassword("");
setStatus(t("adminSettings.database.saved"));
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : t("adminSettings.database.failed"));
} finally {
setSaving(false);
}
};
if (loading) {
return (
<Stack alignItems="center" sx={{ py: 4 }}>
@@ -58,26 +91,85 @@ export default function DatabaseTab({ onUnauthorized }: Props) {
);
}
if (error || !info) {
return <Alert severity="error">{error || t("adminSettings.database.loadFailed")}</Alert>;
if (loadError || !info) {
return <Alert severity="error">{loadError || t("adminSettings.database.loadFailed")}</Alert>;
}
const unset = t("adminSettings.database.unset");
const isDirty = database.trim() !== (info.database ?? "") || user.trim() !== (info.user ?? "") || password.trim() !== "";
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 spacing={2}>
<Stack spacing={1.5}>
<Row label={t("adminSettings.database.hostLabel")} value={info.host ?? unset} />
<Row label={t("adminSettings.database.portLabel")} value={info.port ? String(info.port) : unset} />
</Stack>
<TextField
label={t("adminSettings.database.nameLabel")}
value={database}
onChange={e => setDatabase(e.target.value)}
disabled={saving}
fullWidth
/>
<TextField
label={t("adminSettings.database.userLabel")}
value={user}
onChange={e => setUser(e.target.value)}
disabled={saving}
fullWidth
autoComplete="off"
/>
<TextField
type="password"
label={t("adminSettings.database.passwordLabel")}
value={password}
onChange={e => setPassword(e.target.value)}
placeholder={t("adminSettings.database.passwordPlaceholder") ?? undefined}
disabled={saving}
fullWidth
autoComplete="new-password"
/>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap">
<Button
variant="contained"
onClick={() => setConfirmOpen(true)}
disabled={saving || !isDirty || !database.trim() || !user.trim() || !password.trim()}
>
{saving ? t("adminSettings.database.saving") : t("adminSettings.database.testAndSave")}
</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>
</Paper>
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{t("adminSettings.database.confirmTitle")}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 0.5 }}>
<Alert severity="warning">{t("adminSettings.database.confirmWarning")}</Alert>
<DialogContentText>
{t("adminSettings.database.confirmBody", { database: database.trim(), user: user.trim() })}
</DialogContentText>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmOpen(false)}>{t("common:cancel")}</Button>
<Button variant="contained" color="warning" onClick={confirmSwitch}>
{t("adminSettings.database.testAndSave")}
</Button>
</DialogActions>
</Dialog>
</Stack>
);
}
@@ -1,7 +1,10 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import Alert from "@mui/material/Alert";
import type { MakerWorldSettings } from "../../../utils/settings";
import { settingsApi } from "../../../api/settings";
import { UnauthorizedError } from "../../../api/client";
import SectionHeader from "../../../components/SectionHeader";
import FolderScanPanel from "./FolderScanPanel";
import CookiePanel from "./CookiePanel";
@@ -30,6 +33,26 @@ export default function ImportsSection({
onBack,
}: Props) {
const { t } = useTranslation("app");
const [syncError, setSyncError] = React.useState<string | null>(null);
// Saves locally (unchanged -- still what every import request in this browser actually sends)
// and syncs to the backend so it (a) follows this user to other devices/browsers and (b) makes
// the admin Users table's "MakerWorld: Connected" column a real fact instead of always false.
const handleSaveMakerworldCookie = async (cookie: string) => {
onUpdateMakerWorld({ cookie });
setSyncError(null);
try {
await settingsApi.updateMakerworld(cookie.trim() || null);
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
// Best-effort: this browser's own imports still work off the local copy either way.
setSyncError(t("settings.imports.makerworldSyncFailed"));
}
};
return (
<Stack spacing={3}>
<SectionHeader
@@ -48,12 +71,13 @@ export default function ImportsSection({
<CookiePanel
cookie={makerworldCookie}
onSave={cookie => onUpdateMakerWorld({ cookie })}
onSave={handleSaveMakerworldCookie}
headingText={t("settings.imports.makerworldHeading")}
descText={t("settings.imports.makerworldDesc")}
helpText={t("settings.imports.makerworldHelp")}
placeholderText={t("settings.imports.makerworldPlaceholder") ?? undefined}
/>
{syncError && <Alert severity="warning" onClose={() => setSyncError(null)}>{syncError}</Alert>}
</Stack>
);
}
+14 -6
View File
@@ -18,10 +18,11 @@ 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. */
/** Admin-only, read-only roster of every account on the instance. Models/Collections are
* per-user counts; MakerWorld is a connected/not-connected flag (each user's own MakerWorld
* session cookie, saved server-side from Settings -> Imports -- see
* services/makerworldCookieService.ts). The "delete all models for a user" action lives on the
* Settings -> Triggers page, not here. */
export default function UsersPage({ onUnauthorized }: Props) {
const { t, i18n } = useTranslation(["app", "common"]);
const [users, setUsers] = React.useState<AdminUser[]>([]);
@@ -68,7 +69,7 @@ export default function UsersPage({ onUnauthorized }: Props) {
<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>{t("adminSettings.users.columnMakerworld")}</TableCell>
<TableCell align="right">{t("adminSettings.users.columnCollections")}</TableCell>
<TableCell>{t("adminSettings.users.columnCreated")}</TableCell>
</TableRow>
@@ -87,7 +88,14 @@ export default function UsersPage({ onUnauthorized }: Props) {
/>
</TableCell>
<TableCell align="right">{u.print_count}</TableCell>
<TableCell align="right">{u.thingiverse_count}</TableCell>
<TableCell>
<Chip
label={u.makerworld_connected ? t("adminSettings.users.makerworldConnected") : t("adminSettings.users.makerworldNotConnected")}
size="small"
color={u.makerworld_connected ? "success" : "default"}
variant={u.makerworld_connected ? "filled" : "outlined"}
/>
</TableCell>
<TableCell align="right">{u.collection_count}</TableCell>
<TableCell>{formatDate(u.created_at)}</TableCell>
</TableRow>