Prechádzať zdrojové kódy

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

Milo Schwartz 8 mesiacov pred
rodič
commit
f6c7c017cb

+ 0 - 1
server/index.ts

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

+ 45 - 21
server/routers/org/getOrg.ts

@@ -1,39 +1,56 @@
-import { Request, Response, NextFunction } from 'express';
-import { z } from 'zod';
-import { db } from '@server/db';
-import { orgs } from '@server/db/schema';
-import { eq } from 'drizzle-orm';
+import { Request, Response, NextFunction } from "express";
+import { z } from "zod";
+import { db } from "@server/db";
+import { Org, orgs } from "@server/db/schema";
+import { eq } from "drizzle-orm";
 import response from "@server/utils/response";
-import HttpCode from '@server/types/HttpCode';
-import createHttpError from 'http-errors';
-import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions';
-import logger from '@server/logger';
+import HttpCode from "@server/types/HttpCode";
+import createHttpError from "http-errors";
+import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
+import logger from "@server/logger";
 
 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 {
         const parsedParams = getOrgSchema.safeParse(req.params);
         if (!parsedParams.success) {
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,
-                    parsedParams.error.errors.map(e => e.message).join(', ')
-                )
+                    parsedParams.error.errors.map((e) => e.message).join(", "),
+                ),
             );
         }
 
         const { orgId } = parsedParams.data;
 
         // 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) {
-            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)
             .where(eq(orgs.orgId, orgId))
             .limit(1);
@@ -42,13 +59,15 @@ export async function getOrg(req: Request, res: Response, next: NextFunction): P
             return next(
                 createHttpError(
                     HttpCode.NOT_FOUND,
-                    `Organization with ID ${orgId} not found`
-                )
+                    `Organization with ID ${orgId} not found`,
+                ),
             );
         }
 
-        return response(res, {
-            data: org[0],
+        return response<GetOrgResponse>(res, {
+            data: {
+                org: org[0],
+            },
             success: true,
             error: false,
             message: "Organization retrieved successfully",
@@ -56,6 +75,11 @@ export async function getOrg(req: Request, res: Response, next: NextFunction): P
         });
     } catch (error) {
         logger.error(error);
-        return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred..."));
+        return next(
+            createHttpError(
+                HttpCode.INTERNAL_SERVER_ERROR,
+                "An error occurred...",
+            ),
+        );
     }
 }

+ 65 - 41
server/routers/org/listOrgs.ts

@@ -1,79 +1,98 @@
-import { Request, Response, NextFunction } from 'express';
-import { z } from 'zod';
-import { db } from '@server/db';
-import { orgs } from '@server/db/schema';
+import { Request, Response, NextFunction } from "express";
+import { z } from "zod";
+import { db } from "@server/db";
+import { Org, orgs } from "@server/db/schema";
 import response from "@server/utils/response";
-import HttpCode from '@server/types/HttpCode';
-import createHttpError from 'http-errors';
-import { sql, inArray } from 'drizzle-orm';
-import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions';
-import logger from '@server/logger';
+import HttpCode from "@server/types/HttpCode";
+import createHttpError from "http-errors";
+import { sql, inArray } from "drizzle-orm";
+import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
+import logger from "@server/logger";
 
 const listOrgsSchema = z.object({
-    limit: z.string().optional().transform(Number).pipe(z.number().int().positive().default(10)),
-    offset: z.string().optional().transform(Number).pipe(z.number().int().nonnegative().default(0)),
+    limit: z
+        .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 {
         const parsedQuery = listOrgsSchema.safeParse(req.query);
         if (!parsedQuery.success) {
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,
-                    parsedQuery.error.errors.map(e => e.message).join(', ')
-                )
+                    parsedQuery.error.errors.map((e) => e.message).join(", "),
+                ),
             );
         }
 
         const { limit, offset } = parsedQuery.data;
 
         // 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) {
-            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
         const userOrgIds = req.userOrgIds;
 
         if (!userOrgIds || userOrgIds.length === 0) {
-            return res.status(HttpCode.OK).send(
-                response(res, {
-                    data: {
-                        organizations: [],
-                        pagination: {
-                            total: 0,
-                            limit,
-                            offset,
-                        },
+            return response<ListOrgsResponse>(res, {
+                data: {
+                    organizations: [],
+                    pagination: {
+                        total: 0,
+                        limit,
+                        offset,
                     },
-                    success: true,
-                    error: false,
-                    message: "No organizations found for the user",
-                    status: HttpCode.OK,
-                })
-            );
+                },
+                success: true,
+                error: false,
+                message: "No organizations found for the user",
+                status: HttpCode.OK,
+            });
         }
 
-        const organizations = await db.select()
+        const organizations = await db
+            .select()
             .from(orgs)
             .where(inArray(orgs.orgId, userOrgIds))
             .limit(limit)
             .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)
             .where(inArray(orgs.orgId, userOrgIds));
         const totalCount = totalCountResult[0].count;
 
-        // // Add the user's role for each organization
-        // const organizationsWithRoles = organizations.map(org => ({
-        //   ...org,
-        //   userRole: req.userOrgRoleIds[org.orgId],
-        // }));
-
-        return response(res, {
+        return response<ListOrgsResponse>(res, {
             data: {
                 organizations,
                 pagination: {
@@ -89,6 +108,11 @@ export async function listOrgs(req: Request, res: Response, next: NextFunction):
         });
     } catch (error) {
         logger.error(error);
-        return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred..."));
+        return next(
+            createHttpError(
+                HttpCode.INTERNAL_SERVER_ERROR,
+                "An error occurred...",
+            ),
+        );
     }
 }

+ 3 - 3
server/routers/resource/listResources.ts

@@ -79,7 +79,7 @@ function queryResources(
     }
 }
 
-export type ListSitesResponse = {
+export type ListResourcesResponse = {
     resources: NonNullable<Awaited<ReturnType<typeof queryResources>>>;
     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(
                 createHttpError(
                     HttpCode.FORBIDDEN,
@@ -167,7 +167,7 @@ export async function listResources(
         const totalCountResult = await countQuery;
         const totalCount = totalCountResult[0].count;
 
-        return response<ListSitesResponse>(res, {
+        return response<ListResourcesResponse>(res, {
             data: {
                 resources: resourcesList,
                 pagination: {

+ 1 - 1
src/app/[orgId]/components/Header.tsx

@@ -77,7 +77,7 @@ export default function Header({ email, orgName, name }: HeaderProps) {
                             </DropdownMenuGroup>
                         </DropdownMenuContent>
                     </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}
                     </span>
                 </div>

+ 18 - 7
src/app/[orgId]/layout.tsx

@@ -5,6 +5,10 @@ import Header from "./components/Header";
 import { verifySession } from "@app/lib/auth/verifySession";
 import { redirect } from "next/navigation";
 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 = {
     title: "Configuration",
@@ -15,22 +19,22 @@ const topNavItems = [
     {
         title: "Sites",
         href: "/{orgId}/sites",
-        icon: <Combine className="h-5 w-5"/>,
+        icon: <Combine className="h-5 w-5" />,
     },
     {
         title: "Resources",
         href: "/{orgId}/resources",
-        icon: <Waypoints className="h-5 w-5"/>,
+        icon: <Waypoints className="h-5 w-5" />,
     },
     {
         title: "Users",
         href: "/{orgId}/users",
-        icon: <Users className="h-5 w-5"/>,
+        icon: <Users className="h-5 w-5" />,
     },
     {
         title: "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,
     params,
 }: ConfigurationLaytoutProps) {
-    const loadUser = cache(async () => await verifySession());
-
-    const user = await loadUser();
+    const user = await verifySession();
 
     if (!user) {
         redirect("/auth/login");
     }
 
+    try {
+        await internal.get<AxiosResponse<GetOrgResponse>>(
+            `/org/${params.orgId}`,
+            authCookieHeader(),
+        );
+    } catch {
+        redirect(`/`);
+    }
+
     return (
         <>
             <div className="w-full bg-muted mb-6 select-none sm:px-0 px-3 pt-3">

+ 10 - 6
src/app/[orgId]/page.tsx

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

+ 142 - 0
src/app/[orgId]/resources/components/ResourcesDataTable.tsx

@@ -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>
+    );
+}

+ 84 - 0
src/app/[orgId]/resources/components/ResourcesTable.tsx

@@ -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`);
+            }}
+        />
+    );
+}

+ 34 - 3
src/app/[orgId]/resources/page.tsx

@@ -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 (
         <>
-            <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">
                     Manage Resources
                 </h2>
                 <p className="text-muted-foreground">
-                    Create secure proxies to your private resources.
+                    Create secure proxies to your private applications.
                 </p>
             </div>
+
+            <ResourcesTable resources={resourceRows} orgId={params.orgId} />
         </>
     );
 }

+ 2 - 2
src/app/[orgId]/sites/[niceId]/components/create-site.tsx

@@ -177,13 +177,13 @@ sh get-docker.sh`;
                     />
                     {form.watch("method") === "wg" && !isLoading ? (
                         <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>
                     ) : form.watch("method") === "wg" && isLoading ? (
                         <p>Loading WireGuard configuration...</p>
                     ) : (
                         <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>
                     )}
                     <div className="flex items-center space-x-2">

+ 10 - 8
src/app/[orgId]/sites/components/DataTable.tsx → src/app/[orgId]/sites/components/SitesDataTable.tsx

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

+ 2 - 2
src/app/[orgId]/sites/components/SitesTable.tsx

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

+ 0 - 0
src/app/[orgId]/sites/components/DataTablePagination.tsx → src/components/DataTablePagination.tsx