瀏覽代碼

list roles, make sidebar component, responsive mobile settings menu selector

Milo Schwartz 8 月之前
父節點
當前提交
bb17d30c9e

+ 2 - 1
package.json

@@ -30,6 +30,7 @@
         "@radix-ui/react-separator": "1.1.0",
         "@radix-ui/react-separator": "1.1.0",
         "@radix-ui/react-slot": "1.1.0",
         "@radix-ui/react-slot": "1.1.0",
         "@radix-ui/react-switch": "1.1.1",
         "@radix-ui/react-switch": "1.1.1",
+        "@radix-ui/react-tabs": "1.1.1",
         "@radix-ui/react-toast": "1.2.2",
         "@radix-ui/react-toast": "1.2.2",
         "@react-email/components": "0.0.25",
         "@react-email/components": "0.0.25",
         "@react-email/tailwind": "0.1.0",
         "@react-email/tailwind": "0.1.0",
@@ -81,11 +82,11 @@
         "@types/nodemailer": "6.4.16",
         "@types/nodemailer": "6.4.16",
         "@types/react": "npm:types-react@19.0.0-rc.1",
         "@types/react": "npm:types-react@19.0.0-rc.1",
         "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
         "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
+        "@types/ws": "8.5.13",
         "@types/yargs": "17.0.33",
         "@types/yargs": "17.0.33",
         "drizzle-kit": "0.24.2",
         "drizzle-kit": "0.24.2",
         "esbuild": "0.20.1",
         "esbuild": "0.20.1",
         "esbuild-node-externals": "1.13.0",
         "esbuild-node-externals": "1.13.0",
-        "@types/ws": "8.5.13",
         "eslint": "^8",
         "eslint": "^8",
         "eslint-config-next": "15.0.1",
         "eslint-config-next": "15.0.1",
         "postcss": "^8",
         "postcss": "^8",

+ 5 - 5
server/auth/actions.ts

@@ -7,7 +7,7 @@ import HttpCode from "@server/types/HttpCode";
 
 
 export enum ActionsEnum {
 export enum ActionsEnum {
     createOrg = "createOrg",
     createOrg = "createOrg",
-    deleteOrg = "deleteOrg",
+    // deleteOrg = "deleteOrg",
     getOrg = "getOrg",
     getOrg = "getOrg",
     updateOrg = "updateOrg",
     updateOrg = "updateOrg",
     createSite = "createSite",
     createSite = "createSite",
@@ -39,16 +39,16 @@ export enum ActionsEnum {
     addRoleResource = "addRoleResource",
     addRoleResource = "addRoleResource",
     removeRoleResource = "removeRoleResource",
     removeRoleResource = "removeRoleResource",
     removeRoleSite = "removeRoleSite",
     removeRoleSite = "removeRoleSite",
-    addRoleAction = "addRoleAction",
-    removeRoleAction = "removeRoleAction",
+    // addRoleAction = "addRoleAction",
+    // removeRoleAction = "removeRoleAction",
     listRoleSites = "listRoleSites",
     listRoleSites = "listRoleSites",
     listRoleResources = "listRoleResources",
     listRoleResources = "listRoleResources",
     listRoleActions = "listRoleActions",
     listRoleActions = "listRoleActions",
     addUserRole = "addUserRole",
     addUserRole = "addUserRole",
     addUserResource = "addUserResource",
     addUserResource = "addUserResource",
     addUserSite = "addUserSite",
     addUserSite = "addUserSite",
-    addUserAction = "addUserAction",
-    removeUserAction = "removeUserAction",
+    // addUserAction = "addUserAction",
+    // removeUserAction = "removeUserAction",
     removeUserResource = "removeUserResource",
     removeUserResource = "removeUserResource",
     removeUserSite = "removeUserSite",
     removeUserSite = "removeUserSite",
 }
 }

+ 1 - 2
server/db/ensureActions.ts

@@ -56,14 +56,13 @@ export async function ensureActions() {
 }
 }
 
 
 export async function createAdminRole(orgId: string) {
 export async function createAdminRole(orgId: string) {
-    // Create the Default role if it doesn't exist
     const [insertedRole] = await db
     const [insertedRole] = await db
         .insert(roles)
         .insert(roles)
         .values({
         .values({
             orgId,
             orgId,
             isAdmin: true,
             isAdmin: true,
             name: "Admin",
             name: "Admin",
-            description: "Admin role most permissions",
+            description: "Admin role with the most permissions",
         })
         })
         .returning({ roleId: roles.roleId })
         .returning({ roleId: roles.roleId })
         .execute();
         .execute();

+ 13 - 13
server/routers/external.ts

@@ -53,12 +53,12 @@ authenticated.post(
     verifyUserHasAction(ActionsEnum.updateOrg),
     verifyUserHasAction(ActionsEnum.updateOrg),
     org.updateOrg
     org.updateOrg
 );
 );
-// authenticated.delete(
-//     "/org/:orgId",
-//     verifyOrgAccess,
-//     verifyUserIsOrgOwner,
-//     org.deleteOrg
-// );
+authenticated.delete(
+    "/org/:orgId",
+    verifyOrgAccess,
+    verifyUserIsOrgOwner,
+    org.deleteOrg
+);
 
 
 authenticated.put(
 authenticated.put(
     "/org/:orgId/site",
     "/org/:orgId/site",
@@ -192,13 +192,13 @@ authenticated.delete(
     target.deleteTarget
     target.deleteTarget
 );
 );
 
 
-// authenticated.put(
-//     "/org/:orgId/role",
-//     verifyOrgAccess,
-//     verifyAdmin,
-//     verifyUserHasAction(ActionsEnum.createRole),
-//     role.createRole
-// );
+authenticated.put(
+    "/org/:orgId/role",
+    verifyOrgAccess,
+    verifyAdmin,
+    verifyUserHasAction(ActionsEnum.createRole),
+    role.createRole
+);
 authenticated.get(
 authenticated.get(
     "/org/:orgId/roles",
     "/org/:orgId/roles",
     verifyOrgAccess,
     verifyOrgAccess,

+ 1 - 11
server/routers/org/createOrg.ts

@@ -45,13 +45,6 @@ export async function createOrg(
             );
             );
         }
         }
 
 
-        // TODO: we cant do this when they create an org because they are not in an org yet... maybe we need to make the org id optional on the userActions table
-        // Check if the user has permission
-        // const hasPermission = await checkUserActionPermission(ActionsEnum.createOrg, req);
-        // if (!hasPermission) {
-        //     return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have permission to perform this action'));
-        // }
-
         const { orgId, name } = parsedBody.data;
         const { orgId, name } = parsedBody.data;
 
 
         // make sure the orgId is unique
         // make sure the orgId is unique
@@ -113,10 +106,7 @@ export async function createOrg(
     } catch (error) {
     } catch (error) {
         logger.error(error);
         logger.error(error);
         return next(
         return next(
-            createHttpError(
-                HttpCode.INTERNAL_SERVER_ERROR,
-                "An error occurred..."
-            )
+            createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
         );
         );
     }
     }
 }
 }

+ 5 - 8
server/routers/resource/createResource.ts

@@ -3,6 +3,7 @@ import { z } from "zod";
 import { db } from "@server/db";
 import { db } from "@server/db";
 import {
 import {
     orgs,
     orgs,
+    Resource,
     resources,
     resources,
     roleResources,
     roleResources,
     roles,
     roles,
@@ -29,6 +30,8 @@ const createResourceSchema = z.object({
     subdomain: z.string().min(1).max(255).optional(),
     subdomain: z.string().min(1).max(255).optional(),
 });
 });
 
 
+export type CreateResourceResponse = Resource;
+
 export async function createResource(
 export async function createResource(
     req: Request,
     req: Request,
     res: Response,
     res: Response,
@@ -82,10 +85,8 @@ export async function createResource(
             );
             );
         }
         }
 
 
-        // Generate a unique resourceId
         const fullDomain = `${subdomain}.${org[0].domain}`;
         const fullDomain = `${subdomain}.${org[0].domain}`;
 
 
-        // Create new resource in the database
         const newResource = await db
         const newResource = await db
             .insert(resources)
             .insert(resources)
             .values({
             .values({
@@ -122,7 +123,7 @@ export async function createResource(
             });
             });
         }
         }
 
 
-        response(res, {
+        response<CreateResourceResponse>(res, {
             data: newResource[0],
             data: newResource[0],
             success: true,
             success: true,
             error: false,
             error: false,
@@ -130,12 +131,8 @@ export async function createResource(
             status: HttpCode.CREATED,
             status: HttpCode.CREATED,
         });
         });
     } catch (error) {
     } catch (error) {
-        throw error;
         return next(
         return next(
-            createHttpError(
-                HttpCode.INTERNAL_SERVER_ERROR,
-                "An error occurred..."
-            )
+            createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
         );
         );
     }
     }
 }
 }

+ 43 - 2
server/routers/role/createRole.ts

@@ -1,12 +1,14 @@
 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 { roles } from "@server/db/schema";
+import { orgs, Role, roleActions, roles } 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 logger from "@server/logger";
 import logger from "@server/logger";
 import { fromError } from "zod-validation-error";
 import { fromError } from "zod-validation-error";
+import { ActionsEnum } from "@server/auth/actions";
+import { eq, and } from "drizzle-orm";
 
 
 const createRoleParamsSchema = z.object({
 const createRoleParamsSchema = z.object({
     orgId: z.string(),
     orgId: z.string(),
@@ -17,6 +19,8 @@ const createRoleSchema = z.object({
     description: z.string().optional(),
     description: z.string().optional(),
 });
 });
 
 
+export type CreateRoleResponse = Role;
+
 export async function createRole(
 export async function createRole(
     req: Request,
     req: Request,
     res: Response,
     res: Response,
@@ -47,6 +51,25 @@ export async function createRole(
 
 
         const { orgId } = parsedParams.data;
         const { orgId } = parsedParams.data;
 
 
+        const allRoles = await db
+            .select({
+                roleId: roles.roleId,
+                name: roles.name,
+            })
+            .from(roles)
+            .leftJoin(orgs, eq(roles.orgId, orgs.orgId))
+            .where(and(eq(roles.name, roleData.name), eq(roles.orgId, orgId)));
+
+        // make sure name is unique
+        if (allRoles.length > 0) {
+            return next(
+                createHttpError(
+                    HttpCode.BAD_REQUEST,
+                    "Role with that name already exists"
+                )
+            );
+        }
+
         const newRole = await db
         const newRole = await db
             .insert(roles)
             .insert(roles)
             .values({
             .values({
@@ -55,7 +78,25 @@ export async function createRole(
             })
             })
             .returning();
             .returning();
 
 
-        return response(res, {
+        // default allowed actions for a non admin role
+        const allowedActions: ActionsEnum[] = [
+            ActionsEnum.getOrg,
+            ActionsEnum.getResource,
+            ActionsEnum.listResources,
+        ];
+
+        await db
+            .insert(roleActions)
+            .values(
+                allowedActions.map((action) => ({
+                    roleId: newRole[0].roleId,
+                    actionId: action,
+                    orgId,
+                }))
+            )
+            .execute();
+
+        return response<Role>(res, {
             data: newRole[0],
             data: newRole[0],
             success: true,
             success: true,
             error: false,
             error: false,

+ 2 - 1
server/routers/site/createSite.ts

@@ -79,6 +79,7 @@ export async function createSite(
                 subnet,
                 subnet,
             })
             })
             .returning();
             .returning();
+
         const adminRole = await db
         const adminRole = await db
             .select()
             .select()
             .from(roles)
             .from(roles)
@@ -104,7 +105,7 @@ export async function createSite(
             });
             });
         }
         }
 
 
-        // Add the peer to the exit node
+        // add the peer to the exit node
         await addPeer(exitNodeId, {
         await addPeer(exitNodeId, {
             publicKey: pubKey,
             publicKey: pubKey,
             allowedIps: [],
             allowedIps: [],

+ 41 - 0
src/app/[orgId]/settings/access/layout.tsx

@@ -0,0 +1,41 @@
+import { SidebarSettings } from "@app/components/SidebarSettings";
+
+interface AccessLayoutProps {
+    children: React.ReactNode;
+    params: Promise<{ resourceId: number | string; orgId: string }>;
+}
+
+export default async function ResourceLayout(props: AccessLayoutProps) {
+    const params = await props.params;
+    const { children } = props;
+
+    const sidebarNavItems = [
+        {
+            title: "Users",
+            href: `/{orgId}/settings/access/users`,
+        },
+        {
+            title: "Roles",
+            href: `/{orgId}/settings/access/roles`,
+        },
+    ];
+
+    return (
+        <>
+            <div className="space-y-0.5 select-none mb-6">
+                <h2 className="text-2xl font-bold tracking-tight">
+                    Users & Roles
+                </h2>
+                <p className="text-muted-foreground">
+                    Manage users and roles for your organization.
+                </p>
+            </div>
+
+            <SidebarSettings
+                sidebarNavItems={sidebarNavItems}
+            >
+                {children}
+            </SidebarSettings>
+        </>
+    );
+}

+ 12 - 0
src/app/[orgId]/settings/access/page.tsx

@@ -0,0 +1,12 @@
+import { redirect } from "next/navigation";
+
+type AccessPageProps = {
+    params: Promise<{ orgId: string }>;
+};
+
+export default async function AccessPage(props: AccessPageProps) {
+    const params = await props.params;
+    redirect(`/${params.orgId}/settings/access/users`);
+
+    return <></>;
+}

+ 142 - 0
src/app/[orgId]/settings/access/roles/components/RolesDataTable.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 { Plus } from "lucide-react";
+import { DataTablePagination } from "@app/components/DataTablePagination";
+
+interface DataTableProps<TData, TValue> {
+    columns: ColumnDef<TData, TValue>[];
+    data: TData[];
+    addRole?: () => void;
+}
+
+export function RolesDataTable<TData, TValue>({
+    addRole,
+    columns,
+    data,
+}: DataTableProps<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 roles"
+                    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 (addRole) {
+                            addRole();
+                        }
+                    }}
+                >
+                    <Plus className="mr-2 h-4 w-4" /> Add Role
+                </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 roles. Create a role, then add users to
+                                    the it.
+                                </TableCell>
+                            </TableRow>
+                        )}
+                    </TableBody>
+                </Table>
+            </div>
+            <div className="mt-4">
+                <DataTablePagination table={table} />
+            </div>
+        </div>
+    );
+}

+ 154 - 0
src/app/[orgId]/settings/access/roles/components/RolesTable.tsx

@@ -0,0 +1,154 @@
+"use client";
+
+import { ColumnDef } from "@tanstack/react-table";
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from "@app/components/ui/dropdown-menu";
+import { Button } from "@app/components/ui/button";
+import { ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
+import { useState } from "react";
+import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
+import api from "@app/api";
+import { useOrgContext } from "@app/hooks/useOrgContext";
+import { useToast } from "@app/hooks/useToast";
+import { RolesDataTable } from "./RolesDataTable";
+import { Role } from "@server/db/schema";
+
+export type RoleRow = Role;
+
+type RolesTableProps = {
+    roles: RoleRow[];
+};
+
+export default function UsersTable({ roles }: RolesTableProps) {
+    const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+    const [roleToRemove, setUserToRemove] = useState<RoleRow | null>(null);
+
+    const { org } = useOrgContext();
+    const { toast } = useToast();
+
+    const columns: ColumnDef<RoleRow>[] = [
+        {
+            accessorKey: "name",
+            header: ({ column }) => {
+                return (
+                    <Button
+                        variant="ghost"
+                        onClick={() =>
+                            column.toggleSorting(column.getIsSorted() === "asc")
+                        }
+                    >
+                        Name
+                        <ArrowUpDown className="ml-2 h-4 w-4" />
+                    </Button>
+                );
+            },
+        },
+        {
+            accessorKey: "description",
+            header: "Description",
+        },
+        {
+            id: "actions",
+            cell: ({ row }) => {
+                const roleRow = row.original;
+
+                return (
+                    <>
+                        {!roleRow.isAdmin && (
+                            <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>
+                                        <button
+                                            className="text-red-600 hover:text-red-800"
+                                            onClick={() => {
+                                                setIsDeleteModalOpen(true);
+                                                setUserToRemove(roleRow);
+                                            }}
+                                        >
+                                            Remove User
+                                        </button>
+                                    </DropdownMenuItem>
+                                </DropdownMenuContent>
+                            </DropdownMenu>
+                        )}
+                    </>
+                );
+            },
+        },
+    ];
+
+    async function removeRole() {
+        if (roleToRemove) {
+            const res = await api
+                .delete(`/org/${org!.org.orgId}/role/${roleToRemove.roleId}`)
+                .catch((e) => {
+                    toast({
+                        variant: "destructive",
+                        title: "Failed to remove role",
+                        description:
+                            e.message ??
+                            "An error occurred while removing the role.",
+                    });
+                });
+
+            if (res && res.status === 200) {
+                toast({
+                    variant: "default",
+                    title: "Role removed",
+                    description: `The role ${roleToRemove.name} has been removed from the organization.`,
+                });
+            }
+        }
+        setIsDeleteModalOpen(false);
+    }
+
+    return (
+        <>
+            <ConfirmDeleteDialog
+                open={isDeleteModalOpen}
+                setOpen={(val) => {
+                    setIsDeleteModalOpen(val);
+                    setUserToRemove(null);
+                }}
+                dialog={
+                    <div>
+                        <p className="mb-2">
+                            Are you sure you want to remove the role{" "}
+                            <b>{roleToRemove?.name}</b> from the organization?
+                        </p>
+
+                        <p className="mb-2">
+                            You cannot undo this action. Please select a new
+                            role to move existing users to after deletion.
+                        </p>
+
+                        <p>
+                            To confirm, please type the name of the role below.
+                        </p>
+                    </div>
+                }
+                buttonText="Confirm remove role"
+                onConfirm={removeRole}
+                string={roleToRemove?.name ?? ""}
+                title="Remove role from organization"
+            />
+
+            <RolesDataTable columns={columns} data={roles} />
+        </>
+    );
+}

+ 57 - 0
src/app/[orgId]/settings/access/roles/page.tsx

@@ -0,0 +1,57 @@
+import { internal } from "@app/api";
+import { authCookieHeader } from "@app/api/cookies";
+import { AxiosResponse } from "axios";
+import { GetOrgResponse } from "@server/routers/org";
+import { cache } from "react";
+import OrgProvider from "@app/providers/OrgProvider";
+import { ListRolesResponse } from "@server/routers/role";
+import RolesTable, { RoleRow } from "./components/RolesTable";
+
+type RolesPageProps = {
+    params: Promise<{ orgId: string }>;
+};
+
+export default async function RolesPage(props: RolesPageProps) {
+    const params = await props.params;
+
+    let roles: ListRolesResponse["roles"] = [];
+    const res = await internal
+        .get<AxiosResponse<ListRolesResponse>>(
+            `/org/${params.orgId}/roles`,
+            await authCookieHeader()
+        )
+        .catch((e) => {
+            console.error(e);
+        });
+
+    if (res && res.status === 200) {
+        roles = res.data.data.roles;
+    }
+
+    let org: GetOrgResponse | null = null;
+    const getOrg = cache(async () =>
+        internal
+            .get<AxiosResponse<GetOrgResponse>>(
+                `/org/${params.orgId}`,
+                await authCookieHeader()
+            )
+            .catch((e) => {
+                console.error(e);
+            })
+    );
+    const orgRes = await getOrg();
+
+    if (orgRes && orgRes.status === 200) {
+        org = orgRes.data.data;
+    }
+
+    const roleRows: RoleRow[] = roles;
+
+    return (
+        <>
+            <OrgProvider org={org}>
+                <RolesTable roles={roleRows} />
+            </OrgProvider>
+        </>
+    );
+}

+ 0 - 0
src/app/[orgId]/settings/users/components/InviteUserForm.tsx → src/app/[orgId]/settings/access/users/components/InviteUserForm.tsx


+ 1 - 1
src/app/[orgId]/settings/users/components/UsersDataTable.tsx → src/app/[orgId]/settings/access/users/components/UsersDataTable.tsx

@@ -22,7 +22,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 "../../../../../components/DataTablePagination";
+import { DataTablePagination } from "../../../../../../components/DataTablePagination";
 import { Plus } from "lucide-react";
 import { Plus } from "lucide-react";
 
 
 interface DataTableProps<TData, TValue> {
 interface DataTableProps<TData, TValue> {

+ 0 - 0
src/app/[orgId]/settings/users/components/UsersTable.tsx → src/app/[orgId]/settings/access/users/components/UsersTable.tsx


+ 0 - 11
src/app/[orgId]/settings/users/page.tsx → src/app/[orgId]/settings/access/users/page.tsx

@@ -8,7 +8,6 @@ import { cache } from "react";
 import OrgProvider from "@app/providers/OrgProvider";
 import OrgProvider from "@app/providers/OrgProvider";
 import UserProvider from "@app/providers/UserProvider";
 import UserProvider from "@app/providers/UserProvider";
 import { verifySession } from "@app/lib/auth/verifySession";
 import { verifySession } from "@app/lib/auth/verifySession";
-import CopyTextBox from "@app/components/CopyTextBox";
 
 
 type UsersPageProps = {
 type UsersPageProps = {
     params: Promise<{ orgId: string }>;
     params: Promise<{ orgId: string }>;
@@ -63,16 +62,6 @@ export default async function UsersPage(props: UsersPageProps) {
 
 
     return (
     return (
         <>
         <>
-            <div className="space-y-0.5 select-none mb-6">
-                <h2 className="text-2xl font-bold tracking-tight">
-                    Manage Users
-                </h2>
-                <p className="text-muted-foreground">
-                    Manage existing your users or invite new ones to your
-                    organization.
-                </p>
-            </div>
-
             <UserProvider user={user!}>
             <UserProvider user={user!}>
                 <OrgProvider org={org}>
                 <OrgProvider org={org}>
                     <UsersTable users={userRows} />
                     <UsersTable users={userRows} />

+ 2 - 2
src/app/[orgId]/settings/layout.tsx

@@ -29,8 +29,8 @@ const topNavItems = [
         icon: <Waypoints className="h-5 w-5" />,
         icon: <Waypoints className="h-5 w-5" />,
     },
     },
     {
     {
-        title: "Users",
-        href: "/{orgId}/settings/users",
+        title: "Access",
+        href: "/{orgId}/settings/access",
         icon: <Users className="h-5 w-5" />,
         icon: <Users className="h-5 w-5" />,
     },
     },
     {
     {

+ 0 - 47
src/app/[orgId]/settings/resources/[resourceId]/components/ClientLayout.tsx

@@ -1,47 +0,0 @@
-"use client";
-
-import { SidebarNav } from "@app/components/sidebar-nav";
-import { useResourceContext } from "@app/hooks/useResourceContext";
-
-const sidebarNavItems = [
-    {
-        title: "General",
-        href: "/{orgId}/settings/resources/{resourceId}",
-    },
-    {
-        title: "Targets",
-        href: "/{orgId}/settings/resources/{resourceId}/targets",
-    },
-];
-
-export function ClientLayout({
-    isCreate,
-    children,
-}: {
-    isCreate: boolean;
-    children: React.ReactNode;
-}) {
-    const { resource } = useResourceContext();
-    return (
-        <div className="hidden space-y-6 0 pb-16 md:block">
-            <div className="space-y-0.5">
-                <h2 className="text-2xl font-bold tracking-tight">
-                    {isCreate ? "New Resource" : resource?.name + " Settings"}
-                </h2>
-                <p className="text-muted-foreground">
-                    {isCreate
-                        ? "Create a new resource"
-                        : "Configure the settings on your resource: " +
-                              resource?.name || ""}
-                    .
-                </p>
-            </div>
-            <div className="flex flex-col space-y-6 lg:flex-row lg:space-x-12 lg:space-y-0">
-                <aside className="-mx-4 lg:w-1/5">
-                    <SidebarNav items={sidebarNavItems} disabled={isCreate} />
-                </aside>
-                <div className="flex-1 lg:max-w-2xl">{children}</div>
-            </div>
-        </div>
-    );
-}

+ 31 - 10
src/app/[orgId]/settings/resources/[resourceId]/layout.tsx

@@ -1,12 +1,10 @@
-import Image from "next/image";
 import ResourceProvider from "@app/providers/ResourceProvider";
 import ResourceProvider from "@app/providers/ResourceProvider";
 import { internal } from "@app/api";
 import { internal } from "@app/api";
 import { GetResourceResponse } from "@server/routers/resource";
 import { GetResourceResponse } from "@server/routers/resource";
 import { AxiosResponse } from "axios";
 import { AxiosResponse } from "axios";
 import { redirect } from "next/navigation";
 import { redirect } from "next/navigation";
 import { authCookieHeader } from "@app/api/cookies";
 import { authCookieHeader } from "@app/api/cookies";
-import Link from "next/link";
-import { ClientLayout } from "./components/ClientLayout";
+import { SidebarSettings } from "@app/components/SidebarSettings";
 
 
 interface ResourceLayoutProps {
 interface ResourceLayoutProps {
     children: React.ReactNode;
     children: React.ReactNode;
@@ -32,19 +30,42 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
         }
         }
     }
     }
 
 
+    const sidebarNavItems = [
+        {
+            title: "General",
+            href: `/{orgId}/settings/resources/resourceId`,
+        },
+        {
+            title: "Targets",
+            href: `/{orgId}/settings/resources/{resourceId}/targets`,
+        },
+    ];
+
+    const isCreate = params.resourceId === "create";
+
     return (
     return (
         <>
         <>
-            <div className="mb-4">
-                <Link
-                    href={`/${params.orgId}/settings/resources`}
-                    className="text-primary font-medium"
-                ></Link>
+            <div className="space-y-0.5 select-none mb-6">
+                <h2 className="text-2xl font-bold tracking-tight">
+                    {isCreate ? "New Resource" : resource?.name + " Settings"}
+                </h2>
+                <p className="text-muted-foreground">
+                    {isCreate
+                        ? "Create a new resource"
+                        : "Configure the settings on your resource: " +
+                              resource?.name || ""}
+                    .
+                </p>
             </div>
             </div>
 
 
             <ResourceProvider resource={resource}>
             <ResourceProvider resource={resource}>
-                <ClientLayout isCreate={params.resourceId === "create"}>
+                <SidebarSettings
+                    sidebarNavItems={sidebarNavItems}
+                    disabled={isCreate}
+                    limitWidth={true}
+                >
                     {children}
                     {children}
-                </ClientLayout>
+                </SidebarSettings>
             </ResourceProvider>
             </ResourceProvider>
         </>
         </>
     );
     );

+ 0 - 42
src/app/[orgId]/settings/sites/[niceId]/components/ClientLayout.tsx

@@ -1,42 +0,0 @@
-"use client";
-
-import { SidebarNav } from "@app/components/sidebar-nav";
-import { useSiteContext } from "@app/hooks/useSiteContext";
-
-const sidebarNavItems = [
-    {
-        title: "General",
-        href: "/{orgId}/settings/sites/{niceId}",
-    },
-];
-
-export function ClientLayout({ isCreate, children }: { isCreate: boolean; children: React.ReactNode }) {
-    const { site } = useSiteContext();
-    return (<div className="hidden space-y-6 0 pb-16 md:block">
-        <div className="space-y-0.5">
-            <h2 className="text-2xl font-bold tracking-tight">
-                {isCreate
-                    ? "New Site"
-                    : site?.name + " Settings"}
-            </h2>
-            <p className="text-muted-foreground">
-                {isCreate
-                    ? "Create a new site"
-                    : "Configure the settings on your site: " +
-                    site?.name || ""}
-                .
-            </p>
-        </div>
-        <div className="flex flex-col space-y-6 lg:flex-row lg:space-x-12 lg:space-y-0">
-            <aside className="-mx-4 lg:w-1/5">
-                <SidebarNav
-                    items={sidebarNavItems}
-                    disabled={isCreate}
-                />
-            </aside>
-            <div className="flex-1 lg:max-w-2xl">
-                {children}
-            </div>
-        </div>
-    </div>);
-}

+ 28 - 12
src/app/[orgId]/settings/sites/[niceId]/layout.tsx

@@ -4,8 +4,7 @@ import { GetSiteResponse } from "@server/routers/site";
 import { AxiosResponse } from "axios";
 import { AxiosResponse } from "axios";
 import { redirect } from "next/navigation";
 import { redirect } from "next/navigation";
 import { authCookieHeader } from "@app/api/cookies";
 import { authCookieHeader } from "@app/api/cookies";
-import Link from "next/link";
-import { ClientLayout } from "./components/ClientLayout";
+import { SidebarSettings } from "@app/components/SidebarSettings";
 
 
 interface SettingsLayoutProps {
 interface SettingsLayoutProps {
     children: React.ReactNode;
     children: React.ReactNode;
@@ -31,20 +30,37 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
         }
         }
     }
     }
 
 
+    const sidebarNavItems = [
+        {
+            title: "General",
+            href: "/{orgId}/settings/sites/{niceId}",
+        },
+    ];
+
+    const isCreate = params.niceId === "create";
+
     return (
     return (
         <>
         <>
-            <div className="mb-4">
-                <Link
-                    href={`/${params.orgId}/settings/sites`}
-                    className="text-primary font-medium"
-                ></Link>
+            <div className="space-y-0.5 select-none mb-6">
+                <h2 className="text-2xl font-bold tracking-tight">
+                    {isCreate ? "New Site" : site?.name + " Settings"}
+                </h2>
+                <p className="text-muted-foreground">
+                    {isCreate
+                        ? "Create a new site"
+                        : "Configure the settings on your site: " +
+                              site?.name || ""}
+                    .
+                </p>
             </div>
             </div>
 
 
-            <SiteProvider site={site}>
-                <ClientLayout isCreate={params.niceId === "create"}>
-                    {children}
-                </ClientLayout>
-            </SiteProvider>
+            <SidebarSettings
+                sidebarNavItems={sidebarNavItems}
+                disabled={isCreate}
+                limitWidth={true}
+            >
+                {children}
+            </SidebarSettings>
         </>
         </>
     );
     );
 }
 }

+ 30 - 0
src/components/SidebarSettings.tsx

@@ -0,0 +1,30 @@
+"use client";
+
+import { SidebarNav } from "@app/components/sidebar-nav";
+
+interface SideBarSettingsProps {
+    children: React.ReactNode;
+    sidebarNavItems: Array<{ title: string; href: string }>;
+    disabled?: boolean;
+    limitWidth?: boolean;
+}
+
+export function SidebarSettings({
+    children,
+    sidebarNavItems,
+    disabled,
+    limitWidth,
+}: SideBarSettingsProps) {
+    return (
+        <div className="space-y-6 0 pb-16k">
+            <div className="flex flex-col space-y-6 lg:flex-row lg:space-x-12 lg:space-y-0">
+                <aside className="-mx-4 lg:w-1/5">
+                    <SidebarNav items={sidebarNavItems} disabled={disabled} />
+                </aside>
+                <div className={`flex-1 ${limitWidth ? "lg:max-w-2xl" : ""}`}>
+                    {children}
+                </div>
+            </div>
+        </div>
+    );
+}

+ 108 - 39
src/components/sidebar-nav.tsx

@@ -1,53 +1,122 @@
-"use client"
-import React from 'react'
-import Link from "next/link"
-import { useParams, usePathname, useRouter } from "next/navigation"
-import { cn } from "@/lib/utils"
-import { buttonVariants } from "@/components/ui/button"
+"use client";
+
+import React from "react";
+import Link from "next/link";
+import { useParams, usePathname, useRouter } from "next/navigation";
+import { cn } from "@/lib/utils";
+import { buttonVariants } from "@/components/ui/button";
+import {
+    Select,
+    SelectContent,
+    SelectItem,
+    SelectTrigger,
+    SelectValue,
+} from "@/components/ui/select";
 
 
 interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
 interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
     items: {
     items: {
-        href: string
-        title: string
-    }[]
-    disabled?: boolean
+        href: string;
+        title: string;
+    }[];
+    disabled?: boolean;
 }
 }
 
 
-export function SidebarNav({ className, items, disabled = false, ...props }: SidebarNavProps) {
+export function SidebarNav({
+    className,
+    items,
+    disabled = false,
+    ...props
+}: SidebarNavProps) {
     const pathname = usePathname();
     const pathname = usePathname();
     const params = useParams();
     const params = useParams();
     const orgId = params.orgId as string;
     const orgId = params.orgId as string;
     const niceId = params.niceId as string;
     const niceId = params.niceId as string;
     const resourceId = params.resourceId as string;
     const resourceId = params.resourceId as string;
 
 
+    const router = useRouter();
+
+    const handleSelectChange = (value: string) => {
+        if (!disabled) {
+            router.push(value);
+        }
+    };
+
+    function getSelectedValue() {
+        const item = items.find((item) => hydrateHref(item.href) === pathname);
+        return hydrateHref(item?.href || "");
+    }
+
+    function hydrateHref(val: string): string {
+        return val
+            .replace("{orgId}", orgId)
+            .replace("{niceId}", niceId)
+            .replace("{resourceId}", resourceId);
+    }
+
     return (
     return (
-        <nav
-            className={cn(
-                "flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1",
-                disabled && "opacity-50 pointer-events-none",
-                className
-            )}
-            {...props}
-        >
-            {items.map((item) => (
-                <Link
-                    key={item.href.replace("{orgId}", orgId).replace("{niceId}", niceId).replace("{resourceId}", resourceId)}
-                    href={item.href.replace("{orgId}", orgId).replace("{niceId}", niceId).replace("{resourceId}", resourceId)}
-                    className={cn(
-                        buttonVariants({ variant: "ghost" }),
-                        pathname === item.href.replace("{orgId}", orgId).replace("{niceId}", niceId).replace("{resourceId}", resourceId) && !pathname.includes("create")
-                            ? "bg-muted hover:bg-muted dark:bg-border dark:hover:bg-border"
-                            : "hover:bg-transparent hover:underline",
-                        "justify-start",
-                        disabled && "cursor-not-allowed"
-                    )}
-                    onClick={disabled ? (e) => e.preventDefault() : undefined}
-                    tabIndex={disabled ? -1 : undefined}
-                    aria-disabled={disabled}
+        <div>
+            <div className="block lg:hidden px-4">
+                <Select
+                    onValueChange={handleSelectChange}
+                    disabled={disabled}
+                    defaultValue={getSelectedValue()}
                 >
                 >
-                    {item.title}
-                </Link>
-            ))}
-        </nav>
-    )
+                    <SelectTrigger>
+                        <SelectValue placeholder="Select an option" />
+                    </SelectTrigger>
+                    <SelectContent>
+                        {items.map((item) => (
+                            <SelectItem
+                                key={hydrateHref(item.href)}
+                                value={hydrateHref(item.href)}
+                            >
+                                {item.title}
+                            </SelectItem>
+                        ))}
+                    </SelectContent>
+                </Select>
+            </div>
+            <nav
+                className={cn(
+                    "hidden lg:flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1",
+                    disabled && "opacity-50 pointer-events-none",
+                    className
+                )}
+                {...props}
+            >
+                {items.map((item) => (
+                    <Link
+                        key={item.href
+                            .replace("{orgId}", orgId)
+                            .replace("{niceId}", niceId)
+                            .replace("{resourceId}", resourceId)}
+                        href={item.href
+                            .replace("{orgId}", orgId)
+                            .replace("{niceId}", niceId)
+                            .replace("{resourceId}", resourceId)}
+                        className={cn(
+                            buttonVariants({ variant: "ghost" }),
+                            pathname ===
+                                item.href
+                                    .replace("{orgId}", orgId)
+                                    .replace("{niceId}", niceId)
+                                    .replace("{resourceId}", resourceId) &&
+                                !pathname.includes("create")
+                                ? "bg-muted hover:bg-muted dark:bg-border dark:hover:bg-border"
+                                : "hover:bg-transparent hover:underline",
+                            "justify-start",
+                            disabled && "cursor-not-allowed"
+                        )}
+                        onClick={
+                            disabled ? (e) => e.preventDefault() : undefined
+                        }
+                        tabIndex={disabled ? -1 : undefined}
+                        aria-disabled={disabled}
+                    >
+                        {item.title}
+                    </Link>
+                ))}
+            </nav>
+        </div>
+    );
 }
 }

+ 55 - 0
src/components/ui/tabs.tsx

@@ -0,0 +1,55 @@
+"use client"
+
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+import { cn } from "@/lib/utils"
+
+const Tabs = TabsPrimitive.Root
+
+const TabsList = React.forwardRef<
+  React.ElementRef<typeof TabsPrimitive.List>,
+  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
+>(({ className, ...props }, ref) => (
+  <TabsPrimitive.List
+    ref={ref}
+    className={cn(
+      "inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
+      className
+    )}
+    {...props}
+  />
+))
+TabsList.displayName = TabsPrimitive.List.displayName
+
+const TabsTrigger = React.forwardRef<
+  React.ElementRef<typeof TabsPrimitive.Trigger>,
+  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
+>(({ className, ...props }, ref) => (
+  <TabsPrimitive.Trigger
+    ref={ref}
+    className={cn(
+      "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
+      className
+    )}
+    {...props}
+  />
+))
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
+
+const TabsContent = React.forwardRef<
+  React.ElementRef<typeof TabsPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
+>(({ className, ...props }, ref) => (
+  <TabsPrimitive.Content
+    ref={ref}
+    className={cn(
+      "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
+      className
+    )}
+    {...props}
+  />
+))
+TabsContent.displayName = TabsPrimitive.Content.displayName
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }