show resources table, check org access, and handle redirects on root

This commit is contained in:
Milo Schwartz 2024-10-19 15:49:16 -04:00
parent edde7a247a
commit f6c7c017cb
No known key found for this signature in database
14 changed files with 416 additions and 95 deletions

View file

@ -91,7 +91,6 @@ declare global {
interface Request { interface Request {
user?: User; user?: User;
userOrgRoleId?: number; userOrgRoleId?: number;
orgId?: string;
userOrgId?: string; userOrgId?: string;
userOrgIds?: string[]; userOrgIds?: string[];
} }

View file

@ -1,39 +1,56 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from "express";
import { z } from 'zod'; import { z } from "zod";
import { db } from '@server/db'; import { db } from "@server/db";
import { orgs } from '@server/db/schema'; import { Org, orgs } from "@server/db/schema";
import { eq } from 'drizzle-orm'; import { eq } from "drizzle-orm";
import response from "@server/utils/response"; import response from "@server/utils/response";
import HttpCode from '@server/types/HttpCode'; import HttpCode from "@server/types/HttpCode";
import createHttpError from 'http-errors'; import createHttpError from "http-errors";
import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions'; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import logger from '@server/logger'; import logger from "@server/logger";
const getOrgSchema = z.object({ const getOrgSchema = z.object({
orgId: z.string() orgId: z.string(),
}); });
export async function getOrg(req: Request, res: Response, next: NextFunction): Promise<any> { export type GetOrgResponse = {
org: Org;
}
export async function getOrg(
req: Request,
res: Response,
next: NextFunction,
): Promise<any> {
try { try {
const parsedParams = getOrgSchema.safeParse(req.params); const parsedParams = getOrgSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
parsedParams.error.errors.map(e => e.message).join(', ') parsedParams.error.errors.map((e) => e.message).join(", "),
) ),
); );
} }
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
// Check if the user has permission to list sites // Check if the user has permission to list sites
const hasPermission = await checkUserActionPermission(ActionsEnum.getOrg, req); const hasPermission = await checkUserActionPermission(
ActionsEnum.getOrg,
req,
);
if (!hasPermission) { if (!hasPermission) {
return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have permission to perform this action')); return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission to perform this action",
),
);
} }
const org = await db.select() const org = await db
.select()
.from(orgs) .from(orgs)
.where(eq(orgs.orgId, orgId)) .where(eq(orgs.orgId, orgId))
.limit(1); .limit(1);
@ -42,13 +59,15 @@ export async function getOrg(req: Request, res: Response, next: NextFunction): P
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found` `Organization with ID ${orgId} not found`,
) ),
); );
} }
return response(res, { return response<GetOrgResponse>(res, {
data: org[0], data: {
org: org[0],
},
success: true, success: true,
error: false, error: false,
message: "Organization retrieved successfully", message: "Organization retrieved successfully",
@ -56,6 +75,11 @@ export async function getOrg(req: Request, res: Response, next: NextFunction): P
}); });
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred...")); return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred...",
),
);
} }
} }

View file

@ -1,45 +1,69 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from "express";
import { z } from 'zod'; import { z } from "zod";
import { db } from '@server/db'; import { db } from "@server/db";
import { orgs } from '@server/db/schema'; import { Org, orgs } from "@server/db/schema";
import response from "@server/utils/response"; import response from "@server/utils/response";
import HttpCode from '@server/types/HttpCode'; import HttpCode from "@server/types/HttpCode";
import createHttpError from 'http-errors'; import createHttpError from "http-errors";
import { sql, inArray } from 'drizzle-orm'; import { sql, inArray } from "drizzle-orm";
import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions'; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import logger from '@server/logger'; import logger from "@server/logger";
const listOrgsSchema = z.object({ const listOrgsSchema = z.object({
limit: z.string().optional().transform(Number).pipe(z.number().int().positive().default(10)), limit: z
offset: z.string().optional().transform(Number).pipe(z.number().int().nonnegative().default(0)), .string()
.optional()
.transform(Number)
.pipe(z.number().int().positive().default(10)),
offset: z
.string()
.optional()
.transform(Number)
.pipe(z.number().int().nonnegative().default(0)),
}); });
export async function listOrgs(req: Request, res: Response, next: NextFunction): Promise<any> { export type ListOrgsResponse = {
organizations: Org[];
pagination: { total: number; limit: number; offset: number };
};
export async function listOrgs(
req: Request,
res: Response,
next: NextFunction,
): Promise<any> {
try { try {
const parsedQuery = listOrgsSchema.safeParse(req.query); const parsedQuery = listOrgsSchema.safeParse(req.query);
if (!parsedQuery.success) { if (!parsedQuery.success) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
parsedQuery.error.errors.map(e => e.message).join(', ') parsedQuery.error.errors.map((e) => e.message).join(", "),
) ),
); );
} }
const { limit, offset } = parsedQuery.data; const { limit, offset } = parsedQuery.data;
// Check if the user has permission to list sites // Check if the user has permission to list sites
const hasPermission = await checkUserActionPermission(ActionsEnum.listOrgs, req); const hasPermission = await checkUserActionPermission(
ActionsEnum.listOrgs,
req,
);
if (!hasPermission) { if (!hasPermission) {
return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have permission to perform this action')); return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission to perform this action",
),
);
} }
// Use the userOrgs passed from the middleware // Use the userOrgs passed from the middleware
const userOrgIds = req.userOrgIds; const userOrgIds = req.userOrgIds;
if (!userOrgIds || userOrgIds.length === 0) { if (!userOrgIds || userOrgIds.length === 0) {
return res.status(HttpCode.OK).send( return response<ListOrgsResponse>(res, {
response(res, {
data: { data: {
organizations: [], organizations: [],
pagination: { pagination: {
@ -52,28 +76,23 @@ export async function listOrgs(req: Request, res: Response, next: NextFunction):
error: false, error: false,
message: "No organizations found for the user", message: "No organizations found for the user",
status: HttpCode.OK, status: HttpCode.OK,
}) });
);
} }
const organizations = await db.select() const organizations = await db
.select()
.from(orgs) .from(orgs)
.where(inArray(orgs.orgId, userOrgIds)) .where(inArray(orgs.orgId, userOrgIds))
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);
const totalCountResult = await db.select({ count: sql<number>`cast(count(*) as integer)` }) const totalCountResult = await db
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(orgs) .from(orgs)
.where(inArray(orgs.orgId, userOrgIds)); .where(inArray(orgs.orgId, userOrgIds));
const totalCount = totalCountResult[0].count; const totalCount = totalCountResult[0].count;
// // Add the user's role for each organization return response<ListOrgsResponse>(res, {
// const organizationsWithRoles = organizations.map(org => ({
// ...org,
// userRole: req.userOrgRoleIds[org.orgId],
// }));
return response(res, {
data: { data: {
organizations, organizations,
pagination: { pagination: {
@ -89,6 +108,11 @@ export async function listOrgs(req: Request, res: Response, next: NextFunction):
}); });
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred...")); return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred...",
),
);
} }
} }

View file

@ -79,7 +79,7 @@ function queryResources(
} }
} }
export type ListSitesResponse = { export type ListResourcesResponse = {
resources: NonNullable<Awaited<ReturnType<typeof queryResources>>>; resources: NonNullable<Awaited<ReturnType<typeof queryResources>>>;
pagination: { total: number; limit: number; offset: number }; pagination: { total: number; limit: number; offset: number };
}; };
@ -126,7 +126,7 @@ export async function listResources(
); );
} }
if (orgId && orgId !== req.orgId) { if (orgId && orgId !== req.userOrgId) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
@ -167,7 +167,7 @@ export async function listResources(
const totalCountResult = await countQuery; const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count; const totalCount = totalCountResult[0].count;
return response<ListSitesResponse>(res, { return response<ListResourcesResponse>(res, {
data: { data: {
resources: resourcesList, resources: resourcesList,
pagination: { pagination: {

View file

@ -77,7 +77,7 @@ export default function Header({ email, orgName, name }: HeaderProps) {
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<span className="truncate max-w-[150px] md:max-w-none"> <span className="truncate max-w-[150px] md:max-w-none font-medium">
{name || email} {name || email}
</span> </span>
</div> </div>

View file

@ -5,6 +5,10 @@ import Header from "./components/Header";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
import { internal } from "@app/api";
import { AxiosResponse } from "axios";
import { GetOrgResponse } from "@server/routers/org";
import { authCookieHeader } from "@app/api/cookies";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Configuration", title: "Configuration",
@ -15,22 +19,22 @@ const topNavItems = [
{ {
title: "Sites", title: "Sites",
href: "/{orgId}/sites", href: "/{orgId}/sites",
icon: <Combine className="h-5 w-5"/>, icon: <Combine className="h-5 w-5" />,
}, },
{ {
title: "Resources", title: "Resources",
href: "/{orgId}/resources", href: "/{orgId}/resources",
icon: <Waypoints className="h-5 w-5"/>, icon: <Waypoints className="h-5 w-5" />,
}, },
{ {
title: "Users", title: "Users",
href: "/{orgId}/users", href: "/{orgId}/users",
icon: <Users className="h-5 w-5"/>, icon: <Users className="h-5 w-5" />,
}, },
{ {
title: "General", title: "General",
href: "/{orgId}/general", href: "/{orgId}/general",
icon: <Cog className="h-5 w-5"/>, icon: <Cog className="h-5 w-5" />,
}, },
]; ];
@ -43,14 +47,21 @@ export default async function ConfigurationLaytout({
children, children,
params, params,
}: ConfigurationLaytoutProps) { }: ConfigurationLaytoutProps) {
const loadUser = cache(async () => await verifySession()); const user = await verifySession();
const user = await loadUser();
if (!user) { if (!user) {
redirect("/auth/login"); redirect("/auth/login");
} }
try {
await internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
authCookieHeader(),
);
} catch {
redirect(`/`);
}
return ( return (
<> <>
<div className="w-full bg-muted mb-6 select-none sm:px-0 px-3 pt-3"> <div className="w-full bg-muted mb-6 select-none sm:px-0 px-3 pt-3">

View file

@ -1,7 +1,11 @@
export default async function Page() { import { redirect } from "next/navigation";
return (
<> type OrgPageProps = {
<p>IDK what this will show...</p> params: { orgId: string };
</> };
);
export default async function Page({ params }: OrgPageProps) {
redirect(`/${params.orgId}/sites`);
return <></>;
} }

View file

@ -0,0 +1,142 @@
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@app/components/ui/button";
import { useState } from "react";
import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Plus } from "lucide-react";
interface ResourcesDataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
addResource?: () => void;
}
export function ResourcesDataTable<TData, TValue>({
addResource,
columns,
data,
}: ResourcesDataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
state: {
sorting,
columnFilters,
},
});
return (
<div>
<div className="flex items-center justify-between pb-4">
<Input
placeholder="Search your resources"
value={
(table.getColumn("name")?.getFilterValue() as string) ??
""
}
onChange={(event) =>
table
.getColumn("name")
?.setFilterValue(event.target.value)
}
className="max-w-sm mr-2"
/>
<Button
onClick={() => {
if (addResource) {
addResource();
}
}}
>
<Plus className="mr-2 h-4 w-4" /> Add Resource
</Button>
</div>
<div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{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}
data-state={
row.getIsSelected() && "selected"
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No resources. Create one to get started.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="mt-4">
<DataTablePagination table={table} />
</div>
</div>
);
}

View file

@ -0,0 +1,84 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ResourcesDataTable } from "./ResourcesDataTable";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
export type ResourceRow = {
id: string;
name: string;
orgId: string;
};
export const columns: ColumnDef<ResourceRow>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
},
{
id: "actions",
cell: ({ row }) => {
const resourceRow = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Link
href={`/${resourceRow.orgId}/resources/${resourceRow.id}`}
>
View settings
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
type ResourcesTableProps = {
resources: ResourceRow[];
orgId: string;
};
export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const router = useRouter();
return (
<ResourcesDataTable
columns={columns}
data={resources}
addResource={() => {
router.push(`/${orgId}/resources/create`);
}}
/>
);
}

View file

@ -1,14 +1,45 @@
export default async function Page() { import { internal } from "@app/api";
import { authCookieHeader } from "@app/api/cookies";
import ResourcesTable, { ResourceRow } from "./components/ResourcesTable";
import { AxiosResponse } from "axios";
import { ListResourcesResponse } from "@server/routers/resource";
type ResourcesPageProps = {
params: { orgId: string };
};
export default async function Page({ params }: ResourcesPageProps) {
let resources: ListResourcesResponse["resources"] = [];
try {
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
`/org/${params.orgId}/resources`,
authCookieHeader(),
);
resources = res.data.data.resources;
} catch (e) {
console.error("Error fetching resources", e);
}
const resourceRows: ResourceRow[] = resources.map((resource) => {
return {
id: resource.resourceId.toString(),
name: resource.name,
orgId: params.orgId,
};
});
return ( return (
<> <>
<div className="space-y-0.5 select-none"> <div className="space-y-0.5 select-none mb-6">
<h2 className="text-2xl font-bold tracking-tight"> <h2 className="text-2xl font-bold tracking-tight">
Manage Resources Manage Resources
</h2> </h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Create secure proxies to your private resources. Create secure proxies to your private applications.
</p> </p>
</div> </div>
<ResourcesTable resources={resourceRows} orgId={params.orgId} />
</> </>
); );
} }

View file

@ -177,13 +177,13 @@ sh get-docker.sh`;
/> />
{form.watch("method") === "wg" && !isLoading ? ( {form.watch("method") === "wg" && !isLoading ? (
<pre className="mt-2 w-full rounded-md bg-muted p-4 overflow-x-auto"> <pre className="mt-2 w-full rounded-md bg-muted p-4 overflow-x-auto">
<code className="text-white whitespace-pre-wrap font-mono">{wgConfig}</code> <code className="whitespace-pre-wrap font-mono">{wgConfig}</code>
</pre> </pre>
) : form.watch("method") === "wg" && isLoading ? ( ) : form.watch("method") === "wg" && isLoading ? (
<p>Loading WireGuard configuration...</p> <p>Loading WireGuard configuration...</p>
) : ( ) : (
<pre className="mt-2 w-full rounded-md bg-muted p-4 overflow-x-auto"> <pre className="mt-2 w-full rounded-md bg-muted p-4 overflow-x-auto">
<code className="text-white whitespace-pre-wrap">{newtConfig}</code> <code className="whitespace-pre-wrap">{newtConfig}</code>
</pre> </pre>
)} )}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">

View file

@ -23,7 +23,7 @@ import {
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { useState } from "react"; import { useState } from "react";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "./DataTablePagination"; import { DataTablePagination } from "../../../../components/DataTablePagination";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
@ -32,7 +32,7 @@ interface DataTableProps<TData, TValue> {
addSite?: () => void; addSite?: () => void;
} }
export function DataTable<TData, TValue>({ export function SitesDataTable<TData, TValue>({
addSite, addSite,
columns, columns,
data, data,
@ -59,7 +59,7 @@ export function DataTable<TData, TValue>({
<div> <div>
<div className="flex items-center justify-between pb-4"> <div className="flex items-center justify-between pb-4">
<Input <Input
placeholder="Search sites" placeholder="Search your sites"
value={ value={
(table.getColumn("name")?.getFilterValue() as string) ?? (table.getColumn("name")?.getFilterValue() as string) ??
"" ""
@ -71,11 +71,13 @@ export function DataTable<TData, TValue>({
} }
className="max-w-sm mr-2" className="max-w-sm mr-2"
/> />
<Button onClick={() => { <Button
onClick={() => {
if (addSite) { if (addSite) {
addSite(); addSite();
} }
}}> }}
>
<Plus className="mr-2 h-4 w-4" /> Add Site <Plus className="mr-2 h-4 w-4" /> Add Site
</Button> </Button>
</div> </div>

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "./DataTable"; import { SitesDataTable } from "./SitesDataTable";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -99,7 +99,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
const router = useRouter(); const router = useRouter();
return ( return (
<DataTable <SitesDataTable
columns={columns} columns={columns}
data={sites} data={sites}
addSite={() => { addSite={() => {