feat: implement session management with filtering and sorting capabilities

This commit is contained in:
Mauricio Siu
2026-08-13 01:22:49 -06:00
parent dbe2b38ea9
commit f6c1e77279
5 changed files with 393 additions and 153 deletions
@@ -1,6 +1,18 @@
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { format } from "date-fns";
import { Loader2, LogOut, Smartphone } from "lucide-react";
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 {
@@ -10,6 +22,14 @@ import {
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,
@@ -19,9 +39,44 @@ import {
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
import { DialogAction } from "@/components/shared/dialog-action";
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,
@@ -30,6 +85,18 @@ export const ShowSessions = () => {
const { mutateAsync: revoke, isPending: isRevoking } =
api.user.revokeSession.useMutation();
const [statusFilter, setStatusFilter] = useState<
"all" | "active" | "expired"
>("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 });
@@ -42,16 +109,171 @@ export const ShowSessions = () => {
}
};
const activeSessions = sessions?.filter(
(s) => new Date(s.expiresAt) > new Date(),
);
const expiredSessions = sessions?.filter(
(s) => new Date(s.expiresAt) <= new Date(),
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 (globalFilter.trim()) {
const query = globalFilter.toLowerCase();
list = list.filter((s) =>
[
s.firstName,
s.lastName,
s.email,
s.ipAddress,
s.userAgent ? parseUserAgent(s.userAgent) : null,
]
.filter(Boolean)
.some((field) => field?.toLowerCase().includes(query)),
);
}
return list;
}, [sessions, statusFilter, 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-5xl mx-auto">
<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">
@@ -59,10 +281,12 @@ export const ShowSessions = () => {
Sessions
</CardTitle>
<CardDescription>
Manage active user sessions. Revoke sessions to force logout.
{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-6 py-8 border-t">
<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>
@@ -77,28 +301,108 @@ export const ShowSessions = () => {
</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 className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by user, 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>
</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>
)}
</>
@@ -110,93 +414,6 @@ export const ShowSessions = () => {
);
};
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";
+6 -7
View File
@@ -297,6 +297,12 @@ const MENU: Menu = {
url: "/dashboard/settings/profile",
icon: User,
},
{
isSingle: true,
title: "Sessions",
icon: LogIn,
url: "/dashboard/settings/sessions",
},
{
isSingle: true,
title: "Remote Servers",
@@ -320,13 +326,6 @@ 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",
@@ -6,15 +6,8 @@ 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 />
@@ -58,17 +51,6 @@ export async function getServerSideProps(
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(),
+50 -7
View File
@@ -286,7 +286,9 @@ export const userRouter = createTRPCRouter({
});
}
}),
listSessions: adminProcedure.query(async ({ ctx }) => {
listSessions: protectedProcedure.query(async ({ ctx }) => {
const isOwner = ctx.user.role === "owner";
const sessions = await db
.select({
id: session.id,
@@ -298,23 +300,50 @@ export const userRouter = createTRPCRouter({
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))
.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;
return sessions.map((s) => ({
...s,
isCurrent: s.id === ctx.session.id,
}));
}),
revokeSession: adminProcedure
revokeSession: protectedProcedure
.input(
z.object({
sessionId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const targetSession = await db.query.session.findFirst({
where: eq(session.id, input.sessionId),
});
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",
@@ -327,7 +356,21 @@ export const userRouter = createTRPCRouter({
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
+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: [],
});