mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 05:11:26 +00:00
Merge pull request #4841 from daniloneto/feat/session-management
feat: add session management page (list active sessions + revoke)
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,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 ? "..." : "");
|
||||
}
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user