From f6c1e7727902cbd5b149d15a1e46e9dad197e857 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 13 Aug 2026 01:22:49 -0600 Subject: [PATCH] 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: [], });