mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat: make ttl configurable in admin
This commit is contained in:
1 parent
4f408031b4
commit
6c66e7b615
10 files changed
+315
-14
No files matched your search
+14
-3
@@ -1,17 +1,28 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
import type { Role } from "@prisma/client";
|
||||
import { AUTH_ALGO, AUTH_SECRET, AUTH_TOKEN_TTL } from "./config";
|
||||
import { AUTH_ALGO, AUTH_SECRET } from "./config";
|
||||
import { getAuthTokenTtl } from "./services/settingsService";
|
||||
|
||||
export type TokenPayload = { sub: string; role: Role };
|
||||
|
||||
export function createToken(userId: string, role: Role): string {
|
||||
function createToken(userId: string, role: Role, ttlSeconds: number): string {
|
||||
return jwt.sign({ sub: userId, role }, AUTH_SECRET, {
|
||||
algorithm: AUTH_ALGO,
|
||||
expiresIn: AUTH_TOKEN_TTL,
|
||||
expiresIn: ttlSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
/** Signs a fresh JWT for `userId`, using the instance's current session length (admin-configurable
|
||||
* -- see settingsService.ts's getAuthTokenTtl / AdminSettingsPage's Session section) -- and
|
||||
* returns that same length alongside it so the caller can echo it back as the login response's
|
||||
* `expires_in`. Every route that issues a token (register/login/verify-email/refresh) goes
|
||||
* through this instead of calling createToken directly. */
|
||||
export async function issueToken(userId: string, role: Role): Promise<{ token: string; expiresIn: number }> {
|
||||
const expiresIn = await getAuthTokenTtl();
|
||||
return { token: createToken(userId, role, expiresIn), expiresIn };
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): TokenPayload | null {
|
||||
try {
|
||||
return jwt.verify(token, AUTH_SECRET, { algorithms: [AUTH_ALGO] }) as TokenPayload;
|
||||
|
||||
@@ -37,8 +37,11 @@ export const MODEL_PREVIEWS = path.join(STORAGE, "model-previews");
|
||||
export const PUBLIC_URL = (process.env.PUBLIC_URL || "").trim().replace(/\/+$/, "");
|
||||
|
||||
export const AUTH_SECRET = process.env.AUTH_SECRET || "changeme-secret";
|
||||
export const AUTH_TOKEN_TTL = envInt("AUTH_TOKEN_TTL", 43200);
|
||||
export const AUTH_ALGO = "HS256" as const;
|
||||
// The AUTH_TOKEN_TTL env var itself is read in services/settingsService.ts (getAuthTokenTtl),
|
||||
// not here -- it's only ever consulted as the seed value for a fresh instance's first read, same
|
||||
// as SMTP_HOST/SMTP_PORT/etc. are read directly in that file rather than exported from this one.
|
||||
// Once an admin saves a session length via the Admin Settings page, the DB row wins from then on.
|
||||
|
||||
export const IMPORT_ALLOWED_EXTS = new Set([".stl", ".3mf", ".step", ".stp", ".obj", ".lbrn", ".lbrn2", ".zip"]);
|
||||
// Extensions the 3D viewer can actually render as a Plate -- mirrors frontend's MODEL_EXTS
|
||||
|
||||
@@ -2,8 +2,7 @@ import crypto from "node:crypto";
|
||||
import { Router } from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { AUTH_TOKEN_TTL } from "../config";
|
||||
import { createToken, requireAuth } from "../auth";
|
||||
import { issueToken, requireAuth } from "../auth";
|
||||
import { prisma } from "../db";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { parseBody } from "../utils/validate";
|
||||
@@ -94,8 +93,8 @@ router.post(
|
||||
return;
|
||||
}
|
||||
|
||||
const token = createToken(user.id, user.role);
|
||||
res.json({ token, expires_in: AUTH_TOKEN_TTL, user: toUserOut(user) });
|
||||
const { token, expiresIn } = await issueToken(user.id, user.role);
|
||||
res.json({ token, expires_in: expiresIn, user: toUserOut(user) });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -115,8 +114,8 @@ router.post(
|
||||
throw new HttpError(403, "Please verify your email before signing in.", "EMAIL_NOT_VERIFIED");
|
||||
}
|
||||
|
||||
const token = createToken(user.id, user.role);
|
||||
res.json({ token, expires_in: AUTH_TOKEN_TTL, user: toUserOut(user) });
|
||||
const { token, expiresIn } = await issueToken(user.id, user.role);
|
||||
res.json({ token, expires_in: expiresIn, user: toUserOut(user) });
|
||||
void createLog({ userId: user.id, action: "user_logged_in", details: { email: user.email } });
|
||||
}),
|
||||
);
|
||||
@@ -160,8 +159,8 @@ router.post(
|
||||
// Verifying doubles as signing in -- the user just proved control of the mailbox, and
|
||||
// making them turn around and log in again with a password they only just typed is friction
|
||||
// with no security benefit.
|
||||
const token = createToken(verified.id, verified.role);
|
||||
res.json({ token, expires_in: AUTH_TOKEN_TTL, user: toUserOut(verified) });
|
||||
const { token, expiresIn } = await issueToken(verified.id, verified.role);
|
||||
res.json({ token, expires_in: expiresIn, user: toUserOut(verified) });
|
||||
void createLog({ userId: verified.id, action: "user_logged_in", details: { email: verified.email } });
|
||||
}),
|
||||
);
|
||||
@@ -282,8 +281,8 @@ router.post(
|
||||
asyncHandler(async (req, res) => {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.userId! } });
|
||||
if (!user) throw new HttpError(401, "Invalid or expired token");
|
||||
const token = createToken(user.id, user.role);
|
||||
res.json({ token, expires_in: AUTH_TOKEN_TTL });
|
||||
const { token, expiresIn } = await issueToken(user.id, user.role);
|
||||
res.json({ token, expires_in: expiresIn });
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@ import { parseBody } from "../utils/validate";
|
||||
import { asyncHandler } from "../utils/asyncHandler";
|
||||
import {
|
||||
getAllowRegistrations,
|
||||
getAuthTokenTtl,
|
||||
getPreviewMode,
|
||||
getSmtpSettings,
|
||||
getThingiverseAccessToken,
|
||||
setAllowRegistrations,
|
||||
setAuthTokenTtl,
|
||||
setPreviewMode,
|
||||
setSmtpSettings,
|
||||
setThingiverseAccessToken,
|
||||
@@ -105,6 +107,34 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
// Admin-only both ways, like Storage above -- this affects every session on the instance
|
||||
// (including whoever's editing it), not just the caller's own login.
|
||||
router.get(
|
||||
"/settings/auth",
|
||||
requireAdmin,
|
||||
asyncHandler(async (_req, res) => {
|
||||
res.json({ token_ttl_seconds: await getAuthTokenTtl() });
|
||||
}),
|
||||
);
|
||||
|
||||
// 5 minutes to 1 year -- wide enough to cover any real deployment, narrow enough that a typo
|
||||
// (e.g. an extra zero) still lands somewhere sane instead of "never expires" or "expires
|
||||
// immediately". Only affects tokens issued *after* saving -- see auth.ts's issueToken.
|
||||
const MIN_AUTH_TOKEN_TTL_SECONDS = 5 * 60;
|
||||
const MAX_AUTH_TOKEN_TTL_SECONDS = 365 * 24 * 60 * 60;
|
||||
const authSettingsSchema = z.object({
|
||||
token_ttl_seconds: z.number().int().min(MIN_AUTH_TOKEN_TTL_SECONDS).max(MAX_AUTH_TOKEN_TTL_SECONDS),
|
||||
});
|
||||
router.patch(
|
||||
"/settings/auth",
|
||||
requireAdmin,
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(authSettingsSchema, req.body);
|
||||
await setAuthTokenTtl(body.token_ttl_seconds);
|
||||
res.json({ token_ttl_seconds: body.token_ttl_seconds });
|
||||
}),
|
||||
);
|
||||
|
||||
// Read is open to every user -- LibraryPage needs the current mode to know whether to generate
|
||||
// previews at all. Only admins can change it (see getPreviewMode's instance-wide rationale).
|
||||
router.get(
|
||||
|
||||
@@ -130,6 +130,33 @@ export async function setSmtpSettings(patch: Partial<SmtpSettings>): Promise<Smt
|
||||
return next;
|
||||
}
|
||||
|
||||
const AUTH_TOKEN_TTL_KEY = "auth_token_ttl_seconds";
|
||||
// Matches the old AUTH_TOKEN_TTL env default (12 hours) -- used only when neither a DB row nor
|
||||
// the env var itself is set.
|
||||
const DEFAULT_AUTH_TOKEN_TTL_SECONDS = 43200;
|
||||
|
||||
// How long a signed-in session's token stays valid before requiring another login (see
|
||||
// auth.ts's issueToken, which every login/register/verify route goes through). Seeds from the
|
||||
// AUTH_TOKEN_TTL env var the first time this is read on a fresh instance -- once an admin saves
|
||||
// a value via PATCH /settings/auth (AdminSettingsPage's Session section), the DB row becomes the
|
||||
// sole source of truth, same pattern as SMTP/preview mode/Thingiverse token above. Changing this
|
||||
// only affects tokens issued *after* the change; anyone already signed in keeps whatever TTL was
|
||||
// active when their token was issued, since that's baked into the JWT itself.
|
||||
export async function getAuthTokenTtl(): Promise<number> {
|
||||
const row = await prisma.setting.findUnique({ where: { key: AUTH_TOKEN_TTL_KEY } });
|
||||
const value = row?.value;
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) return Math.trunc(value);
|
||||
return envInt("AUTH_TOKEN_TTL", DEFAULT_AUTH_TOKEN_TTL_SECONDS);
|
||||
}
|
||||
|
||||
export async function setAuthTokenTtl(seconds: number): Promise<void> {
|
||||
await prisma.setting.upsert({
|
||||
where: { key: AUTH_TOKEN_TTL_KEY },
|
||||
create: { key: AUTH_TOKEN_TTL_KEY, value: seconds },
|
||||
update: { value: seconds },
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -13,6 +13,10 @@ export type StorageSettings = {
|
||||
|
||||
export type PreviewMode = "automatic" | "on-demand" | "disabled";
|
||||
|
||||
export type AuthSettings = {
|
||||
token_ttl_seconds: number;
|
||||
};
|
||||
|
||||
export type SmtpSettings = {
|
||||
host: string | null;
|
||||
port: number;
|
||||
@@ -132,6 +136,28 @@ export const settingsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
// Instance-wide: how long a signed-in session's token stays valid before requiring another
|
||||
// login (see backend's auth.ts issueToken). Admin-only both ways, like Storage above -- this
|
||||
// affects every session on the instance, not just the caller's own.
|
||||
getAuth: async (): Promise<AuthSettings> => {
|
||||
const res = await fetch(`${apiBase()}/settings/auth`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to load session settings");
|
||||
return res.json();
|
||||
},
|
||||
|
||||
updateAuth: async (tokenTtlSeconds: number): Promise<AuthSettings> => {
|
||||
const res = await fetch(`${apiBase()}/settings/auth`, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ token_ttl_seconds: tokenTtlSeconds }),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) {
|
||||
throw new Error(await readErrorMessage(res, "Failed to update session settings"));
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
|
||||
// SMTP is used to send the account-verification email on registration (see backend's
|
||||
// routes/auth.ts) -- write-only for the password like the Thingiverse token above.
|
||||
getSmtp: async (): Promise<SmtpSettings> => {
|
||||
|
||||
@@ -317,6 +317,32 @@
|
||||
"cleared": "Access Token removed.",
|
||||
"failed": "Failed to save Thingiverse settings."
|
||||
},
|
||||
"session": {
|
||||
"heading": "Session Length",
|
||||
"subtitle": "Control how long a signed-in session stays valid for the whole instance.",
|
||||
"infoTitle": "What this controls",
|
||||
"infoBody": "How long a freshly issued sign-in token stays valid before that user is asked to log in again. Applies instance-wide to every new login, registration, and email verification. Changing this only affects tokens issued from now on -- anyone already signed in keeps whatever length was active when they logged in.",
|
||||
"tokenTtlLabel": "Session length (seconds)",
|
||||
"tokenTtlHelp": "Between 5 minutes and 1 year.",
|
||||
"invalid": "Enter a number between 300 (5 minutes) and 31536000 (1 year) seconds.",
|
||||
"approx": "≈ {{value}}",
|
||||
"units": {
|
||||
"minutes_one": "{{count}} minute",
|
||||
"minutes_other": "{{count}} minutes",
|
||||
"hours_one": "{{count}} hour",
|
||||
"hours_other": "{{count}} hours",
|
||||
"days_one": "{{count}} day",
|
||||
"days_other": "{{count}} days",
|
||||
"weeks_one": "{{count}} week",
|
||||
"weeks_other": "{{count}} weeks",
|
||||
"months_one": "{{count}} month",
|
||||
"months_other": "{{count}} months"
|
||||
},
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved.",
|
||||
"failed": "Failed to save session settings."
|
||||
},
|
||||
"connections": {
|
||||
"heading": "Connections"
|
||||
},
|
||||
|
||||
@@ -317,6 +317,32 @@
|
||||
"cleared": "Prieigos raktas pašalintas.",
|
||||
"failed": "Nepavyko išsaugoti Thingiverse nustatymų."
|
||||
},
|
||||
"session": {
|
||||
"heading": "Sesijos trukmė",
|
||||
"subtitle": "Valdykite, kiek laiko galioja prisijungimo sesija visai sistemai.",
|
||||
"infoTitle": "Ką tai valdo",
|
||||
"infoBody": "Kiek laiko galioja naujai išduotas prisijungimo žetonas, kol naudotojo bus paprašyta prisijungti iš naujo. Taikoma visai sistemai -- kiekvienam naujam prisijungimui, registracijai ir el. pašto patvirtinimui. Pakeitimas paveikia tik nuo šiol išduodamus žetonus -- jau prisijungę naudotojai išlaiko tą trukmę, kuri galiojo jiems prisijungiant.",
|
||||
"tokenTtlLabel": "Sesijos trukmė (sekundėmis)",
|
||||
"tokenTtlHelp": "Nuo 5 minučių iki 1 metų.",
|
||||
"invalid": "Įveskite skaičių nuo 300 (5 minutės) iki 31536000 (1 metai) sekundžių.",
|
||||
"approx": "≈ {{value}}",
|
||||
"units": {
|
||||
"minutes_one": "{{count}} minutė",
|
||||
"minutes_other": "{{count}} minučių",
|
||||
"hours_one": "{{count}} valanda",
|
||||
"hours_other": "{{count}} valandų",
|
||||
"days_one": "{{count}} diena",
|
||||
"days_other": "{{count}} dienų",
|
||||
"weeks_one": "{{count}} savaitė",
|
||||
"weeks_other": "{{count}} savaičių",
|
||||
"months_one": "{{count}} mėnuo",
|
||||
"months_other": "{{count}} mėnesių"
|
||||
},
|
||||
"save": "Išsaugoti",
|
||||
"saving": "Saugoma…",
|
||||
"saved": "Išsaugota.",
|
||||
"failed": "Nepavyko išsaugoti sesijos nustatymų."
|
||||
},
|
||||
"connections": {
|
||||
"heading": "Ryšiai"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
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 AlertTitle from "@mui/material/AlertTitle";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Button from "@mui/material/Button";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { settingsApi } from "../../api/settings";
|
||||
import SectionHeader from "../../components/SectionHeader";
|
||||
|
||||
type Props = {
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
// Mirrors the backend's own bounds (routes/settings.ts's MIN/MAX_AUTH_TOKEN_TTL_SECONDS) so a
|
||||
// bad value is rejected client-side before it ever reaches the server.
|
||||
const MIN_SECONDS = 5 * 60;
|
||||
const MAX_SECONDS = 365 * 24 * 60 * 60;
|
||||
|
||||
type DurationUnit = "minutes" | "hours" | "days" | "weeks" | "months";
|
||||
const UNIT_SECONDS: Record<DurationUnit, number> = {
|
||||
minutes: 60,
|
||||
hours: 3600,
|
||||
days: 86400,
|
||||
weeks: 604800,
|
||||
months: 2629800, // 30.44 days, i.e. a year / 12 -- close enough for a rough conversion hint
|
||||
};
|
||||
|
||||
/** Picks the largest unit `totalSeconds` amounts to at least one of, e.g. 90000 -> "1 day", not
|
||||
* "1500 minutes" -- so a large TTL always reads as a plain "X hours/days/weeks/months" instead
|
||||
* of forcing the admin to do that arithmetic themselves. Returns null under a minute, where the
|
||||
* raw seconds the admin already typed are conversion enough. */
|
||||
function bestFitDuration(totalSeconds: number): { unit: DurationUnit; value: number } | null {
|
||||
const units: DurationUnit[] = ["months", "weeks", "days", "hours", "minutes"];
|
||||
for (const unit of units) {
|
||||
const size = UNIT_SECONDS[unit];
|
||||
if (totalSeconds >= size) {
|
||||
return { unit, value: Math.round((totalSeconds / size) * 10) / 10 };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Instance-wide: how long a freshly issued sign-in token stays valid before that user has to
|
||||
* log in again (see backend's auth.ts issueToken and getAuthTokenTtl) -- admin-configured like
|
||||
* Storage Structure and Model Previews above it. Seeded from the AUTH_TOKEN_TTL env var the
|
||||
* first time this is ever read on a fresh instance; once saved here, the DB value takes over
|
||||
* for good. Only affects tokens issued after saving -- anyone already signed in keeps whatever
|
||||
* length was active at their own login. */
|
||||
export default function SessionSection({ onUnauthorized }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const [seconds, setSeconds] = React.useState(43200);
|
||||
const [draft, setDraft] = React.useState("43200");
|
||||
const [loading, setLoading] = 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;
|
||||
setLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await settingsApi.getAuth();
|
||||
if (active) {
|
||||
setSeconds(data.token_ttl_seconds);
|
||||
setDraft(String(data.token_ttl_seconds));
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [onUnauthorized]);
|
||||
|
||||
const parsed = Number.parseInt(draft, 10);
|
||||
const isValid = Number.isFinite(parsed) && parsed >= MIN_SECONDS && parsed <= MAX_SECONDS;
|
||||
const preview = isValid ? bestFitDuration(parsed) : null;
|
||||
|
||||
const save = async () => {
|
||||
if (!isValid || saving) return;
|
||||
setSaving(true);
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await settingsApi.updateAuth(parsed);
|
||||
setSeconds(data.token_ttl_seconds);
|
||||
setDraft(String(data.token_ttl_seconds));
|
||||
setStatus(t("adminSettings.session.saved"));
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : t("adminSettings.session.failed"));
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<SectionHeader
|
||||
title={t("adminSettings.session.heading")}
|
||||
subtitle={t("adminSettings.session.subtitle")}
|
||||
/>
|
||||
<Paper variant="outlined" sx={{ p: 2.5 }}>
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="info">
|
||||
<AlertTitle>{t("adminSettings.session.infoTitle")}</AlertTitle>
|
||||
{t("adminSettings.session.infoBody")}
|
||||
</Alert>
|
||||
|
||||
<TextField
|
||||
type="number"
|
||||
label={t("adminSettings.session.tokenTtlLabel")}
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
error={draft !== "" && !isValid}
|
||||
helperText={
|
||||
draft !== "" && !isValid
|
||||
? t("adminSettings.session.invalid")
|
||||
: preview
|
||||
? t("adminSettings.session.approx", { value: t(`adminSettings.session.units.${preview.unit}`, { count: preview.value }) })
|
||||
: t("adminSettings.session.tokenTtlHelp")
|
||||
}
|
||||
disabled={loading || saving}
|
||||
slotProps={{ htmlInput: { min: MIN_SECONDS, max: MAX_SECONDS } }}
|
||||
sx={{ maxWidth: 320 }}
|
||||
/>
|
||||
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Button variant="contained" onClick={save} disabled={loading || saving || !isValid || parsed === seconds}>
|
||||
{saving ? t("adminSettings.session.saving") : t("adminSettings.session.save")}
|
||||
</Button>
|
||||
{saving && <CircularProgress size={14} />}
|
||||
{status && <Typography variant="caption" color="text.secondary">{status}</Typography>}
|
||||
</Stack>
|
||||
|
||||
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { PreviewMode } from "../../api/settings";
|
||||
import StorageSection from "./StorageSection";
|
||||
import PreviewsSection from "./PreviewsSection";
|
||||
import ThingiverseSection from "./ThingiverseSection";
|
||||
import SessionSection from "./SessionSection";
|
||||
|
||||
type Props = {
|
||||
onUnauthorized?: () => void;
|
||||
@@ -21,6 +22,7 @@ export default function AdminSettingsPage({ onUnauthorized, onPreviewModeChanged
|
||||
<ThingiverseSection onUnauthorized={onUnauthorized} />
|
||||
<StorageSection onUnauthorized={onUnauthorized} />
|
||||
<PreviewsSection onUnauthorized={onUnauthorized} onSaved={onPreviewModeChanged} />
|
||||
<SessionSection onUnauthorized={onUnauthorized} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user