mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 21:31:11 +00:00
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:
@@ -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 ? "..." : "");
|
||||
}
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user