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 }) => {