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..6f0eab1e2 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx @@ -0,0 +1,442 @@ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { format } from "date-fns"; +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 { + Card, + CardContent, + CardDescription, + 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, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/utils/api"; + +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, + refetch, + } = 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 }, + ]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + 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 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 (isOwner && userFilter !== "all") { + list = list.filter((s) => s.userId === userFilter); + } + + if (globalFilter.trim()) { + const query = globalFilter.toLowerCase(); + list = list.filter((s) => + [s.ipAddress, s.userAgent ? parseUserAgent(s.userAgent) : null] + .filter(Boolean) + .some((field) => field?.toLowerCase().includes(query)), + ); + } + + return list; + }, [sessions, statusFilter, userFilter, isOwner, 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 ( +
+ +
+ + + + Sessions + + + {isOwner + ? "Manage active sessions across your organization. Revoke sessions to force logout." + : "Manage your active sessions. Revoke sessions to force logout."} + + + + {isPending ? ( +
+ Loading... + +
+ ) : !sessions || sessions.length === 0 ? ( +
+ + + No sessions found + +
+ ) : ( + <> +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> + + {isOwner && ( + + )} +
+
+ + + {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()} + +
+ + +
+
+ )} + + )} +
+
+
+
+ ); +}; + +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 91d3de817..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, @@ -297,6 +298,12 @@ const MENU: Menu = { url: "/dashboard/settings/profile", icon: User, }, + { + isSingle: true, + title: "Sessions", + icon: Smartphone, + url: "/dashboard/settings/sessions", + }, { isSingle: true, title: "Remote Servers", @@ -382,7 +389,7 @@ const MENU: Menu = { isSingle: true, title: "S3 Destinations", url: "/dashboard/settings/destinations", - icon: Database, + icon: HardDrive, isEnabled: ({ permissions }) => !!permissions?.destination.read, }, diff --git a/apps/dokploy/pages/dashboard/settings/sessions.tsx b/apps/dokploy/pages/dashboard/settings/sessions.tsx new file mode 100644 index 000000000..fdde096df --- /dev/null +++ b/apps/dokploy/pages/dashboard/settings/sessions.tsx @@ -0,0 +1,64 @@ +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"; + +const Page = () => { + 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(); + + 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 4f90b2cdc..0dd06c6e0 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -34,7 +34,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"; @@ -286,6 +286,93 @@ export const userRouter = createTRPCRouter({ }); } }), + listSessions: protectedProcedure.query(async ({ ctx }) => { + const isOwner = ctx.user.role === "owner"; + + 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, + }) + .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.map((s) => ({ + ...s, + isCurrent: s.id === ctx.session.id, + })); + }), + revokeSession: protectedProcedure + .input( + z.object({ + sessionId: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + 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", + 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.", + }); + } + 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 .input(apiFindOneToken) .query(async ({ input }) => { 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: [], });