mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-11 14:35:17 +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
|
// Only enabled for users with member.read permission
|
||||||
isEnabled: ({ permissions }) => !!permissions?.member.read,
|
isEnabled: ({ permissions }) => !!permissions?.member.read,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
isSingle: true,
|
||||||
|
title: "Sessions",
|
||||||
|
icon: LogIn,
|
||||||
|
url: "/dashboard/settings/sessions",
|
||||||
|
isEnabled: ({ permissions }) => !!permissions?.member.read,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
title: "Audit Logs",
|
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 { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import * as bcrypt from "bcrypt";
|
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 { z } from "zod";
|
||||||
import { apiKeyNameSchema } from "@/lib/api-keys";
|
import { apiKeyNameSchema } from "@/lib/api-keys";
|
||||||
import { audit } from "@/server/api/utils/audit";
|
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
|
getUserByToken: publicProcedure
|
||||||
.input(apiFindOneToken)
|
.input(apiFindOneToken)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user