mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 06:09:56 -05:00
fix: minor fixes along admin
This commit is contained in:
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;
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user