Files
app/src/routes/admin/sessions.ts
BOHA 9ae49c0d6a refactor: route/schema consistency (parseId, broad invalidation, Czech messages, Zod 4 strictObject)
- 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>
2026-06-06 02:29:04 +02:00

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);
});
}