admin dashboard
This commit is contained in:
+75
-43
@@ -6,7 +6,7 @@ import bcrypt from "bcrypt";
|
|||||||
import jwt, { SignOptions } from "jsonwebtoken";
|
import jwt, { SignOptions } from "jsonwebtoken";
|
||||||
import ms, { type StringValue } from "ms";
|
import ms, { type StringValue } from "ms";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PrismaClient } from "./generated/client";
|
import { PrismaClient, Prisma } from "./generated/client";
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
import { requireAuth, optionalAuth } from "./middleware/auth";
|
import { requireAuth, optionalAuth } from "./middleware/auth";
|
||||||
import { sanitizeText } from "./security";
|
import { sanitizeText } from "./security";
|
||||||
@@ -319,6 +319,30 @@ const resolveExpiresAt = (expiresIn: string, fallbackMs: number): Date => {
|
|||||||
return new Date(Date.now() + ttlMs);
|
return new Date(Date.now() + ttlMs);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
class HttpError extends Error {
|
||||||
|
statusCode: number;
|
||||||
|
|
||||||
|
constructor(statusCode: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMissingRefreshTokenTableError = (error: unknown): boolean => {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
// P2021 = "The table `<table>` does not exist in the current database."
|
||||||
|
if (error.code === "P2021") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
typeof error === "object" && error && "message" in error
|
||||||
|
? String((error as any).message)
|
||||||
|
: "";
|
||||||
|
return /no such table:\s*RefreshToken/i.test(message);
|
||||||
|
};
|
||||||
|
|
||||||
const getRefreshTokenExpiresAt = (): Date =>
|
const getRefreshTokenExpiresAt = (): Date =>
|
||||||
resolveExpiresAt(config.jwtRefreshExpiresIn, 7 * 24 * 60 * 60 * 1000);
|
resolveExpiresAt(config.jwtRefreshExpiresIn, 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
@@ -730,37 +754,6 @@ router.post("/refresh", async (req: Request, res: Response) => {
|
|||||||
// If refresh token rotation is enabled, check database and rotate
|
// If refresh token rotation is enabled, check database and rotate
|
||||||
if (config.enableRefreshTokenRotation) {
|
if (config.enableRefreshTokenRotation) {
|
||||||
try {
|
try {
|
||||||
// Check if refresh token exists in database and is not revoked
|
|
||||||
const storedToken = await prisma.refreshToken.findUnique({
|
|
||||||
where: { token: oldRefreshToken },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!storedToken || storedToken.revoked || storedToken.userId !== user.id) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: "Unauthorized",
|
|
||||||
message: "Invalid or revoked refresh token",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if token has expired
|
|
||||||
if (new Date() > storedToken.expiresAt) {
|
|
||||||
// Mark as revoked
|
|
||||||
await prisma.refreshToken.update({
|
|
||||||
where: { id: storedToken.id },
|
|
||||||
data: { revoked: true },
|
|
||||||
});
|
|
||||||
return res.status(401).json({
|
|
||||||
error: "Unauthorized",
|
|
||||||
message: "Refresh token has expired",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revoke old refresh token
|
|
||||||
await prisma.refreshToken.update({
|
|
||||||
where: { id: storedToken.id },
|
|
||||||
data: { revoked: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate new tokens (rotation)
|
// Generate new tokens (rotation)
|
||||||
const { accessToken, refreshToken: newRefreshToken } = generateTokens(
|
const { accessToken, refreshToken: newRefreshToken } = generateTokens(
|
||||||
user.id,
|
user.id,
|
||||||
@@ -768,15 +761,37 @@ router.post("/refresh", async (req: Request, res: Response) => {
|
|||||||
{ impersonatorId: decoded.impersonatorId }
|
{ impersonatorId: decoded.impersonatorId }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store new refresh token
|
|
||||||
const expiresAt = getRefreshTokenExpiresAt();
|
const expiresAt = getRefreshTokenExpiresAt();
|
||||||
|
|
||||||
await prisma.refreshToken.create({
|
await prisma.$transaction(async (tx) => {
|
||||||
data: {
|
const storedToken = await tx.refreshToken.findUnique({
|
||||||
userId: user.id,
|
where: { token: oldRefreshToken },
|
||||||
token: newRefreshToken,
|
});
|
||||||
expiresAt,
|
|
||||||
},
|
if (!storedToken || storedToken.userId !== user.id || storedToken.revoked) {
|
||||||
|
throw new HttpError(401, "Invalid or revoked refresh token");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new Date() > storedToken.expiresAt) {
|
||||||
|
throw new HttpError(401, "Refresh token has expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke old token first; if anything fails after this, the transaction rolls back.
|
||||||
|
const revoked = await tx.refreshToken.updateMany({
|
||||||
|
where: { id: storedToken.id, revoked: false },
|
||||||
|
data: { revoked: true },
|
||||||
|
});
|
||||||
|
if (revoked.count !== 1) {
|
||||||
|
throw new HttpError(401, "Invalid or revoked refresh token");
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.refreshToken.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
token: newRefreshToken,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -784,11 +799,28 @@ router.post("/refresh", async (req: Request, res: Response) => {
|
|||||||
refreshToken: newRefreshToken,
|
refreshToken: newRefreshToken,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If table doesn't exist (feature disabled), fall back to old behavior
|
if (error instanceof HttpError) {
|
||||||
if (process.env.NODE_ENV === "development") {
|
return res.status(error.statusCode).json({
|
||||||
console.debug("Refresh token rotation skipped (feature disabled or table missing)");
|
error: "Unauthorized",
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If table doesn't exist (feature disabled / migration not applied), fall back.
|
||||||
|
if (isMissingRefreshTokenTableError(error)) {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.debug(
|
||||||
|
"Refresh token rotation skipped (feature disabled or table missing)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Fall through to old behavior below
|
||||||
|
} else {
|
||||||
|
console.error("Refresh token rotation error:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: "Internal server error",
|
||||||
|
message: "Failed to rotate refresh token",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Fall through to old behavior below
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ app.use(
|
|||||||
cors({
|
cors({
|
||||||
origin: (origin, cb) => cb(null, isAllowedOrigin(origin ?? undefined)),
|
origin: (origin, cb) => cb(null, isAllowedOrigin(origin ?? undefined)),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"],
|
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token", "x-imported-file"],
|
||||||
exposedHeaders: ["x-csrf-token", "x-request-id"],
|
exposedHeaders: ["x-csrf-token", "x-request-id"],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -301,7 +301,16 @@ export const importLegacyFiles = async (
|
|||||||
(parsed as any).elements &&
|
(parsed as any).elements &&
|
||||||
(parsed as any).appState
|
(parsed as any).appState
|
||||||
) {
|
) {
|
||||||
const result = await importDrawings([file], targetCollectionId, undefined, onProgress);
|
const mappedOnProgress = onProgress
|
||||||
|
? (_idx: number, status: UploadStatus, progress: number, error?: string) =>
|
||||||
|
onProgress(fileIndex, status, progress, error)
|
||||||
|
: undefined;
|
||||||
|
const result = await importDrawings(
|
||||||
|
[file],
|
||||||
|
targetCollectionId,
|
||||||
|
undefined,
|
||||||
|
mappedOnProgress
|
||||||
|
);
|
||||||
successCount += result.success;
|
successCount += result.success;
|
||||||
failCount += result.failed;
|
failCount += result.failed;
|
||||||
errors.push(...result.errors);
|
errors.push(...result.errors);
|
||||||
|
|||||||
Reference in New Issue
Block a user