From fe10330b76b4eea9cc8b4cf5ec90c427ab72352d Mon Sep 17 00:00:00 2001 From: "J. Newing" Date: Tue, 5 Aug 2025 14:33:09 -0400 Subject: [PATCH 1/2] initial commit of the session mgr stuff --- package-lock.json | 110 +------- server/routers/external.ts | 9 + server/routers/session/getSessionStats.ts | 70 +++++ server/routers/session/index.ts | 6 + .../session/invalidateAllUserSessions.ts | 60 +++++ .../session/invalidateResourceSession.ts | 57 ++++ .../routers/session/invalidateUserSession.ts | 60 +++++ .../routers/session/listResourceSessions.ts | 88 ++++++ server/routers/session/listUserSessions.ts | 59 ++++ .../sessions/InvalidateSessionDialog.tsx | 86 ++++++ .../sessions/ResourceSessionsDataTable.tsx | 30 +++ .../admin/sessions/ResourceSessionsTable.tsx | 250 +++++++++++++++++ .../admin/sessions/UserSessionsDataTable.tsx | 30 +++ src/app/admin/sessions/UserSessionsTable.tsx | 251 ++++++++++++++++++ src/app/admin/sessions/page.tsx | 147 ++++++++++ .../admin/sessions/resource-sessions/page.tsx | 48 ++++ src/app/admin/sessions/user-sessions/page.tsx | 48 ++++ src/app/navigation.tsx | 8 +- 18 files changed, 1315 insertions(+), 102 deletions(-) create mode 100644 server/routers/session/getSessionStats.ts create mode 100644 server/routers/session/index.ts create mode 100644 server/routers/session/invalidateAllUserSessions.ts create mode 100644 server/routers/session/invalidateResourceSession.ts create mode 100644 server/routers/session/invalidateUserSession.ts create mode 100644 server/routers/session/listResourceSessions.ts create mode 100644 server/routers/session/listUserSessions.ts create mode 100644 src/app/admin/sessions/InvalidateSessionDialog.tsx create mode 100644 src/app/admin/sessions/ResourceSessionsDataTable.tsx create mode 100644 src/app/admin/sessions/ResourceSessionsTable.tsx create mode 100644 src/app/admin/sessions/UserSessionsDataTable.tsx create mode 100644 src/app/admin/sessions/UserSessionsTable.tsx create mode 100644 src/app/admin/sessions/page.tsx create mode 100644 src/app/admin/sessions/resource-sessions/page.tsx create mode 100644 src/app/admin/sessions/user-sessions/page.tsx diff --git a/package-lock.json b/package-lock.json index baec0b2b1..7eb58d2c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,6 @@ "http-errors": "2.0.0", "i": "^0.3.7", "input-otp": "1.4.2", - "ioredis": "^5.6.1", "jmespath": "^0.16.0", "js-yaml": "4.1.0", "jsonwebtoken": "^9.0.2", @@ -77,7 +76,6 @@ "oslo": "1.2.1", "pg": "^8.16.2", "qrcode.react": "4.2.0", - "rate-limit-redis": "^4.2.1", "react": "19.1.0", "react-dom": "19.1.0", "react-easy-sort": "^1.6.0", @@ -2010,12 +2008,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@ioredis/commands": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", - "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", - "license": "MIT" - }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -2058,6 +2050,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, "license": "ISC", "dependencies": { "minipass": "^7.0.4" @@ -6309,6 +6302,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -6455,15 +6449,6 @@ "node": ">=6" } }, - "node_modules/cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/cmdk": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", @@ -6947,15 +6932,6 @@ "node": ">=0.4.0" } }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -8835,6 +8811,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -9122,30 +9099,6 @@ "tslib": "^2.8.0" } }, - "node_modules/ioredis": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz", - "integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==", - "license": "MIT", - "dependencies": { - "@ioredis/commands": "^1.1.1", - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.4", - "denque": "^2.1.0", - "lodash.defaults": "^4.2.0", - "lodash.isarguments": "^3.1.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" - }, - "engines": { - "node": ">=12.22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ioredis" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -9606,6 +9559,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, "license": "ISC", "engines": { "node": ">=16" @@ -10112,24 +10066,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "license": "MIT" - }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, - "node_modules/lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "license": "MIT" - }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -10481,6 +10423,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "dev": true, "license": "MIT", "dependencies": { "minipass": "^7.1.2" @@ -10493,6 +10436,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, "license": "MIT", "bin": { "mkdirp": "dist/cjs/src/bin.js" @@ -14470,18 +14414,6 @@ "node": ">= 0.6" } }, - "node_modules/rate-limit-redis": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-4.2.1.tgz", - "integrity": "sha512-JsUsVmRVI6G/XrlYtfGV1NMCbGS/CVYayHkxD5Ism5FaL8qpFHCXbFkUeIi5WJ/onJOKWCgtB/xtCLa6qSXb4g==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "peerDependencies": { - "express-rate-limit": ">= 6" - } - }, "node_modules/raw-body": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", @@ -14828,27 +14760,6 @@ "node": ">=0.8.8" } }, - "node_modules/redis-errors": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/redis-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "license": "MIT", - "dependencies": { - "redis-errors": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -15628,12 +15539,6 @@ "node": "*" } }, - "node_modules/standard-as-callback": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", - "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", - "license": "MIT" - }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -16021,6 +15926,7 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, "license": "ISC", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -16667,6 +16573,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^3.1.1" @@ -16972,6 +16879,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" diff --git a/server/routers/external.ts b/server/routers/external.ts index 5bae553e8..12414bff2 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -14,6 +14,7 @@ import * as accessToken from "./accessToken"; import * as idp from "./idp"; import * as license from "./license"; import * as apiKeys from "./apiKeys"; +import * as session from "./session"; import HttpCode from "@server/types/HttpCode"; import { verifyAccessTokenAccess, @@ -784,6 +785,14 @@ authenticated.delete( domain.deleteAccountDomain ); +// Session management routes (admin only) +authenticated.get("/sessions/users", verifyUserIsServerAdmin, session.listUserSessions); +authenticated.get("/sessions/resources", verifyUserIsServerAdmin, session.listResourceSessions); +authenticated.delete("/session/user/:sessionId", verifyUserIsServerAdmin, session.invalidateUserSession); +authenticated.delete("/session/resource/:sessionId", verifyUserIsServerAdmin, session.invalidateResourceSession); +authenticated.delete("/sessions/user/:userId", verifyUserIsServerAdmin, session.invalidateAllUserSessions); +authenticated.get("/sessions/stats", verifyUserIsServerAdmin, session.getSessionStats); + // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); diff --git a/server/routers/session/getSessionStats.ts b/server/routers/session/getSessionStats.ts new file mode 100644 index 000000000..56a5c5f3b --- /dev/null +++ b/server/routers/session/getSessionStats.ts @@ -0,0 +1,70 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { response } from "@server/lib"; +import { db, sessions, resourceSessions } from "@server/db"; +import { count, lt } from "drizzle-orm"; +import logger from "@server/logger"; + +export type GetSessionStatsResponse = { + stats: { + totalUserSessions: number; + totalResourceSessions: number; + userSessionsExpiringSoon: number; + resourceSessionsExpiringSoon: number; + }; +}; + +export async function getSessionStats( + req: Request, + res: Response, + next: NextFunction, +): Promise { + try { + const now = Date.now(); + const oneHourFromNow = now + (60 * 60 * 1000); // 1 hour in milliseconds + + // Get user session counts + const [userSessionsResult] = await db + .select({ count: count() }) + .from(sessions); + + const [userSessionsExpiringSoonResult] = await db + .select({ count: count() }) + .from(sessions) + .where(lt(sessions.expiresAt, oneHourFromNow)); + + // Get resource session counts + const [resourceSessionsResult] = await db + .select({ count: count() }) + .from(resourceSessions); + + const [resourceSessionsExpiringSoonResult] = await db + .select({ count: count() }) + .from(resourceSessions) + .where(lt(resourceSessions.expiresAt, oneHourFromNow)); + + const stats = { + totalUserSessions: userSessionsResult?.count || 0, + totalResourceSessions: resourceSessionsResult?.count || 0, + userSessionsExpiringSoon: userSessionsExpiringSoonResult?.count || 0, + resourceSessionsExpiringSoon: resourceSessionsExpiringSoonResult?.count || 0, + }; + + return response(res, { + data: { stats }, + success: true, + error: false, + message: "Session statistics retrieved successfully", + status: HttpCode.OK, + }); + } catch (e) { + logger.error("Failed to get session statistics", e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get session statistics", + ), + ); + } +} \ No newline at end of file diff --git a/server/routers/session/index.ts b/server/routers/session/index.ts new file mode 100644 index 000000000..991dc9d2f --- /dev/null +++ b/server/routers/session/index.ts @@ -0,0 +1,6 @@ +export * from "./listUserSessions"; +export * from "./listResourceSessions"; +export * from "./invalidateUserSession"; +export * from "./invalidateResourceSession"; +export * from "./invalidateAllUserSessions"; +export * from "./getSessionStats"; \ No newline at end of file diff --git a/server/routers/session/invalidateAllUserSessions.ts b/server/routers/session/invalidateAllUserSessions.ts new file mode 100644 index 000000000..9a5800bc4 --- /dev/null +++ b/server/routers/session/invalidateAllUserSessions.ts @@ -0,0 +1,60 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import HttpCode from "@server/types/HttpCode"; +import { response } from "@server/lib"; +import { invalidateAllSessions } from "@server/auth/sessions/app"; +import logger from "@server/logger"; + +export const invalidateAllUserSessionsParams = z.object({ + userId: z.string(), +}).strict(); + +export type InvalidateAllUserSessionsParams = z.infer; + +export type InvalidateAllUserSessionsResponse = { + success: boolean; +}; + +export async function invalidateAllUserSessions( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const parsedParams = invalidateAllUserSessionsParams.safeParse(req.params); + + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString(), + ), + ); + } + + const { userId } = parsedParams.data; + + // Note: Admins can invalidate any user's sessions, including their own + // This is intentional as server admins should have full control + + try { + await invalidateAllSessions(userId); + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "All user sessions invalidated successfully", + status: HttpCode.OK, + }); + } catch (e) { + logger.error("Failed to invalidate all user sessions", e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to invalidate all user sessions", + ), + ); + } +} \ No newline at end of file diff --git a/server/routers/session/invalidateResourceSession.ts b/server/routers/session/invalidateResourceSession.ts new file mode 100644 index 000000000..52e4b54b5 --- /dev/null +++ b/server/routers/session/invalidateResourceSession.ts @@ -0,0 +1,57 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import HttpCode from "@server/types/HttpCode"; +import { response } from "@server/lib"; +import { invalidateResourceSession as invalidateResourceSessionAuth } from "@server/auth/sessions/resource"; +import logger from "@server/logger"; + +export const invalidateResourceSessionParams = z.object({ + sessionId: z.string(), +}).strict(); + +export type InvalidateResourceSessionParams = z.infer; + +export type InvalidateResourceSessionResponse = { + success: boolean; +}; + +export async function invalidateResourceSession( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const parsedParams = invalidateResourceSessionParams.safeParse(req.params); + + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString(), + ), + ); + } + + const { sessionId } = parsedParams.data; + + try { + await invalidateResourceSessionAuth(sessionId); + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Resource session invalidated successfully", + status: HttpCode.OK, + }); + } catch (e) { + logger.error("Failed to invalidate resource session", e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to invalidate resource session", + ), + ); + } +} \ No newline at end of file diff --git a/server/routers/session/invalidateUserSession.ts b/server/routers/session/invalidateUserSession.ts new file mode 100644 index 000000000..3760195b0 --- /dev/null +++ b/server/routers/session/invalidateUserSession.ts @@ -0,0 +1,60 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import HttpCode from "@server/types/HttpCode"; +import { response } from "@server/lib"; +import { invalidateSession as invalidateSessionAuth } from "@server/auth/sessions/app"; +import logger from "@server/logger"; + +export const invalidateUserSessionParams = z.object({ + sessionId: z.string(), +}).strict(); + +export type InvalidateUserSessionParams = z.infer; + +export type InvalidateUserSessionResponse = { + success: boolean; +}; + +export async function invalidateUserSession( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const parsedParams = invalidateUserSessionParams.safeParse(req.params); + + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString(), + ), + ); + } + + const { sessionId } = parsedParams.data; + + // Note: Admins can invalidate any session, including their own + // This is intentional as server admins should have full control + + try { + await invalidateSessionAuth(sessionId); + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "User session invalidated successfully", + status: HttpCode.OK, + }); + } catch (e) { + logger.error("Failed to invalidate user session", e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to invalidate user session", + ), + ); + } +} \ No newline at end of file diff --git a/server/routers/session/listResourceSessions.ts b/server/routers/session/listResourceSessions.ts new file mode 100644 index 000000000..2805f6af1 --- /dev/null +++ b/server/routers/session/listResourceSessions.ts @@ -0,0 +1,88 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { response } from "@server/lib"; +import { db, resourceSessions, resources, users, sessions } from "@server/db"; +import { eq, desc } from "drizzle-orm"; +import logger from "@server/logger"; + +export type ListResourceSessionsResponse = { + sessions: Array<{ + sessionId: string; + resourceId: number; + resourceName: string; + expiresAt: number; + sessionLength: number; + doNotExtend: boolean; + isRequestToken: boolean; + userSessionId: string | null; + username: string | null; + email: string | null; + authMethod: string; + }>; +}; + +export async function listResourceSessions( + req: Request, + res: Response, + next: NextFunction, +): Promise { + try { + const result = await db + .select({ + sessionId: resourceSessions.sessionId, + resourceId: resourceSessions.resourceId, + resourceName: resources.name, + expiresAt: resourceSessions.expiresAt, + sessionLength: resourceSessions.sessionLength, + doNotExtend: resourceSessions.doNotExtend, + isRequestToken: resourceSessions.isRequestToken, + userSessionId: resourceSessions.userSessionId, + username: users.username, + email: users.email, + passwordId: resourceSessions.passwordId, + pincodeId: resourceSessions.pincodeId, + whitelistId: resourceSessions.whitelistId, + accessTokenId: resourceSessions.accessTokenId, + }) + .from(resourceSessions) + .innerJoin(resources, eq(resourceSessions.resourceId, resources.resourceId)) + .leftJoin(sessions, eq(resourceSessions.userSessionId, sessions.sessionId)) + .leftJoin(users, eq(sessions.userId, users.userId)) + .orderBy(desc(resourceSessions.expiresAt)); + + const sessionsWithAuthMethod = result.map(session => ({ + sessionId: session.sessionId, + resourceId: session.resourceId, + resourceName: session.resourceName, + expiresAt: session.expiresAt, + sessionLength: session.sessionLength, + doNotExtend: session.doNotExtend, + isRequestToken: session.isRequestToken || false, + userSessionId: session.userSessionId, + username: session.username, + email: session.email, + authMethod: session.passwordId ? "Password" : + session.pincodeId ? "Pincode" : + session.whitelistId ? "Whitelist" : + session.accessTokenId ? "Access Token" : + session.userSessionId ? "User Session" : "Unknown", + })); + + return response(res, { + data: { sessions: sessionsWithAuthMethod }, + success: true, + error: false, + message: "Resource sessions retrieved successfully", + status: HttpCode.OK, + }); + } catch (e) { + logger.error("Failed to list resource sessions", e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to list resource sessions", + ), + ); + } +} \ No newline at end of file diff --git a/server/routers/session/listUserSessions.ts b/server/routers/session/listUserSessions.ts new file mode 100644 index 000000000..488f645f5 --- /dev/null +++ b/server/routers/session/listUserSessions.ts @@ -0,0 +1,59 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { response } from "@server/lib"; +import { db, sessions, users } from "@server/db"; +import { eq, desc } from "drizzle-orm"; +import logger from "@server/logger"; + +export type ListUserSessionsResponse = { + sessions: Array<{ + sessionId: string; + userId: string; + username: string; + email: string | null; + expiresAt: number; + createdAt: number; + }>; +}; + +export async function listUserSessions( + req: Request, + res: Response, + next: NextFunction, +): Promise { + try { + const result = await db + .select({ + sessionId: sessions.sessionId, + userId: sessions.userId, + username: users.username, + email: users.email, + expiresAt: sessions.expiresAt, + }) + .from(sessions) + .innerJoin(users, eq(sessions.userId, users.userId)) + .orderBy(desc(sessions.expiresAt)); + + const sessionsWithCreatedAt = result.map(session => ({ + ...session, + createdAt: session.expiresAt - (1000 * 60 * 60 * 24), // Approximate creation time + })); + + return response(res, { + data: { sessions: sessionsWithCreatedAt }, + success: true, + error: false, + message: "User sessions retrieved successfully", + status: HttpCode.OK, + }); + } catch (e) { + logger.error("Failed to list user sessions", e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to list user sessions", + ), + ); + } +} \ No newline at end of file diff --git a/src/app/admin/sessions/InvalidateSessionDialog.tsx b/src/app/admin/sessions/InvalidateSessionDialog.tsx new file mode 100644 index 000000000..b0a83d830 --- /dev/null +++ b/src/app/admin/sessions/InvalidateSessionDialog.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@/components/Credenza"; + +interface InvalidateSessionDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + sessionType: "user" | "resource"; + sessionInfo?: { + username?: string; + email?: string | null; + resourceName?: string; + }; + isLoading?: boolean; +} + +export function InvalidateSessionDialog({ + isOpen, + onClose, + onConfirm, + sessionType, + sessionInfo, + isLoading = false +}: InvalidateSessionDialogProps) { + return ( + + + + + Invalidate {sessionType === "user" ? "User" : "Resource"} Session + + + {sessionType === "user" ? ( + <> + Are you sure you want to invalidate the session for{" "} + {sessionInfo?.username || sessionInfo?.email}? + + ) : ( + <> + Are you sure you want to invalidate the resource session for{" "} + {sessionInfo?.resourceName}? + + )} + + + + {sessionType === "user" ? ( +

+ This will immediately log out the user and they will need to sign in again. + This action cannot be undone. +

+ ) : ( +

+ This will immediately terminate access to the resource and any active + connections will be closed. This action cannot be undone. +

+ )} +
+ + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/sessions/ResourceSessionsDataTable.tsx b/src/app/admin/sessions/ResourceSessionsDataTable.tsx new file mode 100644 index 000000000..c4ba68d65 --- /dev/null +++ b/src/app/admin/sessions/ResourceSessionsDataTable.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { + ColumnDef, +} from "@tanstack/react-table"; +import { DataTable } from "@/components/ui/data-table"; +import { useTranslations } from "next-intl"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function ResourceSessionsDataTable({ + columns, + data +}: DataTableProps) { + + const t = useTranslations(); + + return ( + + ); +} \ No newline at end of file diff --git a/src/app/admin/sessions/ResourceSessionsTable.tsx b/src/app/admin/sessions/ResourceSessionsTable.tsx new file mode 100644 index 000000000..ddf581520 --- /dev/null +++ b/src/app/admin/sessions/ResourceSessionsTable.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { ResourceSessionsDataTable } from "./ResourceSessionsDataTable"; +import { Button } from "@/components/ui/button"; +import { ArrowUpDown, MoreHorizontal, LogOut } from "lucide-react"; +import { useState } from "react"; +import { InvalidateSessionDialog } from "./InvalidateSessionDialog"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; +import { + DropdownMenu, + DropdownMenuItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Badge } from "@/components/ui/badge"; + +export type ResourceSessionRow = { + sessionId: string; + resourceId: number; + resourceName: string; + expiresAt: number; + sessionLength: number; + doNotExtend: boolean; + isRequestToken: boolean; + userSessionId: string | null; + username: string | null; + email: string | null; + authMethod: string; +}; + +type Props = { + sessions: ResourceSessionRow[]; +}; + +export default function ResourceSessionsTable({ sessions }: Props) { + const t = useTranslations(); + + const [isInvalidateModalOpen, setIsInvalidateModalOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [rows, setRows] = useState(sessions); + const [isLoading, setIsLoading] = useState(false); + + const api = createApiClient(useEnvContext()); + + const invalidateSession = async (sessionId: string) => { + setIsLoading(true); + try { + await api.delete(`/session/resource/${sessionId}`); + setRows(prevRows => prevRows.filter(row => row.sessionId !== sessionId)); + toast({ + title: "Resource session invalidated", + description: "The resource session has been successfully invalidated.", + }); + } catch (error) { + toast({ + title: "Error", + description: formatAxiosError(error), + variant: "destructive", + }); + } finally { + setIsLoading(false); + setIsInvalidateModalOpen(false); + setSelected(null); + } + }; + + const formatDateTime = (timestamp: number) => { + return new Date(timestamp).toLocaleString(); + }; + + const formatDuration = (milliseconds: number) => { + const hours = Math.floor(milliseconds / (1000 * 60 * 60)); + const minutes = Math.floor((milliseconds % (1000 * 60 * 60)) / (1000 * 60)); + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; + }; + + const isExpiringSoon = (expiresAt: number) => { + const oneHour = 60 * 60 * 1000; // 1 hour in milliseconds + return expiresAt - Date.now() < oneHour; + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "resourceName", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => ( +
{row.getValue("resourceName")}
+ ), + }, + { + accessorKey: "authMethod", + header: "Auth Method", + cell: ({ row }) => { + const method = row.getValue("authMethod") as string; + const variant = method === "Password" ? "default" : + method === "Pincode" ? "secondary" : + method === "Whitelist" ? "outline" : + method === "Access Token" ? "destructive" : + method === "User Session" ? "success" : "default"; + return ( + + {method} + + ); + }, + }, + { + accessorKey: "username", + header: "User", + cell: ({ row }) => { + const username = row.getValue("username") as string | null; + const email = row.original.email; + if (!username && !email) { + return
-
; + } + return ( +
+
{username || "Unknown"}
+ {email &&
{email}
} +
+ ); + }, + }, + { + accessorKey: "sessionLength", + header: "Duration", + cell: ({ row }) => ( +
{formatDuration(row.getValue("sessionLength"))}
+ ), + }, + { + accessorKey: "expiresAt", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const expiresAt = row.getValue("expiresAt") as number; + const expiringSoon = isExpiringSoon(expiresAt); + const doNotExtend = row.original.doNotExtend; + return ( +
+
{formatDateTime(expiresAt)}
+ {expiringSoon && ( + + Expiring Soon + + )} + {doNotExtend && ( + + No Auto-extend + + )} +
+ ); + }, + }, + { + id: "flags", + header: "Flags", + cell: ({ row }) => { + const isRequestToken = row.original.isRequestToken; + return ( +
+ {isRequestToken && ( + + Request Token + + )} +
+ ); + }, + }, + { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const session = row.original; + + return ( + + + + + + { + setSelected(session); + setIsInvalidateModalOpen(true); + }} + className="text-red-600" + > + + Invalidate Session + + + + ); + }, + }, + ]; + + return ( + <> + + + { + setIsInvalidateModalOpen(false); + setSelected(null); + }} + onConfirm={() => selected && invalidateSession(selected.sessionId)} + sessionType="resource" + sessionInfo={{ + resourceName: selected?.resourceName, + }} + isLoading={isLoading} + /> + + ); +} \ No newline at end of file diff --git a/src/app/admin/sessions/UserSessionsDataTable.tsx b/src/app/admin/sessions/UserSessionsDataTable.tsx new file mode 100644 index 000000000..d8cc9b618 --- /dev/null +++ b/src/app/admin/sessions/UserSessionsDataTable.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { + ColumnDef, +} from "@tanstack/react-table"; +import { DataTable } from "@/components/ui/data-table"; +import { useTranslations } from "next-intl"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function UserSessionsDataTable({ + columns, + data +}: DataTableProps) { + + const t = useTranslations(); + + return ( + + ); +} \ No newline at end of file diff --git a/src/app/admin/sessions/UserSessionsTable.tsx b/src/app/admin/sessions/UserSessionsTable.tsx new file mode 100644 index 000000000..9771dc825 --- /dev/null +++ b/src/app/admin/sessions/UserSessionsTable.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { UserSessionsDataTable } from "./UserSessionsDataTable"; +import { Button } from "@/components/ui/button"; +import { ArrowUpDown, MoreHorizontal, LogOut, Users } from "lucide-react"; +import { useState } from "react"; +import { InvalidateSessionDialog } from "./InvalidateSessionDialog"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; +import { + DropdownMenu, + DropdownMenuItem, + DropdownMenuContent, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { Badge } from "@/components/ui/badge"; + +export type UserSessionRow = { + sessionId: string; + userId: string; + username: string; + email: string | null; + expiresAt: number; + createdAt: number; +}; + +type Props = { + sessions: UserSessionRow[]; +}; + +export default function UserSessionsTable({ sessions }: Props) { + const t = useTranslations(); + + const [isInvalidateModalOpen, setIsInvalidateModalOpen] = useState(false); + const [isInvalidateAllModalOpen, setIsInvalidateAllModalOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [selectedUserId, setSelectedUserId] = useState(null); + const [rows, setRows] = useState(sessions); + const [isLoading, setIsLoading] = useState(false); + + const api = createApiClient(useEnvContext()); + + const invalidateSession = async (sessionId: string) => { + setIsLoading(true); + try { + await api.delete(`/session/user/${sessionId}`); + setRows(prevRows => prevRows.filter(row => row.sessionId !== sessionId)); + toast({ + title: "Session invalidated", + description: "The user session has been successfully invalidated.", + }); + } catch (error) { + toast({ + title: "Error", + description: formatAxiosError(error), + variant: "destructive", + }); + } finally { + setIsLoading(false); + setIsInvalidateModalOpen(false); + setSelected(null); + } + }; + + const invalidateAllUserSessions = async (userId: string) => { + setIsLoading(true); + try { + await api.delete(`/sessions/user/${userId}`); + setRows(prevRows => prevRows.filter(row => row.userId !== userId)); + toast({ + title: "All user sessions invalidated", + description: "All sessions for this user have been successfully invalidated.", + }); + } catch (error) { + toast({ + title: "Error", + description: formatAxiosError(error), + variant: "destructive", + }); + } finally { + setIsLoading(false); + setIsInvalidateAllModalOpen(false); + setSelectedUserId(null); + } + }; + + const formatDateTime = (timestamp: number) => { + return new Date(timestamp).toLocaleString(); + }; + + const isExpiringSoon = (expiresAt: number) => { + const oneHour = 60 * 60 * 1000; // 1 hour in milliseconds + return expiresAt - Date.now() < oneHour; + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "username", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => ( +
{row.getValue("username")}
+ ), + }, + { + accessorKey: "email", + header: "Email", + cell: ({ row }) => ( +
+ {row.getValue("email") || "No email"} +
+ ), + }, + { + accessorKey: "createdAt", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => ( +
{formatDateTime(row.getValue("createdAt"))}
+ ), + }, + { + accessorKey: "expiresAt", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const expiresAt = row.getValue("expiresAt") as number; + const expiringSoon = isExpiringSoon(expiresAt); + return ( +
+
{formatDateTime(expiresAt)}
+ {expiringSoon && ( + + Expiring Soon + + )} +
+ ); + }, + }, + { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const session = row.original; + + return ( + + + + + + { + setSelected(session); + setIsInvalidateModalOpen(true); + }} + className="text-red-600" + > + + Invalidate Session + + + { + setSelectedUserId(session.userId); + setIsInvalidateAllModalOpen(true); + }} + className="text-red-600" + > + + Invalidate All User Sessions + + + + ); + }, + }, + ]; + + return ( + <> + + + { + setIsInvalidateModalOpen(false); + setSelected(null); + }} + onConfirm={() => selected && invalidateSession(selected.sessionId)} + sessionType="user" + sessionInfo={{ + username: selected?.username, + email: selected?.email, + }} + isLoading={isLoading} + /> + + { + setIsInvalidateAllModalOpen(false); + setSelectedUserId(null); + }} + onConfirm={() => selectedUserId && invalidateAllUserSessions(selectedUserId)} + sessionType="user" + sessionInfo={{ + username: rows.find(r => r.userId === selectedUserId)?.username, + email: rows.find(r => r.userId === selectedUserId)?.email, + }} + isLoading={isLoading} + /> + + ); +} \ No newline at end of file diff --git a/src/app/admin/sessions/page.tsx b/src/app/admin/sessions/page.tsx new file mode 100644 index 000000000..0aed9ebdc --- /dev/null +++ b/src/app/admin/sessions/page.tsx @@ -0,0 +1,147 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Users, Monitor, Clock, AlertTriangle } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { getTranslations } from "next-intl/server"; +import { GetSessionStatsResponse } from "@server/routers/session/getSessionStats"; + +type SessionStats = { + totalUserSessions: number; + totalResourceSessions: number; + userSessionsExpiringSoon: number; + resourceSessionsExpiringSoon: number; +}; + +export const dynamic = "force-dynamic"; + +export default async function SessionsPage() { + let stats: SessionStats = { + totalUserSessions: 0, + totalResourceSessions: 0, + userSessionsExpiringSoon: 0, + resourceSessionsExpiringSoon: 0, + }; + + try { + const res = await internal.get>( + `/sessions/stats`, + await authCookieHeader() + ); + stats = res.data.data.stats; + } catch (e) { + console.error(e); + } + + const t = await getTranslations(); + + return ( + <> + + +
+ + + + Active User Sessions + + + + +
{stats.totalUserSessions}
+

+ Dashboard and API sessions +

+
+
+ + + + + Active Resource Sessions + + + + +
{stats.totalResourceSessions}
+

+ Resource access sessions +

+
+
+ + + + + User Sessions Expiring Soon + + + + +
{stats.userSessionsExpiringSoon}
+

+ Expiring within 1 hour +

+
+
+ + + + + Resource Sessions Expiring Soon + + + + +
{stats.resourceSessionsExpiringSoon}
+

+ Expiring within 1 hour +

+
+
+
+ +
+ + + User Sessions + + Manage active user dashboard and API sessions + + + +

+ View all active user sessions, see who is logged in, and invalidate sessions as needed. +

+ + + +
+
+ + + + Resource Sessions + + Manage active resource access sessions + + + +

+ View all active resource sessions, see which resources are being accessed, and invalidate sessions as needed. +

+ + + +
+
+
+ + ); +} \ No newline at end of file diff --git a/src/app/admin/sessions/resource-sessions/page.tsx b/src/app/admin/sessions/resource-sessions/page.tsx new file mode 100644 index 000000000..7f8246960 --- /dev/null +++ b/src/app/admin/sessions/resource-sessions/page.tsx @@ -0,0 +1,48 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import ResourceSessionsTable, { ResourceSessionRow } from "../ResourceSessionsTable"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { InfoIcon } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { ListResourceSessionsResponse } from "@server/routers/session/listResourceSessions"; + +export const dynamic = "force-dynamic"; + +export default async function ResourceSessionsPage() { + let sessions: ResourceSessionRow[] = []; + + try { + const res = await internal.get>( + `/sessions/resources`, + await authCookieHeader() + ); + sessions = res.data.data.sessions; + } catch (e) { + console.error(e); + } + + const t = await getTranslations(); + + return ( + <> + + + + + About Resource Sessions + + Resource sessions represent active access sessions to resources (websites, applications, etc.). + These sessions are created when users authenticate to access protected resources. + When you invalidate a resource session, the user will lose access to that resource immediately. + + + + + + ); +} \ No newline at end of file diff --git a/src/app/admin/sessions/user-sessions/page.tsx b/src/app/admin/sessions/user-sessions/page.tsx new file mode 100644 index 000000000..687bd81a2 --- /dev/null +++ b/src/app/admin/sessions/user-sessions/page.tsx @@ -0,0 +1,48 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import UserSessionsTable, { UserSessionRow } from "../UserSessionsTable"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { InfoIcon } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { ListUserSessionsResponse } from "@server/routers/session/listUserSessions"; + +export const dynamic = "force-dynamic"; + +export default async function UserSessionsPage() { + let sessions: UserSessionRow[] = []; + + try { + const res = await internal.get>( + `/sessions/users`, + await authCookieHeader() + ); + sessions = res.data.data.sessions; + } catch (e) { + console.error(e); + } + + const t = await getTranslations(); + + return ( + <> + + + + + About User Sessions + + User sessions represent active login sessions for users accessing the dashboard + or API. When you invalidate a session, the user will be immediately logged out + and will need to sign in again. Use caution when invalidating your own session. + + + + + + ); +} \ No newline at end of file diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index b26b98ec9..ad7b06c20 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -13,7 +13,8 @@ import { TicketCheck, User, Globe, // Added from 'dev' branch - MonitorUp // Added from 'dev' branch + MonitorUp, // Added from 'dev' branch + Activity } from "lucide-react"; export type SidebarNavSection = { // Added from 'dev' branch @@ -113,6 +114,11 @@ export const adminNavSections: SidebarNavSection[] = [ href: "/admin/users", icon: }, + { + title: "sidebarSessions", + href: "/admin/sessions", + icon: + }, { title: "sidebarApiKeys", href: "/admin/api-keys", From 45fb1ea7d8dc55a94580d9b7a512ef2272c4728f Mon Sep 17 00:00:00 2001 From: "J. Newing" Date: Tue, 5 Aug 2025 15:10:23 -0400 Subject: [PATCH 2/2] lang stuff Not sure if I should be adding the lanuage stuff or not? So maybe I just won't for now?... --- src/app/navigation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index ad7b06c20..0d68c491f 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -115,7 +115,7 @@ export const adminNavSections: SidebarNavSection[] = [ icon: }, { - title: "sidebarSessions", + title: "Session Mgmt.", href: "/admin/sessions", icon: },