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
This commit is contained in:
Danilo Neto
2026-07-17 13:48:54 +00:00
parent df3965a581
commit 7728e5ea9e
5 changed files with 363 additions and 1 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,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 (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl 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>
Manage active user sessions. Revoke sessions to force logout.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 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>
) : (
<>
{activeSessions && activeSessions.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-3">
Active Sessions ({activeSessions.length})
</h3>
<SessionTable
sessions={activeSessions}
onRevoke={handleRevoke}
isRevoking={isRevoking}
/>
</div>
)}
{expiredSessions && expiredSessions.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-3">
Expired Sessions ({expiredSessions.length})
</h3>
<SessionTable
sessions={expiredSessions}
onRevoke={handleRevoke}
isRevoking={isRevoking}
/>
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
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;
}) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead className="hidden md:table-cell">IP Address</TableHead>
<TableHead className="hidden lg:table-cell">Device</TableHead>
<TableHead>Active Since</TableHead>
<TableHead>Expires</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((s) => (
<TableRow key={s.id}>
<TableCell>
<div className="flex items-center gap-1">
<span>
{s.firstName} {s.lastName}
</span>
<span className="text-muted-foreground">({s.email})</span>
{s.isCurrent && (
<Badge variant="secondary" className="ml-1 text-xs">
Current
</Badge>
)}
</div>
</TableCell>
<TableCell className="hidden md:table-cell font-mono text-sm">
{s.ipAddress || "-"}
</TableCell>
<TableCell className="hidden lg:table-cell max-w-[200px] truncate text-sm text-muted-foreground">
{s.userAgent
? parseUserAgent(s.userAgent)
: "-"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{format(new Date(s.createdAt), "MMM d, HH:mm")}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{format(new Date(s.expiresAt), "MMM d, HH:mm")}
</TableCell>
<TableCell className="text-right">
{!s.isCurrent && (
<DialogAction
title="Revoke Session"
description={`Force logout for ${s.firstName} ${s.lastName} (${s.email})?`}
type="destructive"
onClick={async () => onRevoke(s.id)}
>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={isRevoking}
>
<LogOut className="h-4 w-4 text-red-500" />
</Button>
</DialogAction>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
/** 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 ? "..." : "");
}
+7
View File
@@ -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",
@@ -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 (
<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();
const userPermissions = await helpers.user.getPermissions.fetch();
if (!userPermissions?.member.read) {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
return {
props: {
trpcState: helpers.dehydrate(),
},
};
} catch {
return {
props: {},
};
}
}
+45 -1
View File
@@ -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 }) => {