- OfferDetail: invalidate broad ["offers"] not ["offers","list"] (CLAUDE.md convention) - trips.ts/sessions.ts: use parseId helper (validated 400) instead of raw parseInt for route ids - audit-log.ts: z.object().strict() -> z.strictObject() (Zod 4 idiom) - attendance.ts, orders/offers schemas: English user-facing strings -> Czech - project-files.ts: unify 'not found' phrasing to the codebase-dominant 'nenalezen' form Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
129 lines
4.1 KiB
TypeScript
129 lines
4.1 KiB
TypeScript
import { FastifyInstance } from "fastify";
|
|
import prisma from "../../config/database";
|
|
import { requireAuth } from "../../middleware/auth";
|
|
import { success, error, parseId } from "../../utils/response";
|
|
import { hashToken } from "../../services/auth";
|
|
|
|
/** Parse user-agent string into browser, OS, and device icon */
|
|
function parseUserAgent(ua: string | null): {
|
|
browser: string;
|
|
os: string;
|
|
icon: string;
|
|
} {
|
|
if (!ua)
|
|
return {
|
|
browser: "Neznámý prohlížeč",
|
|
os: "Neznámý systém",
|
|
icon: "monitor",
|
|
};
|
|
|
|
let browser = "Neznámý prohlížeč";
|
|
if (ua.includes("Edg/")) browser = "Edge";
|
|
else if (ua.includes("OPR/") || ua.includes("Opera")) browser = "Opera";
|
|
else if (ua.includes("Chrome/")) browser = "Chrome";
|
|
else if (ua.includes("Safari/") && !ua.includes("Chrome")) browser = "Safari";
|
|
else if (ua.includes("Firefox/")) browser = "Firefox";
|
|
|
|
let os = "Neznámý systém";
|
|
if (ua.includes("Windows")) os = "Windows";
|
|
else if (ua.includes("Mac OS X") || ua.includes("Macintosh")) os = "macOS";
|
|
else if (ua.includes("Linux") && !ua.includes("Android")) os = "Linux";
|
|
else if (ua.includes("Android")) os = "Android";
|
|
else if (ua.includes("iPhone") || ua.includes("iPad")) os = "iOS";
|
|
|
|
let icon = "monitor";
|
|
if (
|
|
ua.includes("Mobile") ||
|
|
ua.includes("iPhone") ||
|
|
ua.includes("Android")
|
|
) {
|
|
icon =
|
|
ua.includes("iPad") || ua.includes("Tablet") ? "tablet" : "smartphone";
|
|
}
|
|
|
|
return { browser, os, icon };
|
|
}
|
|
|
|
export default async function sessionsRoutes(
|
|
fastify: FastifyInstance,
|
|
): Promise<void> {
|
|
// GET /api/admin/sessions — list active sessions for current user
|
|
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
|
const authData = request.authData!;
|
|
const currentToken = request.cookies?.refresh_token;
|
|
const currentHash = currentToken ? hashToken(currentToken) : null;
|
|
|
|
const sessions = await prisma.refresh_tokens.findMany({
|
|
where: {
|
|
user_id: authData.userId,
|
|
replaced_at: null,
|
|
expires_at: { gt: new Date() },
|
|
},
|
|
orderBy: { created_at: "desc" },
|
|
});
|
|
|
|
const enriched = sessions.map((s) => {
|
|
const device_info = parseUserAgent(s.user_agent);
|
|
return {
|
|
id: s.id,
|
|
is_current: currentHash ? s.token_hash === currentHash : false,
|
|
device_info,
|
|
ip_address: s.ip_address || "",
|
|
created_at: s.created_at ?? "",
|
|
};
|
|
});
|
|
|
|
return success(reply, enriched);
|
|
});
|
|
|
|
// DELETE /api/admin/sessions/:id — delete specific session
|
|
fastify.delete<{ Params: { id: string } }>(
|
|
"/:id",
|
|
{ preHandler: requireAuth },
|
|
async (request, reply) => {
|
|
const id = parseId((request.params as any).id, reply);
|
|
if (id === null) return;
|
|
const authData = request.authData!;
|
|
|
|
const session = await prisma.refresh_tokens.findFirst({
|
|
where: { id, user_id: authData.userId },
|
|
});
|
|
if (!session) return error(reply, "Relace nenalezena", 404);
|
|
|
|
await prisma.refresh_tokens.update({
|
|
where: { id },
|
|
data: { replaced_at: new Date() },
|
|
});
|
|
return success(reply, null, 200, "Relace ukončena");
|
|
},
|
|
);
|
|
|
|
// DELETE /api/admin/sessions — delete all sessions except current
|
|
fastify.delete("/", { preHandler: requireAuth }, async (request, reply) => {
|
|
const authData = request.authData!;
|
|
const query = request.query as Record<string, unknown>;
|
|
|
|
if (query.action === "all") {
|
|
// Get current token from cookie to exclude (hash it to match stored token_hash)
|
|
const currentToken = request.cookies?.refresh_token;
|
|
const currentHash = currentToken ? hashToken(currentToken) : null;
|
|
|
|
if (!currentHash) {
|
|
return error(reply, "Nelze identifikovat aktuální relaci", 400);
|
|
}
|
|
|
|
await prisma.refresh_tokens.updateMany({
|
|
where: {
|
|
user_id: authData.userId,
|
|
replaced_at: null,
|
|
token_hash: { not: currentHash },
|
|
},
|
|
data: { replaced_at: new Date() },
|
|
});
|
|
return success(reply, null, 200, "Všechny ostatní relace ukončeny");
|
|
}
|
|
|
|
return error(reply, "Neplatná akce", 400);
|
|
});
|
|
}
|