Merge pull request #4841 from daniloneto/feat/session-management

feat: add session management page (list active sessions + revoke)
This commit is contained in:
Mauricio Siu
2026-08-13 01:32:03 -06:00
committed by GitHub
6 changed files with 627 additions and 5 deletions
@@ -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);
});
});
@@ -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;
}) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="ml-2 size-3.5" />
</Button>
);
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<SortingState>([
{ id: "createdAt", desc: true },
]);
const [pagination, setPagination] = useState<PaginationState>({
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<ColumnDef<SessionRow>[]>(
() => [
{
id: "user",
accessorFn: (row) => `${row.firstName} ${row.lastName} ${row.email}`,
header: ({ column }) => <SortableHeader column={column} title="User" />,
cell: ({ row }) => (
<div className="flex items-center gap-1">
<span>
{row.original.firstName} {row.original.lastName}
</span>
<span className="text-muted-foreground">
({row.original.email})
</span>
{row.original.isCurrent && (
<Badge variant="secondary" className="ml-1 text-xs">
Current
</Badge>
)}
</div>
),
},
{
accessorKey: "ipAddress",
header: ({ column }) => (
<SortableHeader column={column} title="IP Address" />
),
cell: ({ row }) => (
<span className="font-mono text-sm">
{row.original.ipAddress || "-"}
</span>
),
meta: { className: "hidden md:table-cell" },
},
{
id: "device",
accessorFn: (row) =>
row.userAgent ? parseUserAgent(row.userAgent) : "",
header: ({ column }) => (
<SortableHeader column={column} title="Device" />
),
cell: ({ row }) => (
<span className="max-w-[200px] truncate text-sm text-muted-foreground">
{row.original.userAgent
? parseUserAgent(row.original.userAgent)
: "-"}
</span>
),
meta: { className: "hidden lg:table-cell" },
},
{
id: "status",
accessorFn: (row) => (new Date(row.expiresAt) <= new Date() ? 1 : 0),
header: ({ column }) => (
<SortableHeader column={column} title="Status" />
),
cell: ({ row }) => {
const isExpired = new Date(row.original.expiresAt) <= new Date();
return isExpired ? (
<Badge variant="outline" className="text-xs">
Expired
</Badge>
) : (
<Badge variant="green" className="text-xs">
Active
</Badge>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<SortableHeader column={column} title="Active Since" />
),
cell: ({ row }) => (
<span className="text-sm text-muted-foreground whitespace-nowrap">
{format(new Date(row.original.createdAt), "MMM d, HH:mm")}
</span>
),
},
{
accessorKey: "expiresAt",
header: ({ column }) => (
<SortableHeader column={column} title="Expires" />
),
cell: ({ row }) => (
<span className="text-sm text-muted-foreground whitespace-nowrap">
{format(new Date(row.original.expiresAt), "MMM d, HH:mm")}
</span>
),
},
{
id: "actions",
enableSorting: false,
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) =>
!row.original.isCurrent && (
<div className="flex justify-end">
<DialogAction
title="Revoke Session"
description={`Force logout for ${row.original.firstName} ${row.original.lastName} (${row.original.email})?`}
type="destructive"
onClick={async () => handleRevoke(row.original.id)}
>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={isRevoking}
>
<LogOut className="h-4 w-4 text-red-500" />
</Button>
</DialogAction>
</div>
),
},
],
[isRevoking, handleRevoke],
);
const table = useReactTable({
data: filteredData,
columns,
state: {
sorting,
pagination,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Smartphone className="size-6 text-muted-foreground self-center" />
Sessions
</CardTitle>
<CardDescription>
{isOwner
? "Manage active sessions across your organization. Revoke sessions to force logout."
: "Manage your active sessions. Revoke sessions to force logout."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !sessions || sessions.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<Smartphone className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
No sessions found
</span>
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by IP, device..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select
value={statusFilter}
onValueChange={(v) =>
setStatusFilter(v as "all" | "active" | "expired")
}
>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="expired">Expired</SelectItem>
</SelectContent>
</Select>
{isOwner && (
<Select value={userFilter} onValueChange={setUserFilter}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="User" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All users</SelectItem>
{members?.map((m) => (
<SelectItem key={m.user.id} value={m.user.id}>
{m.user.firstName} {m.user.lastName} ({m.user.email}
)
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={
(header.column.columnDef.meta as any)?.className
}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={
(cell.column.columnDef.meta as any)?.className
}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No sessions match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
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 ? "..." : "");
}
+9 -2
View File
@@ -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,
},
@@ -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 (
<div className="flex flex-col gap-4 w-full">
<ShowSessions />
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Sessions">{page}</DashboardLayout>;
};
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: {},
};
}
}
+88 -1
View File
@@ -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 }) => {
+1 -2
View File
@@ -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: [],
});