From 7728e5ea9eae130f69c0d52e77f6979663ef1d95 Mon Sep 17 00:00:00 2001 From: Danilo Neto Date: Fri, 17 Jul 2026 13:48:54 +0000 Subject: [PATCH 1/5] feat: add session management page (list active sessions + revoke) Add a new settings page under Settings > Sessions that shows all active and expired login sessions with user info, IP address, device, and an option to revoke (force logout) any session. Backend: - listSessions: adminProcedure that returns sessions joined with user data, ordered by creation date - revokeSession: adminProcedure that deletes a session by ID (blocks self-revocation) Frontend: - New /dashboard/settings/sessions page with ShowSessions component - Sessions table with user, IP, device, timestamps, and revoke action - Sidebar link under Users (gated by member.read permission) Tests: - Input validation tests for revokeSession schema --- .../__test__/api/session-management.test.ts | 23 ++ .../settings/sessions/show-sessions.tsx | 206 ++++++++++++++++++ apps/dokploy/components/layouts/side.tsx | 7 + .../pages/dashboard/settings/sessions.tsx | 82 +++++++ apps/dokploy/server/api/routers/user.ts | 46 +++- 5 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 apps/dokploy/__test__/api/session-management.test.ts create mode 100644 apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx create mode 100644 apps/dokploy/pages/dashboard/settings/sessions.tsx 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 }) => { From c8b87c7105506bbb604fe187fc2f17d7e4dad312 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:15:03 +0000 Subject: [PATCH 2/5] [autofix.ci] apply automated fixes --- .../settings/notifications/handle-notifications.tsx | 3 +-- .../dashboard/settings/sessions/show-sessions.tsx | 11 ++++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx index c2e249c2d..ddcef338e 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx @@ -1932,8 +1932,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
Docker Cleanup - Trigger the action when Docker cleanup is - performed. + Trigger the action when Docker cleanup is performed.
diff --git a/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx b/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx index 35c174dbb..8b8bab0dc 100644 --- a/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx +++ b/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx @@ -22,8 +22,11 @@ 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 { + data: sessions, + isPending, + refetch, + } = api.user.listSessions.useQuery(); const { mutateAsync: revoke, isPending: isRevoking } = api.user.revokeSession.useMutation(); @@ -160,9 +163,7 @@ const SessionTable = ({ {s.ipAddress || "-"} - {s.userAgent - ? parseUserAgent(s.userAgent) - : "-"} + {s.userAgent ? parseUserAgent(s.userAgent) : "-"} {format(new Date(s.createdAt), "MMM d, HH:mm")} From f6c1e7727902cbd5b149d15a1e46e9dad197e857 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 13 Aug 2026 01:22:49 -0600 Subject: [PATCH 3/5] feat: implement session management with filtering and sorting capabilities --- .../settings/sessions/show-sessions.tsx | 455 +++++++++++++----- apps/dokploy/components/layouts/side.tsx | 13 +- .../pages/dashboard/settings/sessions.tsx | 18 - apps/dokploy/server/api/routers/user.ts | 57 ++- packages/server/src/lib/access-control.ts | 3 +- 5 files changed, 393 insertions(+), 153 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx b/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx index 8b8bab0dc..f5c018459 100644 --- a/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx +++ b/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx @@ -1,6 +1,18 @@ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; import { format } from "date-fns"; -import { Loader2, LogOut, Smartphone } from "lucide-react"; +import { ArrowUpDown, Loader2, LogOut, Smartphone } from "lucide-react"; +import { useMemo, useState } from "react"; import { toast } from "sonner"; +import { DialogAction } from "@/components/shared/dialog-action"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -10,6 +22,14 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Table, TableBody, @@ -19,9 +39,44 @@ import { TableRow, } from "@/components/ui/table"; import { api } from "@/utils/api"; -import { DialogAction } from "@/components/shared/dialog-action"; + +type SessionRow = { + id: string; + userId: string; + email: string; + firstName: string | null; + lastName: string | null; + ipAddress: string | null; + userAgent: string | null; + createdAt: Date; + expiresAt: Date; + isCurrent: boolean; +}; + +const SortableHeader = ({ + column, + title, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; +}) => ( + +); export const ShowSessions = () => { + const { data: auth } = api.user.get.useQuery(); + const isOwner = auth?.role === "owner"; const { data: sessions, isPending, @@ -30,6 +85,18 @@ export const ShowSessions = () => { const { mutateAsync: revoke, isPending: isRevoking } = api.user.revokeSession.useMutation(); + const [statusFilter, setStatusFilter] = useState< + "all" | "active" | "expired" + >("all"); + const [globalFilter, setGlobalFilter] = useState(""); + const [sorting, setSorting] = useState([ + { id: "createdAt", desc: true }, + ]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const handleRevoke = async (sessionId: string) => { try { await revoke({ sessionId }); @@ -42,16 +109,171 @@ export const ShowSessions = () => { } }; - const activeSessions = sessions?.filter( - (s) => new Date(s.expiresAt) > new Date(), - ); - const expiredSessions = sessions?.filter( - (s) => new Date(s.expiresAt) <= new Date(), + const filteredData = useMemo(() => { + let list = sessions ?? []; + + if (statusFilter !== "all") { + list = list.filter((s) => { + const isExpired = new Date(s.expiresAt) <= new Date(); + return statusFilter === "expired" ? isExpired : !isExpired; + }); + } + + if (globalFilter.trim()) { + const query = globalFilter.toLowerCase(); + list = list.filter((s) => + [ + s.firstName, + s.lastName, + s.email, + s.ipAddress, + s.userAgent ? parseUserAgent(s.userAgent) : null, + ] + .filter(Boolean) + .some((field) => field?.toLowerCase().includes(query)), + ); + } + + return list; + }, [sessions, statusFilter, globalFilter]); + + const columns = useMemo[]>( + () => [ + { + id: "user", + accessorFn: (row) => `${row.firstName} ${row.lastName} ${row.email}`, + header: ({ column }) => , + cell: ({ row }) => ( +
+ + {row.original.firstName} {row.original.lastName} + + + ({row.original.email}) + + {row.original.isCurrent && ( + + Current + + )} +
+ ), + }, + { + accessorKey: "ipAddress", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.ipAddress || "-"} + + ), + meta: { className: "hidden md:table-cell" }, + }, + { + id: "device", + accessorFn: (row) => + row.userAgent ? parseUserAgent(row.userAgent) : "", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.userAgent + ? parseUserAgent(row.original.userAgent) + : "-"} + + ), + meta: { className: "hidden lg:table-cell" }, + }, + { + id: "status", + accessorFn: (row) => (new Date(row.expiresAt) <= new Date() ? 1 : 0), + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const isExpired = new Date(row.original.expiresAt) <= new Date(); + return isExpired ? ( + + Expired + + ) : ( + + Active + + ); + }, + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {format(new Date(row.original.createdAt), "MMM d, HH:mm")} + + ), + }, + { + accessorKey: "expiresAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {format(new Date(row.original.expiresAt), "MMM d, HH:mm")} + + ), + }, + { + id: "actions", + enableSorting: false, + header: () =>
Actions
, + cell: ({ row }) => + !row.original.isCurrent && ( +
+ handleRevoke(row.original.id)} + > + + +
+ ), + }, + ], + [isRevoking, handleRevoke], ); + const table = useReactTable({ + data: filteredData, + columns, + state: { + sorting, + pagination, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + return (
- +
@@ -59,10 +281,12 @@ export const ShowSessions = () => { Sessions - Manage active user sessions. Revoke sessions to force logout. + {isOwner + ? "Manage active sessions across your organization. Revoke sessions to force logout." + : "Manage your active sessions. Revoke sessions to force logout."} - + {isPending ? (
Loading... @@ -77,28 +301,108 @@ export const ShowSessions = () => {
) : ( <> - {activeSessions && activeSessions.length > 0 && ( -
-

- Active Sessions ({activeSessions.length}) -

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

- Expired Sessions ({expiredSessions.length}) -

- +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No sessions match your filters. + + + )} + +
+
+ {table.getPageCount() > 1 && ( +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + +
+ + +
)} @@ -110,93 +414,6 @@ export const ShowSessions = () => { ); }; -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"; diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 03101e78f..2d9731224 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -297,6 +297,12 @@ const MENU: Menu = { url: "/dashboard/settings/profile", icon: User, }, + { + isSingle: true, + title: "Sessions", + icon: LogIn, + url: "/dashboard/settings/sessions", + }, { isSingle: true, title: "Remote Servers", @@ -320,13 +326,6 @@ 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 index d777ff67c..fdde096df 100644 --- a/apps/dokploy/pages/dashboard/settings/sessions.tsx +++ b/apps/dokploy/pages/dashboard/settings/sessions.tsx @@ -6,15 +6,8 @@ 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 (
@@ -58,17 +51,6 @@ export async function getServerSideProps( 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(), diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index b1ed52796..0dd06c6e0 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -286,7 +286,9 @@ export const userRouter = createTRPCRouter({ }); } }), - listSessions: adminProcedure.query(async ({ ctx }) => { + listSessions: protectedProcedure.query(async ({ ctx }) => { + const isOwner = ctx.user.role === "owner"; + const sessions = await db .select({ id: session.id, @@ -298,23 +300,50 @@ export const userRouter = createTRPCRouter({ 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)) + .innerJoin( + member, + and( + eq(member.userId, session.userId), + eq(member.organizationId, ctx.session.activeOrganizationId), + ), + ) + .where(isOwner ? undefined : eq(session.userId, ctx.user.id)) .orderBy(desc(session.createdAt)); - return sessions; + + return sessions.map((s) => ({ + ...s, + isCurrent: s.id === ctx.session.id, + })); }), - revokeSession: adminProcedure + revokeSession: protectedProcedure .input( z.object({ sessionId: z.string(), }), ) .mutation(async ({ input, ctx }) => { - const targetSession = await db.query.session.findFirst({ - where: eq(session.id, input.sessionId), - }); + const isOwner = ctx.user.role === "owner"; + + const [targetSession] = await db + .select({ + id: session.id, + userId: session.userId, + userEmail: user.email, + }) + .from(session) + .innerJoin(user, eq(session.userId, user.id)) + .innerJoin( + member, + and( + eq(member.userId, session.userId), + eq(member.organizationId, ctx.session.activeOrganizationId), + ), + ) + .where(eq(session.id, input.sessionId)); + if (!targetSession) { throw new TRPCError({ code: "NOT_FOUND", @@ -327,7 +356,21 @@ export const userRouter = createTRPCRouter({ message: "Cannot revoke your own session. Use logout instead.", }); } + if (!isOwner && targetSession.userId !== ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can only revoke your own sessions", + }); + } await db.delete(session).where(eq(session.id, input.sessionId)); + + await audit(ctx, { + action: "logout", + resourceType: "session", + resourceId: targetSession.id, + resourceName: targetSession.userEmail, + }); + return true; }), getUserByToken: publicProcedure diff --git a/packages/server/src/lib/access-control.ts b/packages/server/src/lib/access-control.ts index 1242e725f..2d9ceccf0 100644 --- a/packages/server/src/lib/access-control.ts +++ b/packages/server/src/lib/access-control.ts @@ -202,6 +202,5 @@ export const memberRole = ac.newRole({ auditLog: [], // Members need provider/secret names for env editor autocomplete; values are never exposed vaultProvider: ["read"], - // Members can see configured DNS providers to pick one when adding a domain - dnsProvider: ["read"], + dnsProvider: [], }); From a2329aed2668cddd18585ce830a360d72cd906bf Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 13 Aug 2026 01:25:15 -0600 Subject: [PATCH 4/5] feat: add user filtering to session management and update search functionality --- .../settings/sessions/show-sessions.tsx | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx b/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx index f5c018459..6f0eab1e2 100644 --- a/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx +++ b/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx @@ -84,10 +84,14 @@ export const ShowSessions = () => { } = api.user.listSessions.useQuery(); const { mutateAsync: revoke, isPending: isRevoking } = api.user.revokeSession.useMutation(); + const { data: members } = api.user.all.useQuery(undefined, { + enabled: isOwner, + }); const [statusFilter, setStatusFilter] = useState< "all" | "active" | "expired" >("all"); + const [userFilter, setUserFilter] = useState("all"); const [globalFilter, setGlobalFilter] = useState(""); const [sorting, setSorting] = useState([ { id: "createdAt", desc: true }, @@ -119,23 +123,21 @@ export const ShowSessions = () => { }); } + if (isOwner && userFilter !== "all") { + list = list.filter((s) => s.userId === userFilter); + } + if (globalFilter.trim()) { const query = globalFilter.toLowerCase(); list = list.filter((s) => - [ - s.firstName, - s.lastName, - s.email, - s.ipAddress, - s.userAgent ? parseUserAgent(s.userAgent) : null, - ] + [s.ipAddress, s.userAgent ? parseUserAgent(s.userAgent) : null] .filter(Boolean) .some((field) => field?.toLowerCase().includes(query)), ); } return list; - }, [sessions, statusFilter, globalFilter]); + }, [sessions, statusFilter, userFilter, isOwner, globalFilter]); const columns = useMemo[]>( () => [ @@ -303,7 +305,7 @@ export const ShowSessions = () => { <>
setGlobalFilter(e.target.value)} className="max-w-xs" @@ -323,6 +325,22 @@ export const ShowSessions = () => { Expired + {isOwner && ( + + )}
From c61163d721ee84959be06757ea516ba42ddfe1ab Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 13 Aug 2026 01:31:19 -0600 Subject: [PATCH 5/5] feat: update icons for Sessions and S3 Destinations in the sidebar menu --- apps/dokploy/components/layouts/side.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 2d9731224..2c7d82608 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -14,12 +14,12 @@ import { ClipboardList, Clock, CreditCard, - Database, Folder, Forward, GalleryVerticalEnd, GitBranch, Globe, + HardDrive, House, Key, KeyRound, @@ -31,6 +31,7 @@ import { Rocket, Server, ShieldCheck, + Smartphone, Star, Tags, Trash2, @@ -300,7 +301,7 @@ const MENU: Menu = { { isSingle: true, title: "Sessions", - icon: LogIn, + icon: Smartphone, url: "/dashboard/settings/sessions", }, { @@ -388,7 +389,7 @@ const MENU: Menu = { isSingle: true, title: "S3 Destinations", url: "/dashboard/settings/destinations", - icon: Database, + icon: HardDrive, isEnabled: ({ permissions }) => !!permissions?.destination.read, },