diff --git a/apps/dokploy/__test__/api/session-management.test.ts b/apps/dokploy/__test__/api/session-management.test.ts new file mode 100644 index 000000000..48f7eb257 --- /dev/null +++ b/apps/dokploy/__test__/api/session-management.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +const revokeSessionSchema = z.object({ + sessionId: z.string(), +}); + +describe("revokeSession input validation", () => { + it("accepts a valid session id", () => { + const result = revokeSessionSchema.safeParse({ sessionId: "abc123" }); + expect(result.success).toBe(true); + }); + + it("rejects missing sessionId", () => { + const result = revokeSessionSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it("rejects non-string sessionId", () => { + const result = revokeSessionSchema.safeParse({ sessionId: 123 }); + expect(result.success).toBe(false); + }); +}); diff --git a/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx b/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx new file mode 100644 index 000000000..35c174dbb --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx @@ -0,0 +1,206 @@ +import { format } from "date-fns"; +import { Loader2, LogOut, Smartphone } from "lucide-react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/utils/api"; +import { DialogAction } from "@/components/shared/dialog-action"; + +export const ShowSessions = () => { + const { data: sessions, isPending, refetch } = + api.user.listSessions.useQuery(); + const { mutateAsync: revoke, isPending: isRevoking } = + api.user.revokeSession.useMutation(); + + const handleRevoke = async (sessionId: string) => { + try { + await revoke({ sessionId }); + toast.success("Session revoked successfully"); + refetch(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to revoke session", + ); + } + }; + + const activeSessions = sessions?.filter( + (s) => new Date(s.expiresAt) > new Date(), + ); + const expiredSessions = sessions?.filter( + (s) => new Date(s.expiresAt) <= new Date(), + ); + + return ( +
+ +
+ + + + Sessions + + + Manage active user sessions. Revoke sessions to force logout. + + + + {isPending ? ( +
+ Loading... + +
+ ) : !sessions || sessions.length === 0 ? ( +
+ + + No sessions found + +
+ ) : ( + <> + {activeSessions && activeSessions.length > 0 && ( +
+

+ Active Sessions ({activeSessions.length}) +

+ +
+ )} + {expiredSessions && expiredSessions.length > 0 && ( +
+

+ Expired Sessions ({expiredSessions.length}) +

+ +
+ )} + + )} +
+
+
+
+ ); +}; + +type SessionRow = { + id: string; + email: string; + firstName: string | null; + lastName: string | null; + ipAddress: string | null; + userAgent: string | null; + createdAt: Date; + expiresAt: Date; + isCurrent: boolean; + userId: string; +}; + +const SessionTable = ({ + sessions, + onRevoke, + isRevoking, +}: { + sessions: SessionRow[]; + onRevoke: (id: string) => void; + isRevoking: boolean; +}) => ( + + + + User + IP Address + Device + Active Since + Expires + Actions + + + + {sessions.map((s) => ( + + +
+ + {s.firstName} {s.lastName} + + ({s.email}) + {s.isCurrent && ( + + Current + + )} +
+
+ + {s.ipAddress || "-"} + + + {s.userAgent + ? parseUserAgent(s.userAgent) + : "-"} + + + {format(new Date(s.createdAt), "MMM d, HH:mm")} + + + {format(new Date(s.expiresAt), "MMM d, HH:mm")} + + + {!s.isCurrent && ( + onRevoke(s.id)} + > + + + )} + +
+ ))} +
+
+); + +/** ponyta: parse UA just enough to show OS + browser, no lib needed */ +function parseUserAgent(ua: string): string { + if (ua.includes("Chrome/") && !ua.includes("Edg/")) return "Chrome"; + if (ua.includes("Edg/")) return "Edge"; + if (ua.includes("Firefox/")) return "Firefox"; + if (ua.includes("Safari/") && !ua.includes("Chrome/")) return "Safari"; + if (ua.includes("curl/") || ua.includes("wget/")) return "CLI"; + return ua.slice(0, 40) + (ua.length > 40 ? "..." : ""); +} diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 6763898e8..1af9033de 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -315,6 +315,13 @@ const MENU: Menu = { // Only enabled for users with member.read permission isEnabled: ({ permissions }) => !!permissions?.member.read, }, + { + isSingle: true, + title: "Sessions", + icon: LogIn, + url: "/dashboard/settings/sessions", + isEnabled: ({ permissions }) => !!permissions?.member.read, + }, { isSingle: true, title: "Audit Logs", diff --git a/apps/dokploy/pages/dashboard/settings/sessions.tsx b/apps/dokploy/pages/dashboard/settings/sessions.tsx new file mode 100644 index 000000000..d777ff67c --- /dev/null +++ b/apps/dokploy/pages/dashboard/settings/sessions.tsx @@ -0,0 +1,82 @@ +import { validateRequest } from "@dokploy/server"; +import { createServerSideHelpers } from "@trpc/react-query/server"; +import type { GetServerSidePropsContext } from "next"; +import type { ReactElement } from "react"; +import superjson from "superjson"; +import { ShowSessions } from "@/components/dashboard/settings/sessions/show-sessions"; +import { DashboardLayout } from "@/components/layouts/dashboard-layout"; +import { appRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +const Page = () => { + const { data: permissions } = api.user.getPermissions.useQuery(); + + if (!permissions?.member.read) { + return null; + } + + return ( +
+ +
+ ); +}; + +export default Page; + +Page.getLayout = (page: ReactElement) => { + return {page}; +}; + +export async function getServerSideProps( + ctx: GetServerSidePropsContext<{ serviceId: string }>, +) { + const { req, res } = ctx; + const { user, session } = await validateRequest(req); + + if (!user) { + return { + redirect: { + permanent: false, + destination: "/", + }, + }; + } + + const helpers = createServerSideHelpers({ + router: appRouter, + ctx: { + req: req as any, + res: res as any, + db: null as any, + session: session as any, + user: user as any, + }, + transformer: superjson, + }); + + try { + await helpers.user.get.prefetch(); + + const userPermissions = await helpers.user.getPermissions.fetch(); + + if (!userPermissions?.member.read) { + return { + redirect: { + permanent: false, + destination: "/", + }, + }; + } + + return { + props: { + trpcState: helpers.dehydrate(), + }, + }; + } catch { + return { + props: {}, + }; + } +} diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index b4782b1d2..e8a8508ad 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -33,7 +33,7 @@ import { import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key"; import { TRPCError } from "@trpc/server"; import * as bcrypt from "bcrypt"; -import { and, asc, eq, gt, ne } from "drizzle-orm"; +import { and, asc, desc, eq, gt, ne } from "drizzle-orm"; import { z } from "zod"; import { apiKeyNameSchema } from "@/lib/api-keys"; import { audit } from "@/server/api/utils/audit"; @@ -282,6 +282,50 @@ export const userRouter = createTRPCRouter({ }); } }), + listSessions: adminProcedure.query(async ({ ctx }) => { + const sessions = await db + .select({ + id: session.id, + userId: session.userId, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + ipAddress: session.ipAddress, + userAgent: session.userAgent, + createdAt: session.createdAt, + expiresAt: session.expiresAt, + isCurrent: eq(session.id, ctx.session.id), + }) + .from(session) + .innerJoin(user, eq(session.userId, user.id)) + .orderBy(desc(session.createdAt)); + return sessions; + }), + revokeSession: adminProcedure + .input( + z.object({ + sessionId: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const targetSession = await db.query.session.findFirst({ + where: eq(session.id, input.sessionId), + }); + if (!targetSession) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Session not found", + }); + } + if (targetSession.id === ctx.session.id) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Cannot revoke your own session. Use logout instead.", + }); + } + await db.delete(session).where(eq(session.id, input.sessionId)); + return true; + }), getUserByToken: publicProcedure .input(apiFindOneToken) .query(async ({ input }) => {