Browse Source

Merge branch 'main' of https://github.com/fosrl/pangolin

Owen Schwartz 8 months ago
parent
commit
598ff561e5
100 changed files with 3980 additions and 2309 deletions
  1. 1 0
      package.json
  2. 1 2
      server/auth/actions.ts
  3. 1 2
      server/db/schema.ts
  4. 38 32
      server/routers/auth/verifyRoleAccess.ts
  5. 2 0
      server/routers/auth/verifySiteAccess.ts
  6. 1 1
      server/routers/auth/verifyUserAccess.ts
  7. 18 21
      server/routers/external.ts
  8. 7 5
      server/routers/org/createOrg.ts
  9. 1 0
      server/routers/org/updateOrg.ts
  10. 7 7
      server/routers/resource/createResource.ts
  11. 3 13
      server/routers/resource/getResource.ts
  12. 22 12
      server/routers/resource/listResourceRoles.ts
  13. 111 0
      server/routers/resource/setResourceRoles.ts
  14. 6 2
      server/routers/resource/updateResource.ts
  15. 0 70
      server/routers/role/addRoleResource.ts
  16. 6 4
      server/routers/role/createRole.ts
  17. 41 7
      server/routers/role/deleteRole.ts
  18. 1 1
      server/routers/role/index.ts
  19. 1 0
      server/routers/role/updateRole.ts
  20. 10 11
      server/routers/site/createSite.ts
  21. 1 1
      server/routers/site/listSites.ts
  22. 1 0
      server/routers/site/pickSiteDefaults.ts
  23. 1 0
      server/routers/site/updateSite.ts
  24. 14 9
      server/routers/target/createTarget.ts
  25. 2 2
      server/routers/target/updateTarget.ts
  26. 17 5
      server/routers/traefik/getTraefikConfig.ts
  27. 1 1
      server/routers/user/acceptInvite.ts
  28. 6 5
      server/routers/user/addUserRole.ts
  29. 9 0
      server/schemas/subdomainSchema.ts
  30. 40 0
      src/app/[orgId]/settings/access/components/AccessPageHeaderAndNav.tsx
  31. 5 31
      src/app/[orgId]/settings/access/layout.tsx
  32. 6 4
      src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx
  33. 231 0
      src/app/[orgId]/settings/access/roles/components/DeleteRoleForm.tsx
  34. 43 84
      src/app/[orgId]/settings/access/roles/components/RolesTable.tsx
  35. 7 3
      src/app/[orgId]/settings/access/roles/page.tsx
  36. 167 0
      src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx
  37. 40 19
      src/app/[orgId]/settings/access/users/[userId]/layout.tsx
  38. 4 16
      src/app/[orgId]/settings/access/users/[userId]/page.tsx
  39. 135 122
      src/app/[orgId]/settings/access/users/components/InviteUserForm.tsx
  40. 0 226
      src/app/[orgId]/settings/access/users/components/ManageUserForm.tsx
  41. 68 44
      src/app/[orgId]/settings/access/users/components/UsersTable.tsx
  42. 9 5
      src/app/[orgId]/settings/access/users/page.tsx
  43. 3 1
      src/app/[orgId]/settings/components/Header.tsx
  44. 83 0
      src/app/[orgId]/settings/general/layout.tsx
  45. 60 0
      src/app/[orgId]/settings/general/page.tsx
  46. 1 1
      src/app/[orgId]/settings/layout.tsx
  47. 1 1
      src/app/[orgId]/settings/page.tsx
  48. 204 0
      src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx
  49. 0 241
      src/app/[orgId]/settings/resources/[resourceId]/components/CreateResource.tsx
  50. 9 11
      src/app/[orgId]/settings/resources/[resourceId]/components/CustomDomainInput.tsx
  51. 0 194
      src/app/[orgId]/settings/resources/[resourceId]/components/GeneralForm.tsx
  52. 87 0
      src/app/[orgId]/settings/resources/[resourceId]/components/ResourceInfoBox.tsx
  53. 541 0
      src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx
  54. 272 0
      src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx
  55. 70 34
      src/app/[orgId]/settings/resources/[resourceId]/layout.tsx
  56. 4 23
      src/app/[orgId]/settings/resources/[resourceId]/page.tsx
  57. 0 272
      src/app/[orgId]/settings/resources/[resourceId]/targets/page.tsx
  58. 314 0
      src/app/[orgId]/settings/resources/components/CreateResourceForm.tsx
  59. 181 93
      src/app/[orgId]/settings/resources/components/ResourcesTable.tsx
  60. 30 9
      src/app/[orgId]/settings/resources/page.tsx
  61. 0 218
      src/app/[orgId]/settings/sites/[niceId]/components/CreateSite.tsx
  62. 0 80
      src/app/[orgId]/settings/sites/[niceId]/components/GeneralForm.tsx
  63. 101 0
      src/app/[orgId]/settings/sites/[niceId]/general/page.tsx
  64. 34 32
      src/app/[orgId]/settings/sites/[niceId]/layout.tsx
  65. 3 24
      src/app/[orgId]/settings/sites/[niceId]/page.tsx
  66. 315 0
      src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx
  67. 174 105
      src/app/[orgId]/settings/sites/components/SitesTable.tsx
  68. 5 8
      src/app/[orgId]/settings/sites/page.tsx
  69. 7 3
      src/app/auth/login/LoginForm.tsx
  70. 2 2
      src/app/auth/signup/SignupForm.tsx
  71. 4 3
      src/app/auth/verify-email/VerifyEmailForm.tsx
  72. 2 2
      src/app/globals.css
  73. 3 2
      src/app/invite/page.tsx
  74. 1 1
      src/app/layout.tsx
  75. 1 1
      src/app/profile/account/account-form.tsx
  76. 1 1
      src/app/profile/appearance/appearance-form.tsx
  77. 1 1
      src/app/profile/display/display-form.tsx
  78. 1 1
      src/app/profile/layout.tsx
  79. 1 1
      src/app/profile/notifications/notifications-form.tsx
  80. 1 1
      src/app/profile/profile-form.tsx
  81. 36 9
      src/app/setup/page.tsx
  82. 1 0
      src/components/CopyTextBox.tsx
  83. 24 0
      src/components/SettingsSectionTitle.tsx
  84. 1 1
      src/components/account-form.tsx
  85. 1 1
      src/components/appearance-form.tsx
  86. 1 1
      src/components/display-form.tsx
  87. 1 1
      src/components/notifications-form.tsx
  88. 1 1
      src/components/profile-form.tsx
  89. 1 1
      src/components/sidebar-nav.tsx
  90. 115 0
      src/components/ui/breadcrumb.tsx
  91. 1 0
      src/components/ui/button.tsx
  92. 2 2
      src/components/ui/dialog.tsx
  93. 1 1
      src/components/ui/toaster.tsx
  94. 1 1
      src/contexts/orgContext.ts
  95. 11 0
      src/contexts/orgUserContext.ts
  96. 1 1
      src/contexts/resourceContext.ts
  97. 12 0
      src/hooks/useOrgUserContext.ts
  98. 151 153
      src/hooks/useToast.ts
  99. 12 3
      src/lib/utils.ts
  100. 4 0
      src/providers/OrgProvider.tsx

+ 1 - 0
package.json

@@ -43,6 +43,7 @@
         "cookie-parser": "1.4.6",
         "cors": "2.8.5",
         "drizzle-orm": "0.33.0",
+        "emblor": "1.4.6",
         "express": "4.21.0",
         "express-rate-limit": "7.4.0",
         "glob": "11.0.0",

+ 1 - 2
server/auth/actions.ts

@@ -35,8 +35,7 @@ export enum ActionsEnum {
     listUsers = "listUsers",
     listSiteRoles = "listSiteRoles",
     listResourceRoles = "listResourceRoles",
-    addRoleSite = "addRoleSite",
-    addRoleResource = "addRoleResource",
+    setResourceRoles = "setResourceRoles",
     removeRoleResource = "removeRoleResource",
     removeRoleSite = "removeRoleSite",
     // addRoleAction = "addRoleAction",

+ 1 - 2
server/db/schema.ts

@@ -25,7 +25,6 @@ export const sites = sqliteTable("sites", {
 
 export const resources = sqliteTable("resources", {
     resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
-    fullDomain: text("fullDomain", { length: 2048 }),
     siteId: integer("siteId").references(() => sites.siteId, {
         onDelete: "cascade",
     }),
@@ -33,7 +32,7 @@ export const resources = sqliteTable("resources", {
         onDelete: "cascade",
     }),
     name: text("name").notNull(),
-    subdomain: text("subdomain"),
+    subdomain: text("subdomain").notNull(),
     ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
 });
 

+ 38 - 32
server/routers/auth/verifyRoleAccess.ts

@@ -1,10 +1,16 @@
 import { Request, Response, NextFunction } from "express";
 import { db } from "@server/db";
 import { roles, userOrgs } from "@server/db/schema";
-import { and, eq } from "drizzle-orm";
+import { and, eq, inArray } from "drizzle-orm";
 import createHttpError from "http-errors";
 import HttpCode from "@server/types/HttpCode";
 import logger from "@server/logger";
+import { z } from "zod";
+import { fromError } from "zod-validation-error";
+
+const verifyRoleAccessSchema = z.object({
+    roleIds: z.array(z.number().int().positive()).optional(),
+});
 
 export async function verifyRoleAccess(
     req: Request,
@@ -12,7 +18,7 @@ export async function verifyRoleAccess(
     next: NextFunction
 ) {
     const userId = req.user?.userId;
-    const roleId = parseInt(
+    const singleRoleId = parseInt(
         req.params.roleId || req.body.roleId || req.query.roleId
     );
 
@@ -22,61 +28,61 @@ export async function verifyRoleAccess(
         );
     }
 
-    if (isNaN(roleId)) {
-        return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID"));
+    const parsedBody = verifyRoleAccessSchema.safeParse(req.body);
+    if (!parsedBody.success) {
+        return next(
+            createHttpError(
+                HttpCode.BAD_REQUEST,
+                fromError(parsedBody.error).toString()
+            )
+        );
+    }
+
+    const { roleIds } = parsedBody.data;
+    const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
+
+    if (allRoleIds.length === 0) {
+        return next();
     }
 
     try {
-        const role = await db
+        const rolesData = await db
             .select()
             .from(roles)
-            .where(eq(roles.roleId, roleId))
-            .limit(1);
+            .where(inArray(roles.roleId, allRoleIds));
 
-        if (role.length === 0) {
+        if (rolesData.length !== allRoleIds.length) {
             return next(
                 createHttpError(
                     HttpCode.NOT_FOUND,
-                    `Role with ID ${roleId} not found`
+                    "One or more roles not found"
                 )
             );
         }
 
-        if (!req.userOrg) {
+        // Check user access to each role's organization
+        for (const role of rolesData) {
             const userOrgRole = await db
                 .select()
                 .from(userOrgs)
                 .where(
                     and(
                         eq(userOrgs.userId, userId),
-                        eq(userOrgs.orgId, role[0].orgId!)
+                        eq(userOrgs.orgId, role.orgId!)
                     )
                 )
                 .limit(1);
-            req.userOrg = userOrgRole[0];
-        }
-
-        if (!req.userOrg) {
-            return next(
-                createHttpError(
-                    HttpCode.FORBIDDEN,
-                    "User does not have access to this organization"
-                )
-            );
-        }
 
-        if (req.userOrg.orgId !== role[0].orgId) {
-            return next(
-                createHttpError(
-                    HttpCode.FORBIDDEN,
-                    "Role does not belong to the organization"
-                )
-            );
+            if (userOrgRole.length === 0) {
+                return next(
+                    createHttpError(
+                        HttpCode.FORBIDDEN,
+                        `User does not have access to organization for role ID ${role.roleId}`
+                    )
+                );
+            }
         }
 
-        req.userOrgRoleId = req.userOrg.roleId;
-        req.userOrgId = req.userOrg.orgId;
-
         return next();
     } catch (error) {
         logger.error("Error verifying role access:", error);

+ 2 - 0
server/routers/auth/verifySiteAccess.ts

@@ -10,6 +10,7 @@ import {
 import { and, eq, or } from "drizzle-orm";
 import createHttpError from "http-errors";
 import HttpCode from "@server/types/HttpCode";
+import logger from "@server/logger";
 
 export async function verifySiteAccess(
     req: Request,
@@ -28,6 +29,7 @@ export async function verifySiteAccess(
     }
 
     if (isNaN(siteId)) {
+        logger.debug(JSON.stringify(req.body));
         return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid site ID"));
     }
 

+ 1 - 1
server/routers/auth/verifyUserAccess.ts

@@ -38,7 +38,7 @@ export async function verifyUserAccess(
             req.userOrg = res[0];
         }
 
-        if (req.userOrg) {
+        if (!req.userOrg) {
             return next(
                 createHttpError(
                     HttpCode.FORBIDDEN,

+ 18 - 21
server/routers/external.ts

@@ -20,6 +20,7 @@ import {
     verifyTargetAccess,
     verifyRoleAccess,
     verifyUserAccess,
+    verifyUserInRole,
 } from "./auth";
 import { verifyUserHasAction } from "./auth/verifyUserHasAction";
 import { ActionsEnum } from "@server/auth/actions";
@@ -135,12 +136,13 @@ authenticated.post(
 ); // maybe make this /invite/create instead
 authenticated.post("/invite/accept", user.acceptInvite);
 
-// authenticated.get(
-//     "/resource/:resourceId/roles",
-//     verifyResourceAccess,
-//     verifyUserHasAction(ActionsEnum.listResourceRoles),
-//     resource.listResourceRoles
-// );
+authenticated.get(
+    "/resource/:resourceId/roles",
+    verifyResourceAccess,
+    verifyUserHasAction(ActionsEnum.listResourceRoles),
+    resource.listResourceRoles
+);
+
 authenticated.get(
     "/resource/:resourceId",
     verifyResourceAccess,
@@ -251,20 +253,15 @@ authenticated.post(
 //     verifyUserHasAction(ActionsEnum.listRoleSites),
 //     role.listRoleSites
 // );
-// authenticated.put(
-//     "/role/:roleId/resource",
-//     verifyRoleAccess,
-//     verifyUserInRole,
-//     verifyUserHasAction(ActionsEnum.addRoleResource),
-//     role.addRoleResource
-// );
-// authenticated.delete(
-//     "/role/:roleId/resource",
-//     verifyRoleAccess,
-//     verifyUserInRole,
-//     verifyUserHasAction(ActionsEnum.removeRoleResource),
-//     role.removeRoleResource
-// );
+
+authenticated.post(
+    "/resource/:resourceId/roles",
+    verifyResourceAccess,
+    verifyRoleAccess,
+    verifyUserHasAction(ActionsEnum.setResourceRoles),
+    role.addRoleResource
+);
+
 // authenticated.get(
 //     "/role/:roleId/resources",
 //     verifyRoleAccess,
@@ -370,7 +367,7 @@ authRouter.use(
 authRouter.put("/signup", auth.signup);
 authRouter.post("/login", auth.login);
 authRouter.post("/logout", auth.logout);
-authRouter.post('/newt/get-token', getToken);
+authRouter.post("/newt/get-token", getToken);
 
 authRouter.post("/2fa/enable", verifySessionUserMiddleware, auth.verifyTotp);
 authRouter.post(

+ 7 - 5
server/routers/org/createOrg.ts

@@ -12,11 +12,13 @@ import config from "@server/config";
 import { fromError } from "zod-validation-error";
 import { defaultRoleAllowedActions } from "../role";
 
-const createOrgSchema = z.object({
-    orgId: z.string(),
-    name: z.string().min(1).max(255),
-    // domain: z.string().min(1).max(255).optional(),
-});
+const createOrgSchema = z
+    .object({
+        orgId: z.string(),
+        name: z.string().min(1).max(255),
+        // domain: z.string().min(1).max(255).optional(),
+    })
+    .strict();
 
 const MAX_ORGS = 5;
 

+ 1 - 0
server/routers/org/updateOrg.ts

@@ -18,6 +18,7 @@ const updateOrgBodySchema = z
         name: z.string().min(1).max(255).optional(),
         domain: z.string().min(1).max(255).optional(),
     })
+    .strict()
     .refine((data) => Object.keys(data).length > 0, {
         message: "At least one field must be provided for update",
     });

+ 7 - 7
server/routers/resource/createResource.ts

@@ -15,6 +15,7 @@ import createHttpError from "http-errors";
 import { eq, and } from "drizzle-orm";
 import stoi from "@server/utils/stoi";
 import { fromError } from "zod-validation-error";
+import { subdomainSchema } from "@server/schemas/subdomainSchema";
 
 const createResourceParamsSchema = z.object({
     siteId: z
@@ -25,10 +26,12 @@ const createResourceParamsSchema = z.object({
     orgId: z.string(),
 });
 
-const createResourceSchema = z.object({
-    name: z.string().min(1).max(255),
-    subdomain: z.string().min(1).max(255).optional(),
-});
+const createResourceSchema = z
+    .object({
+        name: z.string().min(1).max(255),
+        subdomain: subdomainSchema,
+    })
+    .strict();
 
 export type CreateResourceResponse = Resource;
 
@@ -85,12 +88,9 @@ export async function createResource(
             );
         }
 
-        const fullDomain = `${subdomain}.${org[0].domain}`;
-
         const newResource = await db
             .insert(resources)
             .values({
-                fullDomain,
                 siteId,
                 orgId,
                 name,

+ 3 - 13
server/routers/resource/getResource.ts

@@ -1,7 +1,7 @@
 import { Request, Response, NextFunction } from "express";
 import { z } from "zod";
 import { db } from "@server/db";
-import { resources } from "@server/db/schema";
+import { Resource, resources } from "@server/db/schema";
 import { eq } from "drizzle-orm";
 import response from "@server/utils/response";
 import HttpCode from "@server/types/HttpCode";
@@ -12,12 +12,7 @@ const getResourceSchema = z.object({
     resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
 });
 
-export type GetResourceResponse = {
-    resourceId: number;
-    siteId: number;
-    orgId: string;
-    name: string;
-};
+export type GetResourceResponse = Resource;
 
 export async function getResource(
     req: Request,
@@ -53,12 +48,7 @@ export async function getResource(
         }
 
         return response(res, {
-            data: {
-                resourceId: resource[0].resourceId,
-                siteId: resource[0].siteId,
-                orgId: resource[0].orgId,
-                name: resource[0].name,
-            },
+            data: resource[0],
             success: true,
             error: false,
             message: "Resource retrieved successfully",

+ 22 - 12
server/routers/resource/listResourceRoles.ts

@@ -13,6 +13,23 @@ const listResourceRolesSchema = z.object({
     resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
 });
 
+async function query(resourceId: number) {
+    return await db
+        .select({
+            roleId: roles.roleId,
+            name: roles.name,
+            description: roles.description,
+            isAdmin: roles.isAdmin,
+        })
+        .from(roleResources)
+        .innerJoin(roles, eq(roleResources.roleId, roles.roleId))
+        .where(eq(roleResources.resourceId, resourceId));
+}
+
+export type ListResourceRolesResponse = {
+    roles: NonNullable<Awaited<ReturnType<typeof query>>>;
+};
+
 export async function listResourceRoles(
     req: Request,
     res: Response,
@@ -31,19 +48,12 @@ export async function listResourceRoles(
 
         const { resourceId } = parsedParams.data;
 
-        const resourceRolesList = await db
-            .select({
-                roleId: roles.roleId,
-                name: roles.name,
-                description: roles.description,
-                isAdmin: roles.isAdmin,
-            })
-            .from(roleResources)
-            .innerJoin(roles, eq(roleResources.roleId, roles.roleId))
-            .where(eq(roleResources.resourceId, resourceId));
+        const resourceRolesList = await query(resourceId);
 
-        return response(res, {
-            data: resourceRolesList,
+        return response<ListResourceRolesResponse>(res, {
+            data: {
+                roles: resourceRolesList,
+            },
             success: true,
             error: false,
             message: "Resource roles retrieved successfully",

+ 111 - 0
server/routers/resource/setResourceRoles.ts

@@ -0,0 +1,111 @@
+import { Request, Response, NextFunction } from "express";
+import { z } from "zod";
+import { db } from "@server/db";
+import { roleResources, roles } from "@server/db/schema";
+import response from "@server/utils/response";
+import HttpCode from "@server/types/HttpCode";
+import createHttpError from "http-errors";
+import logger from "@server/logger";
+import { fromError } from "zod-validation-error";
+import { eq, and, ne } from "drizzle-orm";
+
+const setResourceRolesBodySchema = z.object({
+    roleIds: z.array(z.number().int().positive()),
+});
+
+const setResourceRolesParamsSchema = z.object({
+    resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
+});
+
+export async function addRoleResource(
+    req: Request,
+    res: Response,
+    next: NextFunction
+): Promise<any> {
+    try {
+        const parsedBody = setResourceRolesBodySchema.safeParse(req.body);
+        if (!parsedBody.success) {
+            return next(
+                createHttpError(
+                    HttpCode.BAD_REQUEST,
+                    fromError(parsedBody.error).toString()
+                )
+            );
+        }
+
+        const { roleIds } = parsedBody.data;
+
+        const parsedParams = setResourceRolesParamsSchema.safeParse(req.params);
+        if (!parsedParams.success) {
+            return next(
+                createHttpError(
+                    HttpCode.BAD_REQUEST,
+                    fromError(parsedParams.error).toString()
+                )
+            );
+        }
+
+        const { resourceId } = parsedParams.data;
+
+        // get this org's admin role
+        const adminRole = await db
+            .select()
+            .from(roles)
+            .where(
+                and(
+                    eq(roles.name, "Admin"),
+                    eq(roles.orgId, req.userOrg!.orgId)
+                )
+            )
+            .limit(1);
+
+        if (!adminRole.length) {
+            return next(
+                createHttpError(
+                    HttpCode.INTERNAL_SERVER_ERROR,
+                    "Admin role not found"
+                )
+            );
+        }
+
+        if (roleIds.includes(adminRole[0].roleId)) {
+            return next(
+                createHttpError(
+                    HttpCode.BAD_REQUEST,
+                    "Admin role cannot be assigned to resources"
+                )
+            );
+        }
+
+        await db.transaction(async (trx) => {
+            await trx.delete(roleResources).where(
+                and(
+                    eq(roleResources.resourceId, resourceId),
+                    ne(roleResources.roleId, adminRole[0].roleId) // delete all but the admin role
+                )
+            );
+
+            const newRoleResources = await Promise.all(
+                roleIds.map((roleId) =>
+                    trx
+                        .insert(roleResources)
+                        .values({ roleId, resourceId })
+                        .returning()
+                )
+            );
+
+            return response(res, {
+                data: {},
+                success: true,
+                error: false,
+                message: "Roles set for resource successfully",
+                status: HttpCode.CREATED,
+            });
+        });
+    } catch (error) {
+        logger.error(error);
+        return next(
+            createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
+        );
+    }
+}

+ 6 - 2
server/routers/resource/updateResource.ts

@@ -1,13 +1,14 @@
 import { Request, Response, NextFunction } from "express";
 import { z } from "zod";
 import { db } from "@server/db";
-import { resources } from "@server/db/schema";
+import { resources, sites } 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 logger from "@server/logger";
 import { fromError } from "zod-validation-error";
+import { subdomainSchema } from "@server/schemas/subdomainSchema";
 
 const updateResourceParamsSchema = z.object({
     resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
@@ -16,8 +17,11 @@ const updateResourceParamsSchema = z.object({
 const updateResourceBodySchema = z
     .object({
         name: z.string().min(1).max(255).optional(),
-        subdomain: z.string().min(1).max(255).optional(),
+        subdomain: subdomainSchema.optional(),
+        ssl: z.boolean().optional(),
+        // siteId: z.number(),
     })
+    .strict()
     .refine((data) => Object.keys(data).length > 0, {
         message: "At least one field must be provided for update",
     });

+ 0 - 70
server/routers/role/addRoleResource.ts

@@ -1,70 +0,0 @@
-import { Request, Response, NextFunction } from "express";
-import { z } from "zod";
-import { db } from "@server/db";
-import { roleResources } from "@server/db/schema";
-import response from "@server/utils/response";
-import HttpCode from "@server/types/HttpCode";
-import createHttpError from "http-errors";
-import logger from "@server/logger";
-import { fromError } from "zod-validation-error";
-
-const addRoleResourceParamsSchema = z.object({
-    roleId: z.string().transform(Number).pipe(z.number().int().positive()),
-});
-
-const addRoleResourceSchema = z.object({
-    resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
-});
-
-export async function addRoleResource(
-    req: Request,
-    res: Response,
-    next: NextFunction
-): Promise<any> {
-    try {
-        const parsedBody = addRoleResourceSchema.safeParse(req.body);
-        if (!parsedBody.success) {
-            return next(
-                createHttpError(
-                    HttpCode.BAD_REQUEST,
-                    fromError(parsedBody.error).toString()
-                )
-            );
-        }
-
-        const { resourceId } = parsedBody.data;
-
-        const parsedParams = addRoleResourceParamsSchema.safeParse(req.params);
-        if (!parsedParams.success) {
-            return next(
-                createHttpError(
-                    HttpCode.BAD_REQUEST,
-                    fromError(parsedParams.error).toString()
-                )
-            );
-        }
-
-        const { roleId } = parsedParams.data;
-
-        const newRoleResource = await db
-            .insert(roleResources)
-            .values({
-                roleId,
-                resourceId,
-            })
-            .returning();
-
-        return response(res, {
-            data: newRoleResource[0],
-            success: true,
-            error: false,
-            message: "Resource added to role successfully",
-            status: HttpCode.CREATED,
-        });
-    } catch (error) {
-        logger.error(error);
-        return next(
-            createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
-        );
-    }
-}

+ 6 - 4
server/routers/role/createRole.ts

@@ -14,10 +14,12 @@ const createRoleParamsSchema = z.object({
     orgId: z.string(),
 });
 
-const createRoleSchema = z.object({
-    name: z.string().min(1).max(255),
-    description: z.string().optional(),
-});
+const createRoleSchema = z
+    .object({
+        name: z.string().min(1).max(255),
+        description: z.string().optional(),
+    })
+    .strict();
 
 export const defaultRoleAllowedActions: ActionsEnum[] = [
     ActionsEnum.getOrg,

+ 41 - 7
server/routers/role/deleteRole.ts

@@ -1,7 +1,7 @@
 import { Request, Response, NextFunction } from "express";
 import { z } from "zod";
 import { db } from "@server/db";
-import { roles } from "@server/db/schema";
+import { roles, userOrgs } from "@server/db/schema";
 import { eq } from "drizzle-orm";
 import response from "@server/utils/response";
 import HttpCode from "@server/types/HttpCode";
@@ -13,6 +13,10 @@ const deleteRoleSchema = z.object({
     roleId: z.string().transform(Number).pipe(z.number().int().positive()),
 });
 
+const deelteRoleBodySchema = z.object({
+    roleId: z.string().transform(Number).pipe(z.number().int().positive()),
+});
+
 export async function deleteRole(
     req: Request,
     res: Response,
@@ -29,7 +33,27 @@ export async function deleteRole(
             );
         }
 
+        const parsedBody = deelteRoleBodySchema.safeParse(req.body);
+        if (!parsedBody.success) {
+            return next(
+                createHttpError(
+                    HttpCode.BAD_REQUEST,
+                    fromError(parsedBody.error).toString()
+                )
+            );
+        }
+
         const { roleId } = parsedParams.data;
+        const { roleId: newRoleId } = parsedBody.data;
+
+        if (roleId === newRoleId) {
+            return next(
+                createHttpError(
+                    HttpCode.BAD_REQUEST,
+                    `Cannot delete a role and assign the same role`
+                )
+            );
+        }
 
         const role = await db
             .select()
@@ -55,20 +79,30 @@ export async function deleteRole(
             );
         }
 
-        const deletedRole = await db
-            .delete(roles)
-            .where(eq(roles.roleId, roleId))
-            .returning();
+        const newRole = await db
+            .select()
+            .from(roles)
+            .where(eq(roles.roleId, newRoleId))
+            .limit(1);
 
-        if (deletedRole.length === 0) {
+        if (newRole.length === 0) {
             return next(
                 createHttpError(
                     HttpCode.NOT_FOUND,
-                    `Role with ID ${roleId} not found`
+                    `Role with ID ${newRoleId} not found`
                 )
             );
         }
 
+        // move all users from the userOrgs table with roleId to newRoleId
+        await db
+            .update(userOrgs)
+            .set({ roleId: newRoleId })
+            .where(eq(userOrgs.roleId, roleId));
+
+        // delete the old role
+        await db.delete(roles).where(eq(roles.roleId, roleId));
+
         return response(res, {
             data: null,
             success: true,

+ 1 - 1
server/routers/role/index.ts

@@ -1,5 +1,5 @@
 export * from "./addRoleAction";
-export * from "./addRoleResource";
+export * from "../resource/setResourceRoles";
 export * from "./addRoleSite";
 export * from "./createRole";
 export * from "./deleteRole";

+ 1 - 0
server/routers/role/updateRole.ts

@@ -18,6 +18,7 @@ const updateRoleBodySchema = z
         name: z.string().min(1).max(255).optional(),
         description: z.string().optional(),
     })
+    .strict()
     .refine((data) => Object.keys(data).length > 0, {
         message: "At least one field must be provided for update",
     });

+ 10 - 11
server/routers/site/createSite.ts

@@ -15,13 +15,15 @@ const createSiteParamsSchema = z.object({
     orgId: z.string(),
 });
 
-const createSiteSchema = z.object({
-    name: z.string().min(1).max(255),
-    exitNodeId: z.number().int().positive(),
-    subdomain: z.string().min(1).max(255).optional(),
-    pubKey: z.string().optional(),
-    subnet: z.string(),
-});
+const createSiteSchema = z
+    .object({
+        name: z.string().min(1).max(255),
+        exitNodeId: z.number().int().positive(),
+        subdomain: z.string().min(1).max(255).optional(),
+        pubKey: z.string().optional(),
+        subnet: z.string(),
+    })
+    .strict();
 
 export type CreateSiteResponse = {
     name: string;
@@ -83,10 +85,7 @@ export async function createSite(
             };
         }
 
-        const [newSite] = await db
-            .insert(sites)
-            .values(payload)
-            .returning();
+        const [newSite] = await db.insert(sites).values(payload).returning();
 
         const adminRole = await db
             .select()

+ 1 - 1
server/routers/site/listSites.ts

@@ -76,7 +76,7 @@ export async function listSites(
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,
-                    parsedParams.error.errors.map((e) => e.message).join(", ")
+                    fromError(parsedParams.error)
                 )
             );
         }

+ 1 - 0
server/routers/site/pickSiteDefaults.ts

@@ -76,6 +76,7 @@ export async function pickSiteDefaults(
             status: HttpCode.OK,
         });
     } catch (error) {
+        throw error;
         logger.error(error);
         return next(
             createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")

+ 1 - 0
server/routers/site/updateSite.ts

@@ -23,6 +23,7 @@ const updateSiteBodySchema = z
         megabytesIn: z.number().int().nonnegative().optional(),
         megabytesOut: z.number().int().nonnegative().optional(),
     })
+    .strict()
     .refine((data) => Object.keys(data).length > 0, {
         message: "At least one field must be provided for update",
     });

+ 14 - 9
server/routers/target/createTarget.ts

@@ -1,7 +1,7 @@
 import { Request, Response, NextFunction } from "express";
 import { z } from "zod";
 import { db } from "@server/db";
-import { resources, sites, targets } from "@server/db/schema";
+import { resources, sites, Target, targets } from "@server/db/schema";
 import response from "@server/utils/response";
 import HttpCode from "@server/types/HttpCode";
 import createHttpError from "http-errors";
@@ -15,13 +15,17 @@ const createTargetParamsSchema = z.object({
     resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
 });
 
-const createTargetSchema = z.object({
-    ip: z.string().ip(),
-    method: z.string().min(1).max(10),
-    port: z.number().int().min(1).max(65535),
-    protocol: z.string().optional(),
-    enabled: z.boolean().default(true),
-});
+const createTargetSchema = z
+    .object({
+        ip: z.string().ip(),
+        method: z.string().min(1).max(10),
+        port: z.number().int().min(1).max(65535),
+        protocol: z.string().optional(),
+        enabled: z.boolean().default(true),
+    })
+    .strict();
+
+export type CreateTargetResponse = Target;
 
 export async function createTarget(
     req: Request,
@@ -102,6 +106,7 @@ export async function createTarget(
             .insert(targets)
             .values({
                 resourceId,
+                protocol: "tcp", // hard code for now
                 ...targetData,
             })
             .returning();
@@ -126,7 +131,7 @@ export async function createTarget(
             allowedIps: targetIps.flat(),
         });
 
-        return response(res, {
+        return response<CreateTargetResponse>(res, {
             data: newTarget[0],
             success: true,
             error: false,

+ 2 - 2
server/routers/target/updateTarget.ts

@@ -15,12 +15,12 @@ const updateTargetParamsSchema = z.object({
 
 const updateTargetBodySchema = z
     .object({
-        // ip: z.string().ip().optional(), // for now we cant update the ip; you will have to delete
+        ip: z.string().ip().optional(), // for now we cant update the ip; you will have to delete
         method: z.string().min(1).max(10).optional(),
         port: z.number().int().min(1).max(65535).optional(),
-        protocol: z.string().optional(),
         enabled: z.boolean().optional(),
     })
+    .strict()
     .refine((data) => Object.keys(data).length > 0, {
         message: "At least one field must be provided for update",
     });

+ 17 - 5
server/routers/traefik/getTraefikConfig.ts

@@ -18,10 +18,15 @@ export async function traefikConfigProvider(
                 schema.resources,
                 eq(schema.targets.resourceId, schema.resources.resourceId)
             )
+            .innerJoin(
+                schema.orgs,
+                eq(schema.resources.orgId, schema.orgs.orgId)
+            )
             .where(
                 and(
                     eq(schema.targets.enabled, true),
-                    isNotNull(schema.resources.fullDomain)
+                    isNotNull(schema.resources.subdomain),
+                    isNotNull(schema.orgs.domain)
                 )
             );
 
@@ -60,15 +65,22 @@ export async function traefikConfigProvider(
         for (const item of all) {
             const target = item.targets;
             const resource = item.resources;
+            const org = item.orgs;
 
             const routerName = `${target.targetId}-router`;
             const serviceName = `${target.targetId}-service`;
 
-            if (!resource.fullDomain) {
+            if (!resource || !resource.subdomain) {
+                continue;
+            }
+
+            if (!org || !org.domain) {
                 continue;
             }
 
-            const domainParts = resource.fullDomain.split(".");
+            const fullDomain = `${resource.subdomain}.${org.domain}`;
+
+            const domainParts = fullDomain.split(".");
             let wildCard;
             if (domainParts.length <= 2) {
                 wildCard = `*.${domainParts.join(".")}`;
@@ -97,7 +109,7 @@ export async function traefikConfigProvider(
                 ],
                 middlewares: resource.ssl ? [badgerMiddlewareName] : [],
                 service: serviceName,
-                rule: `Host(\`${resource.fullDomain}\`)`,
+                rule: `Host(\`${fullDomain}\`)`,
                 ...(resource.ssl ? { tls } : {}),
             };
 
@@ -107,7 +119,7 @@ export async function traefikConfigProvider(
                     entryPoints: [config.traefik.http_entrypoint],
                     middlewares: [redirectMiddlewareName],
                     service: serviceName,
-                    rule: `Host(\`${resource.fullDomain}\`)`,
+                    rule: `Host(\`${fullDomain}\`)`,
                 };
             }
 

+ 1 - 1
server/routers/user/acceptInvite.ts

@@ -89,7 +89,7 @@ export async function acceptInvite(
             );
         }
 
-        if (existingUser[0].email !== existingInvite[0].email) {
+        if (req.user && req.user.email !== existingInvite[0].email) {
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,

+ 6 - 5
server/routers/user/addUserRole.ts

@@ -8,10 +8,11 @@ import HttpCode from "@server/types/HttpCode";
 import createHttpError from "http-errors";
 import logger from "@server/logger";
 import { fromError } from "zod-validation-error";
+import stoi from "@server/utils/stoi";
 
 const addUserRoleParamsSchema = z.object({
     userId: z.string(),
-    roleId: z.number().int().positive(),
+    roleId: z.string().transform(stoi).pipe(z.number()),
 });
 
 export type AddUserRoleResponse = z.infer<typeof addUserRoleParamsSchema>;
@@ -22,17 +23,17 @@ export async function addUserRole(
     next: NextFunction
 ): Promise<any> {
     try {
-        const parsedBody = addUserRoleParamsSchema.safeParse(req.body);
-        if (!parsedBody.success) {
+        const parsedParams = addUserRoleParamsSchema.safeParse(req.params);
+        if (!parsedParams.success) {
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,
-                    fromError(parsedBody.error).toString()
+                    fromError(parsedParams.error).toString()
                 )
             );
         }
 
-        const { userId, roleId } = parsedBody.data;
+        const { userId, roleId } = parsedParams.data;
 
         if (!req.userOrg) {
             return next(

+ 9 - 0
server/schemas/subdomainSchema.ts

@@ -0,0 +1,9 @@
+import { z } from "zod";
+
+export const subdomainSchema = z
+    .string()
+    .regex(
+        /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/,
+        "Invalid subdomain format"
+    )
+    .min(1, "Subdomain must be at least 1 character long");

+ 40 - 0
src/app/[orgId]/settings/access/components/AccessPageHeaderAndNav.tsx

@@ -0,0 +1,40 @@
+"use client";
+
+import { SidebarSettings } from "@app/components/SidebarSettings";
+
+type AccessPageHeaderAndNavProps = {
+    children: React.ReactNode;
+};
+
+export default function AccessPageHeaderAndNav({
+    children,
+}: AccessPageHeaderAndNavProps) {
+    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">
+                    Invite users and add them to roles to manage access to your
+                    organization
+                </p>
+            </div>
+            <SidebarSettings sidebarNavItems={sidebarNavItems}>
+                {children}
+            </SidebarSettings>
+        </>
+    );
+}

+ 5 - 31
src/app/[orgId]/settings/access/layout.tsx

@@ -1,40 +1,14 @@
-import { SidebarSettings } from "@app/components/SidebarSettings";
-
 interface AccessLayoutProps {
     children: React.ReactNode;
-    params: Promise<{ resourceId: number | string; orgId: string }>;
+    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">
-                    Invite users and add them to roles to manage access to your
-                    organization.
-                </p>
-            </div>
-
-            <SidebarSettings sidebarNavItems={sidebarNavItems}>
-                {children}
-            </SidebarSettings>
-        </>
-    );
+    return <>{children}</>;
 }

+ 6 - 4
src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx

@@ -29,6 +29,7 @@ import {
 } from "@app/components/Credenza";
 import { useOrgContext } from "@app/hooks/useOrgContext";
 import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
+import { formatAxiosError } from "@app/lib/utils";
 
 type CreateRoleFormProps = {
     open: boolean;
@@ -74,9 +75,10 @@ export default function CreateRoleForm({
                 toast({
                     variant: "destructive",
                     title: "Failed to create role",
-                    description:
-                        e.response?.data?.message ||
-                        "An error occurred while creating the role.",
+                    description: formatAxiosError(
+                        e,
+                        "An error occurred while creating the role."
+                    ),
                 });
             });
 
@@ -121,7 +123,7 @@ export default function CreateRoleForm({
                         <Form {...form}>
                             <form
                                 onSubmit={form.handleSubmit(onSubmit)}
-                                className="space-y-4"
+                                className="space-y-6"
                                 id="create-role-form"
                             >
                                 <FormField

+ 231 - 0
src/app/[orgId]/settings/access/roles/components/DeleteRoleForm.tsx

@@ -0,0 +1,231 @@
+"use client";
+
+import api from "@app/api";
+import { Button } from "@app/components/ui/button";
+import {
+    Form,
+    FormControl,
+    FormField,
+    FormItem,
+    FormLabel,
+    FormMessage,
+} from "@app/components/ui/form";
+import { useToast } from "@app/hooks/useToast";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { AxiosResponse } from "axios";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import {
+    Credenza,
+    CredenzaBody,
+    CredenzaClose,
+    CredenzaContent,
+    CredenzaDescription,
+    CredenzaFooter,
+    CredenzaHeader,
+    CredenzaTitle,
+} from "@app/components/Credenza";
+import { useOrgContext } from "@app/hooks/useOrgContext";
+import { ListRolesResponse } from "@server/routers/role";
+import {
+    Select,
+    SelectContent,
+    SelectItem,
+    SelectTrigger,
+    SelectValue,
+} from "@app/components/ui/select";
+import { RoleRow } from "./RolesTable";
+import { formatAxiosError } from "@app/lib/utils";
+
+type CreateRoleFormProps = {
+    open: boolean;
+    roleToDelete: RoleRow;
+    setOpen: (open: boolean) => void;
+    afterDelete?: () => void;
+};
+
+const formSchema = z.object({
+    newRoleId: z.string({ message: "New role is required" }),
+});
+
+export default function DeleteRoleForm({
+    open,
+    roleToDelete,
+    setOpen,
+    afterDelete,
+}: CreateRoleFormProps) {
+    const { toast } = useToast();
+    const { org } = useOrgContext();
+
+    const [loading, setLoading] = useState(false);
+    const [roles, setRoles] = useState<ListRolesResponse["roles"]>([]);
+
+    useEffect(() => {
+        async function fetchRoles() {
+            const res = await api
+                .get<AxiosResponse<ListRolesResponse>>(
+                    `/org/${org?.org.orgId}/roles`
+                )
+                .catch((e) => {
+                    console.error(e);
+                    toast({
+                        variant: "destructive",
+                        title: "Failed to fetch roles",
+                        description: formatAxiosError(
+                            e,
+                            "An error occurred while fetching the roles"
+                        ),
+                    });
+                });
+
+            if (res?.status === 200) {
+                setRoles(
+                    res.data.data.roles.filter(
+                        (r) => r.roleId !== roleToDelete.roleId
+                    )
+                );
+            }
+        }
+
+        fetchRoles();
+    }, []);
+
+    const form = useForm<z.infer<typeof formSchema>>({
+        resolver: zodResolver(formSchema),
+        defaultValues: {
+            newRoleId: "",
+        },
+    });
+
+    async function onSubmit(values: z.infer<typeof formSchema>) {
+        setLoading(true);
+
+        const res = await api
+            .delete(`/role/${roleToDelete.roleId}`, {
+                data: {
+                    roleId: values.newRoleId,
+                },
+            })
+            .catch((e) => {
+                toast({
+                    variant: "destructive",
+                    title: "Failed to remove role",
+                    description: formatAxiosError(
+                        e,
+                        "An error occurred while removing the role."
+                    ),
+                });
+            });
+
+        if (res && res.status === 200) {
+            toast({
+                variant: "default",
+                title: "Role removed",
+                description: "The role has been successfully removed.",
+            });
+
+            if (open) {
+                setOpen(false);
+            }
+
+            if (afterDelete) {
+                afterDelete();
+            }
+        }
+
+        setLoading(false);
+    }
+
+    return (
+        <>
+            <Credenza
+                open={open}
+                onOpenChange={(val) => {
+                    setOpen(val);
+                    setLoading(false);
+                    form.reset();
+                }}
+            >
+                <CredenzaContent>
+                    <CredenzaHeader>
+                        <CredenzaTitle>Remove Role</CredenzaTitle>
+                        <CredenzaDescription>
+                            Remove a role from the organization
+                        </CredenzaDescription>
+                    </CredenzaHeader>
+                    <CredenzaBody>
+                        <div className="space-y-6">
+                            <div className="space-y-4">
+                                <p>
+                                    You're about to delete the{" "}
+                                    <b>{roleToDelete.name}</b> role. You cannot
+                                    undo this action.
+                                </p>
+                                <p>
+                                    Before deleting this role, please select a
+                                    new role to transfer existing members to.
+                                </p>
+                            </div>
+                            <Form {...form}>
+                                <form
+                                    onSubmit={form.handleSubmit(onSubmit)}
+                                    className="space-y-6"
+                                    id="remove-role-form"
+                                >
+                                    <FormField
+                                        control={form.control}
+                                        name="newRoleId"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>Role</FormLabel>
+                                                <Select
+                                                    onValueChange={
+                                                        field.onChange
+                                                    }
+                                                    value={field.value}
+                                                >
+                                                    <FormControl>
+                                                        <SelectTrigger>
+                                                            <SelectValue placeholder="Select role" />
+                                                        </SelectTrigger>
+                                                    </FormControl>
+                                                    <SelectContent>
+                                                        {roles.map((role) => (
+                                                            <SelectItem
+                                                                key={
+                                                                    role.roleId
+                                                                }
+                                                                value={role.roleId.toString()}
+                                                            >
+                                                                {role.name}
+                                                            </SelectItem>
+                                                        ))}
+                                                    </SelectContent>
+                                                </Select>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                </form>
+                            </Form>
+                        </div>
+                    </CredenzaBody>
+                    <CredenzaFooter>
+                        <Button
+                            type="submit"
+                            form="remove-role-form"
+                            loading={loading}
+                            disabled={loading}
+                        >
+                            Remove Role
+                        </Button>
+                        <CredenzaClose asChild>
+                            <Button variant="outline">Close</Button>
+                        </CredenzaClose>
+                    </CredenzaFooter>
+                </CredenzaContent>
+            </Credenza>
+        </>
+    );
+}

+ 43 - 84
src/app/[orgId]/settings/access/roles/components/RolesTable.tsx

@@ -17,6 +17,7 @@ import { useToast } from "@app/hooks/useToast";
 import { RolesDataTable } from "./RolesDataTable";
 import { Role } from "@server/db/schema";
 import CreateRoleForm from "./CreateRoleForm";
+import DeleteRoleForm from "./DeleteRoleForm";
 
 export type RoleRow = Role;
 
@@ -63,69 +64,42 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
 
                 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);
-                                            }}
+                        <div className="flex items-center justify-end">
+                            {!roleRow.isAdmin && (
+                                <DropdownMenu>
+                                    <DropdownMenuTrigger asChild>
+                                        <Button
+                                            variant="ghost"
+                                            className="h-8 w-8 p-0"
                                         >
-                                            Delete Role
-                                        </button>
-                                    </DropdownMenuItem>
-                                </DropdownMenuContent>
-                            </DropdownMenu>
-                        )}
+                                            <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);
+                                                }}
+                                            >
+                                                Delete Role
+                                            </button>
+                                        </DropdownMenuItem>
+                                    </DropdownMenuContent>
+                                </DropdownMenu>
+                            )}
+                        </div>
                     </>
                 );
             },
         },
     ];
 
-    async function removeRole() {
-        if (roleToRemove) {
-            const res = await api
-                .delete(`/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.`,
-                });
-
-                setRoles((prev) =>
-                    prev.filter((role) => role.roleId !== roleToRemove.roleId)
-                );
-            }
-        }
-        setIsDeleteModalOpen(false);
-    }
-
     return (
         <>
             <CreateRoleForm
@@ -136,34 +110,19 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
                 }}
             />
 
-            <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"
-            />
+            {roleToRemove && (
+                <DeleteRoleForm
+                    open={isDeleteModalOpen}
+                    setOpen={setIsDeleteModalOpen}
+                    roleToDelete={roleToRemove}
+                    afterDelete={() => {
+                        setRoles((prev) =>
+                            prev.filter((r) => r.roleId !== roleToRemove.roleId)
+                        );
+                        setUserToRemove(null);
+                    }}
+                />
+            )}
 
             <RolesDataTable
                 columns={columns}

+ 7 - 3
src/app/[orgId]/settings/access/roles/page.tsx

@@ -6,6 +6,8 @@ import { cache } from "react";
 import OrgProvider from "@app/providers/OrgProvider";
 import { ListRolesResponse } from "@server/routers/role";
 import RolesTable, { RoleRow } from "./components/RolesTable";
+import { SidebarSettings } from "@app/components/SidebarSettings";
+import AccessPageHeaderAndNav from "../components/AccessPageHeaderAndNav";
 
 type RolesPageProps = {
     params: Promise<{ orgId: string }>;
@@ -49,9 +51,11 @@ export default async function RolesPage(props: RolesPageProps) {
 
     return (
         <>
-            <OrgProvider org={org}>
-                <RolesTable roles={roleRows} />
-            </OrgProvider>
+            <AccessPageHeaderAndNav>
+                <OrgProvider org={org}>
+                    <RolesTable roles={roleRows} />
+                </OrgProvider>
+            </AccessPageHeaderAndNav>
         </>
     );
 }

+ 167 - 0
src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx

@@ -0,0 +1,167 @@
+"use client";
+
+import api from "@app/api";
+import {
+    Form,
+    FormControl,
+    FormField,
+    FormItem,
+    FormLabel,
+    FormMessage,
+} from "@app/components/ui/form";
+import { Input } from "@app/components/ui/input";
+import {
+    Select,
+    SelectContent,
+    SelectItem,
+    SelectTrigger,
+    SelectValue,
+} from "@app/components/ui/select";
+import { useToast } from "@app/hooks/useToast";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { InviteUserResponse } from "@server/routers/user";
+import { AxiosResponse } from "axios";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { ListRolesResponse } from "@server/routers/role";
+import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
+import { useParams } from "next/navigation";
+import { Button } from "@app/components/ui/button";
+import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
+import { formatAxiosError } from "@app/lib/utils";
+
+const formSchema = z.object({
+    email: z.string().email({ message: "Please enter a valid email" }),
+    roleId: z.string().min(1, { message: "Please select a role" }),
+});
+
+export default function AccessControlsPage() {
+    const { toast } = useToast();
+    const { orgUser: user } = userOrgUserContext();
+
+    const { orgId } = useParams();
+
+    const [loading, setLoading] = useState(false);
+    const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
+
+    const form = useForm<z.infer<typeof formSchema>>({
+        resolver: zodResolver(formSchema),
+        defaultValues: {
+            email: user.email!,
+            roleId: user.roleId?.toString(),
+        },
+    });
+
+    useEffect(() => {
+        async function fetchRoles() {
+            const res = await api
+                .get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`)
+                .catch((e) => {
+                    console.error(e);
+                    toast({
+                        variant: "destructive",
+                        title: "Failed to fetch roles",
+                        description: formatAxiosError(
+                            e,
+                            "An error occurred while fetching the roles"
+                        ),
+                    });
+                });
+
+            if (res?.status === 200) {
+                setRoles(res.data.data.roles);
+            }
+        }
+
+        fetchRoles();
+
+        form.setValue("roleId", user.roleId.toString());
+    }, []);
+
+    async function onSubmit(values: z.infer<typeof formSchema>) {
+        setLoading(true);
+
+        const res = await api
+            .post<AxiosResponse<InviteUserResponse>>(
+                `/role/${values.roleId}/add/${user.userId}`
+            )
+            .catch((e) => {
+                toast({
+                    variant: "destructive",
+                    title: "Failed to add user to role",
+                    description: formatAxiosError(
+                        e,
+                        "An error occurred while adding user to the role."
+                    ),
+                });
+            });
+
+        if (res && res.status === 200) {
+            toast({
+                variant: "default",
+                title: "User invited",
+                description: "The user has been updated.",
+            });
+        }
+
+        setLoading(false);
+    }
+
+    return (
+        <>
+            <div className="space-y-6">
+                <SettingsSectionTitle
+                    title="Access Controls"
+                    description="Manage what this user can access and do in the organization"
+                    size="1xl"
+                />
+
+                <Form {...form}>
+                    <form
+                        onSubmit={form.handleSubmit(onSubmit)}
+                        className="space-y-6"
+                    >
+                        <FormField
+                            control={form.control}
+                            name="roleId"
+                            render={({ field }) => (
+                                <FormItem>
+                                    <FormLabel>Role</FormLabel>
+                                    <Select
+                                        onValueChange={field.onChange}
+                                        value={field.value}
+                                    >
+                                        <FormControl>
+                                            <SelectTrigger>
+                                                <SelectValue placeholder="Select role" />
+                                            </SelectTrigger>
+                                        </FormControl>
+                                        <SelectContent>
+                                            {roles.map((role) => (
+                                                <SelectItem
+                                                    key={role.roleId}
+                                                    value={role.roleId.toString()}
+                                                >
+                                                    {role.name}
+                                                </SelectItem>
+                                            ))}
+                                        </SelectContent>
+                                    </Select>
+                                    <FormMessage />
+                                </FormItem>
+                            )}
+                        />
+                        <Button
+                            type="submit"
+                            loading={loading}
+                            disabled={loading}
+                        >
+                            Save Changes
+                        </Button>
+                    </form>
+                </Form>
+            </div>
+        </>
+    );
+}

+ 40 - 19
src/app/[orgId]/settings/access/users/[userId]/layout.tsx

@@ -1,11 +1,20 @@
-import SiteProvider from "@app/providers/SiteProvider";
 import { internal } from "@app/api";
-import { GetSiteResponse } from "@server/routers/site";
 import { AxiosResponse } from "axios";
 import { redirect } from "next/navigation";
 import { authCookieHeader } from "@app/api/cookies";
 import { SidebarSettings } from "@app/components/SidebarSettings";
 import { GetOrgUserResponse } from "@server/routers/user";
+import OrgUserProvider from "@app/providers/OrgUserProvider";
+import {
+    Breadcrumb,
+    BreadcrumbItem,
+    BreadcrumbLink,
+    BreadcrumbList,
+    BreadcrumbPage,
+    BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+import Link from "next/link";
+import { ArrowLeft } from "lucide-react";
 
 interface UserLayoutProps {
     children: React.ReactNode;
@@ -30,28 +39,40 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
 
     const sidebarNavItems = [
         {
-            title: "General",
-            href: "/{orgId}/settings/access/users/{userId}",
+            title: "Access Controls",
+            href: "/{orgId}/settings/access/users/{userId}/access-controls",
         },
     ];
 
     return (
         <>
-            <div className="space-y-0.5 select-none mb-6">
-                <h2 className="text-2xl font-bold tracking-tight">
-                    User {user?.email}
-                </h2>
-                <p className="text-muted-foreground">
-                    Manage user access and permissions
-                </p>
-            </div>
-
-            <SidebarSettings
-                sidebarNavItems={sidebarNavItems}
-                limitWidth={true}
-            >
-                {children}
-            </SidebarSettings>
+            <OrgUserProvider orgUser={user}>
+                <div className="mb-4">
+                    <Link
+                        href="../../"
+                        className="text-muted-foreground hover:underline"
+                    >
+                        <div className="flex flex-row items-center gap-1">
+                            <ArrowLeft className="w-4 h-4" />{" "}
+                            <span>All Users</span>
+                        </div>
+                    </Link>
+                </div>
+
+                <div className="space-y-0.5 select-none mb-6">
+                    <h2 className="text-2xl font-bold tracking-tight">
+                        User {user?.email}
+                    </h2>
+                    <p className="text-muted-foreground">Manage user</p>
+                </div>
+
+                <SidebarSettings
+                    sidebarNavItems={sidebarNavItems}
+                    limitWidth={true}
+                >
+                    {children}
+                </SidebarSettings>
+            </OrgUserProvider>
         </>
     );
 }

+ 4 - 16
src/app/[orgId]/settings/access/users/[userId]/page.tsx

@@ -1,20 +1,8 @@
-import React from "react";
-import { Separator } from "@/components/ui/separator";
+import { redirect } from "next/navigation";
 
 export default async function UserPage(props: {
-    params: Promise<{ niceId: string }>;
+    params: Promise<{ orgId: string; userId: string }>;
 }) {
-    const params = await props.params;
-
-    return (
-        <div className="space-y-6">
-            <div>
-                <h3 className="text-lg font-medium">Manage User</h3>
-                <p className="text-sm text-muted-foreground">
-                    Manage user access and permissions
-                </p>
-            </div>
-            <Separator />
-        </div>
-    );
+    const { orgId, userId } = await props.params;
+    redirect(`/${orgId}/settings/access/users/${userId}/access-controls`);
 }

+ 135 - 122
src/app/[orgId]/settings/access/users/components/InviteUserForm.tsx

@@ -38,6 +38,7 @@ import {
 } from "@app/components/Credenza";
 import { useOrgContext } from "@app/hooks/useOrgContext";
 import { ListRolesResponse } from "@server/routers/role";
+import { formatAxiosError } from "@app/lib/utils";
 
 type InviteUserFormProps = {
     open: boolean;
@@ -94,9 +95,10 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
                     toast({
                         variant: "destructive",
                         title: "Failed to fetch roles",
-                        description:
-                            e.message ||
-                            "An error occurred while fetching the roles",
+                        description: formatAxiosError(
+                            e,
+                            "An error occurred while fetching the roles"
+                        ),
                     });
                 });
 
@@ -128,9 +130,10 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
                 toast({
                     variant: "destructive",
                     title: "Failed to invite user",
-                    description:
-                        e.response?.data?.message ||
-                        "An error occurred while inviting the user.",
+                    description: formatAxiosError(
+                        e,
+                        "An error occurred while inviting the user"
+                    ),
                 });
             });
 
@@ -168,125 +171,135 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
                         </CredenzaDescription>
                     </CredenzaHeader>
                     <CredenzaBody>
-                        {!inviteLink && (
-                            <Form {...form}>
-                                <form
-                                    onSubmit={form.handleSubmit(onSubmit)}
-                                    className="space-y-4"
-                                    id="invite-user-form"
-                                >
-                                    <FormField
-                                        control={form.control}
-                                        name="email"
-                                        render={({ field }) => (
-                                            <FormItem>
-                                                <FormLabel>Email</FormLabel>
-                                                <FormControl>
-                                                    <Input
-                                                        placeholder="Enter an email"
-                                                        {...field}
-                                                    />
-                                                </FormControl>
-                                                <FormMessage />
-                                            </FormItem>
-                                        )}
-                                    />
-                                    <FormField
-                                        control={form.control}
-                                        name="roleId"
-                                        render={({ field }) => (
-                                            <FormItem>
-                                                <FormLabel>Role</FormLabel>
-                                                <Select
-                                                    onValueChange={
-                                                        field.onChange
-                                                    }
-                                                >
-                                                    <FormControl>
-                                                        <SelectTrigger>
-                                                            <SelectValue placeholder="Select role" />
-                                                        </SelectTrigger>
-                                                    </FormControl>
-                                                    <SelectContent>
-                                                        {roles.map((role) => (
-                                                            <SelectItem
-                                                                key={
-                                                                    role.roleId
-                                                                }
-                                                                value={role.roleId.toString()}
-                                                            >
-                                                                {role.name}
-                                                            </SelectItem>
-                                                        ))}
-                                                    </SelectContent>
-                                                </Select>
-                                                <FormMessage />
-                                            </FormItem>
-                                        )}
-                                    />
-                                    <FormField
-                                        control={form.control}
-                                        name="validForHours"
-                                        render={({ field }) => (
-                                            <FormItem>
-                                                <FormLabel>Valid For</FormLabel>
-                                                <Select
-                                                    onValueChange={
-                                                        field.onChange
-                                                    }
-                                                    defaultValue={field.value.toString()}
-                                                >
+                        <div className="space-y-6">
+                            {!inviteLink && (
+                                <Form {...form}>
+                                    <form
+                                        onSubmit={form.handleSubmit(onSubmit)}
+                                        className="space-y-4"
+                                        id="invite-user-form"
+                                    >
+                                        <FormField
+                                            control={form.control}
+                                            name="email"
+                                            render={({ field }) => (
+                                                <FormItem>
+                                                    <FormLabel>Email</FormLabel>
                                                     <FormControl>
-                                                        <SelectTrigger>
-                                                            <SelectValue placeholder="Select duration" />
-                                                        </SelectTrigger>
+                                                        <Input
+                                                            placeholder="Enter an email"
+                                                            {...field}
+                                                        />
                                                     </FormControl>
-                                                    <SelectContent>
-                                                        {validFor.map(
-                                                            (option) => (
-                                                                <SelectItem
-                                                                    key={
-                                                                        option.hours
-                                                                    }
-                                                                    value={option.hours.toString()}
-                                                                >
-                                                                    {
-                                                                        option.name
-                                                                    }
-                                                                </SelectItem>
-                                                            )
-                                                        )}
-                                                    </SelectContent>
-                                                </Select>
-                                                <FormMessage />
-                                            </FormItem>
-                                        )}
-                                    />
-                                </form>
-                            </Form>
-                        )}
+                                                    <FormMessage />
+                                                </FormItem>
+                                            )}
+                                        />
+                                        <FormField
+                                            control={form.control}
+                                            name="roleId"
+                                            render={({ field }) => (
+                                                <FormItem>
+                                                    <FormLabel>Role</FormLabel>
+                                                    <Select
+                                                        onValueChange={
+                                                            field.onChange
+                                                        }
+                                                    >
+                                                        <FormControl>
+                                                            <SelectTrigger>
+                                                                <SelectValue placeholder="Select role" />
+                                                            </SelectTrigger>
+                                                        </FormControl>
+                                                        <SelectContent>
+                                                            {roles.map(
+                                                                (role) => (
+                                                                    <SelectItem
+                                                                        key={
+                                                                            role.roleId
+                                                                        }
+                                                                        value={role.roleId.toString()}
+                                                                    >
+                                                                        {
+                                                                            role.name
+                                                                        }
+                                                                    </SelectItem>
+                                                                )
+                                                            )}
+                                                        </SelectContent>
+                                                    </Select>
+                                                    <FormMessage />
+                                                </FormItem>
+                                            )}
+                                        />
+                                        <FormField
+                                            control={form.control}
+                                            name="validForHours"
+                                            render={({ field }) => (
+                                                <FormItem>
+                                                    <FormLabel>
+                                                        Valid For
+                                                    </FormLabel>
+                                                    <Select
+                                                        onValueChange={
+                                                            field.onChange
+                                                        }
+                                                        defaultValue={field.value.toString()}
+                                                    >
+                                                        <FormControl>
+                                                            <SelectTrigger>
+                                                                <SelectValue placeholder="Select duration" />
+                                                            </SelectTrigger>
+                                                        </FormControl>
+                                                        <SelectContent>
+                                                            {validFor.map(
+                                                                (option) => (
+                                                                    <SelectItem
+                                                                        key={
+                                                                            option.hours
+                                                                        }
+                                                                        value={option.hours.toString()}
+                                                                    >
+                                                                        {
+                                                                            option.name
+                                                                        }
+                                                                    </SelectItem>
+                                                                )
+                                                            )}
+                                                        </SelectContent>
+                                                    </Select>
+                                                    <FormMessage />
+                                                </FormItem>
+                                            )}
+                                        />
+                                    </form>
+                                </Form>
+                            )}
 
-                        {inviteLink && (
-                            <div className="max-w-md">
-                                <p className="mb-4">
-                                    The user has been successfully invited. They
-                                    must access the link below to accept the
-                                    invitation.
-                                </p>
-                                <p className="mb-4">
-                                    The invite will expire in{" "}
-                                    <b>
-                                        {expiresInDays}{" "}
-                                        {expiresInDays === 1 ? "day" : "days"}
-                                    </b>
-                                    .
-                                </p>
-                                <CopyTextBox
-                                    text={inviteLink}
-                                    wrapText={false}
-                                />
-                            </div>
-                        )}
+                            {inviteLink && (
+                                <div className="max-w-md space-y-4">
+                                    <p>
+                                        The user has been successfully invited.
+                                        They must access the link below to
+                                        accept the invitation.
+                                    </p>
+                                    <p>
+                                        The invite will expire in{" "}
+                                        <b>
+                                            {expiresInDays}{" "}
+                                            {expiresInDays === 1
+                                                ? "day"
+                                                : "days"}
+                                        </b>
+                                        .
+                                    </p>
+                                    <CopyTextBox
+                                        text={inviteLink}
+                                        wrapText={false}
+                                    />
+                                </div>
+                            )}
+                        </div>
                     </CredenzaBody>
                     <CredenzaFooter>
                         <Button

+ 0 - 226
src/app/[orgId]/settings/access/users/components/ManageUserForm.tsx

@@ -1,226 +0,0 @@
-"use client";
-
-import api from "@app/api";
-import { Button } from "@app/components/ui/button";
-import {
-    Form,
-    FormControl,
-    FormField,
-    FormItem,
-    FormLabel,
-    FormMessage,
-} from "@app/components/ui/form";
-import { Input } from "@app/components/ui/input";
-import {
-    Select,
-    SelectContent,
-    SelectItem,
-    SelectTrigger,
-    SelectValue,
-} from "@app/components/ui/select";
-import { useToast } from "@app/hooks/useToast";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { InviteUserResponse, ListUsersResponse } from "@server/routers/user";
-import { AxiosResponse } from "axios";
-import { useEffect, useState } from "react";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-import {
-    Credenza,
-    CredenzaBody,
-    CredenzaClose,
-    CredenzaContent,
-    CredenzaDescription,
-    CredenzaFooter,
-    CredenzaHeader,
-    CredenzaTitle,
-} from "@app/components/Credenza";
-import { useOrgContext } from "@app/hooks/useOrgContext";
-import { ListRolesResponse } from "@server/routers/role";
-import { ArrayElement } from "@server/types/ArrayElement";
-
-type ManageUserFormProps = {
-    open: boolean;
-    setOpen: (open: boolean) => void;
-    user: ArrayElement<ListUsersResponse["users"]>;
-    onUserUpdate(): (
-        user: ArrayElement<ListUsersResponse["users"]>
-    ) => Promise<void>;
-};
-
-const formSchema = z.object({
-    email: z.string().email({ message: "Please enter a valid email" }),
-    roleId: z.string().min(1, { message: "Please select a role" }),
-});
-
-export default function ManageUserForm({
-    open,
-    setOpen,
-    user,
-}: ManageUserFormProps) {
-    const { toast } = useToast();
-    const { org } = useOrgContext();
-
-    const [loading, setLoading] = useState(false);
-    const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
-
-    const form = useForm<z.infer<typeof formSchema>>({
-        resolver: zodResolver(formSchema),
-        defaultValues: {
-            email: user.email,
-            roleId: user.roleId?.toString(),
-        },
-    });
-
-    useEffect(() => {
-        if (!open) {
-            return;
-        }
-
-        async function fetchRoles() {
-            const res = await api
-                .get<AxiosResponse<ListRolesResponse>>(
-                    `/org/${org?.org.orgId}/roles`
-                )
-                .catch((e) => {
-                    console.error(e);
-                    toast({
-                        variant: "destructive",
-                        title: "Failed to fetch roles",
-                        description:
-                            e.message ||
-                            "An error occurred while fetching the roles",
-                    });
-                });
-
-            if (res?.status === 200) {
-                setRoles(res.data.data.roles);
-                // form.setValue(
-                //     "roleId",
-                //     res.data.data.roles[0].roleId.toString()
-                // );
-            }
-        }
-
-        fetchRoles();
-    }, [open]);
-
-    async function onSubmit(values: z.infer<typeof formSchema>) {
-        setLoading(true);
-
-        const res = await api
-            .post<AxiosResponse<InviteUserResponse>>(
-                `/role/${values.roleId}/add/${user.id}`
-            )
-            .catch((e) => {
-                toast({
-                    variant: "destructive",
-                    title: "Failed to add user to role",
-                    description:
-                        e.response?.data?.message ||
-                        "An error occurred while adding user to the role.",
-                });
-            });
-
-        if (res && res.status === 200) {
-            toast({
-                variant: "default",
-                title: "User invited",
-                description: "The user has been updated.",
-            });
-        }
-
-        setLoading(false);
-    }
-
-    return (
-        <>
-            <Credenza
-                open={open}
-                onOpenChange={(val) => {
-                    setOpen(val);
-                    setLoading(false);
-                    form.reset();
-                }}
-            >
-                <CredenzaContent>
-                    <CredenzaHeader>
-                        <CredenzaTitle>Manage User</CredenzaTitle>
-                        <CredenzaDescription>
-                            Update the role of the user in the organization.
-                        </CredenzaDescription>
-                    </CredenzaHeader>
-                    <CredenzaBody>
-                        <Form {...form}>
-                            <form
-                                onSubmit={form.handleSubmit(onSubmit)}
-                                className="space-y-4"
-                                id="manage-user-form"
-                            >
-                                <FormField
-                                    control={form.control}
-                                    name="email"
-                                    disabled={true}
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>Email</FormLabel>
-                                            <FormControl>
-                                                <Input
-                                                    placeholder="User's email"
-                                                    {...field}
-                                                />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="roleId"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>Role</FormLabel>
-                                            <Select
-                                                onValueChange={field.onChange}
-                                            >
-                                                <FormControl>
-                                                    <SelectTrigger>
-                                                        <SelectValue placeholder="Select role" />
-                                                    </SelectTrigger>
-                                                </FormControl>
-                                                <SelectContent>
-                                                    {roles.map((role) => (
-                                                        <SelectItem
-                                                            key={role.roleId}
-                                                            value={role.roleId.toString()}
-                                                        >
-                                                            {role.name}
-                                                        </SelectItem>
-                                                    ))}
-                                                </SelectContent>
-                                            </Select>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                            </form>
-                        </Form>
-                    </CredenzaBody>
-                    <CredenzaFooter>
-                        <Button
-                            type="submit"
-                            form="manage-user-form"
-                            loading={loading}
-                            disabled={loading}
-                        >
-                            Save User
-                        </Button>
-                        <CredenzaClose asChild>
-                            <Button variant="outline">Close</Button>
-                        </CredenzaClose>
-                    </CredenzaFooter>
-                </CredenzaContent>
-            </Credenza>
-        </>
-    );
-}

+ 68 - 44
src/app/[orgId]/settings/access/users/components/UsersTable.tsx

@@ -8,7 +8,7 @@ import {
     DropdownMenuTrigger,
 } from "@app/components/ui/dropdown-menu";
 import { Button } from "@app/components/ui/button";
-import { ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
+import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
 import { UsersDataTable } from "./UsersDataTable";
 import { useState } from "react";
 import InviteUserForm from "./InviteUserForm";
@@ -17,8 +17,9 @@ import { useUserContext } from "@app/hooks/useUserContext";
 import api from "@app/api";
 import { useOrgContext } from "@app/hooks/useOrgContext";
 import { useToast } from "@app/hooks/useToast";
-import ManageUserForm from "./ManageUserForm";
 import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { formatAxiosError } from "@app/lib/utils";
 
 export type UserRow = {
     id: string;
@@ -39,6 +40,8 @@ export default function UsersTable({ users: u }: UsersTableProps) {
 
     const [users, setUsers] = useState<UserRow[]>(u);
 
+    const router = useRouter();
+
     const user = useUserContext();
     const { org } = useOrgContext();
     const { toast } = useToast();
@@ -109,43 +112,63 @@ export default function UsersTable({ users: u }: UsersTableProps) {
 
                 return (
                     <>
-                        {!userRow.isOwner && (
-                            <DropdownMenu>
-                                <DropdownMenuTrigger asChild>
+                        <div className="flex items-center justify-end">
+                            {!userRow.isOwner && (
+                                <>
+                                    <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={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
+                                                >
+                                                    Manage User
+                                                </Link>
+                                            </DropdownMenuItem>
+                                            {userRow.email !== user?.email && (
+                                                <DropdownMenuItem>
+                                                    <button
+                                                        className="text-red-600 hover:text-red-800"
+                                                        onClick={() => {
+                                                            setIsDeleteModalOpen(
+                                                                true
+                                                            );
+                                                            setSelectedUser(
+                                                                userRow
+                                                            );
+                                                        }}
+                                                    >
+                                                        Remove User
+                                                    </button>
+                                                </DropdownMenuItem>
+                                            )}
+                                        </DropdownMenuContent>
+                                    </DropdownMenu>
                                     <Button
-                                        variant="ghost"
-                                        className="h-8 w-8 p-0"
+                                        variant={"gray"}
+                                        className="ml-2"
+                                        onClick={() =>
+                                            router.push(
+                                                `/${org?.org.orgId}/settings/access/users/${userRow.id}`
+                                            )
+                                        }
                                     >
-                                        <span className="sr-only">
-                                            Open menu
-                                        </span>
-                                        <MoreHorizontal className="h-4 w-4" />
+                                        Manage{" "}
+                                        <ArrowRight className="ml-2 w-4 h-4" />
                                     </Button>
-                                </DropdownMenuTrigger>
-                                <DropdownMenuContent align="end">
-                                    <DropdownMenuItem>
-                                        <Link
-                                            href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
-                                        >
-                                            Manage User
-                                        </Link>
-                                    </DropdownMenuItem>
-                                    {userRow.email !== user?.email && (
-                                        <DropdownMenuItem>
-                                            <button
-                                                className="text-red-600 hover:text-red-800"
-                                                onClick={() => {
-                                                    setIsDeleteModalOpen(true);
-                                                    setSelectedUser(userRow);
-                                                }}
-                                            >
-                                                Remove User
-                                            </button>
-                                        </DropdownMenuItem>
-                                    )}
-                                </DropdownMenuContent>
-                            </DropdownMenu>
-                        )}
+                                </>
+                            )}
+                        </div>
                     </>
                 );
             },
@@ -160,9 +183,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
                     toast({
                         variant: "destructive",
                         title: "Failed to remove user",
-                        description:
-                            e.message ??
-                            "An error occurred while removing the user.",
+                        description: formatAxiosError(
+                            e,
+                            "An error occurred while removing the user."
+                        ),
                     });
                 });
 
@@ -190,13 +214,13 @@ export default function UsersTable({ users: u }: UsersTableProps) {
                     setSelectedUser(null);
                 }}
                 dialog={
-                    <div>
-                        <p className="mb-2">
+                    <div className="space-y-4">
+                        <p>
                             Are you sure you want to remove{" "}
                             <b>{selectedUser?.email}</b> from the organization?
                         </p>
 
-                        <p className="mb-2">
+                        <p>
                             Once removed, this user will no longer have access
                             to the organization. You can always re-invite them
                             later, but they will need to accept the invitation
@@ -209,10 +233,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
                         </p>
                     </div>
                 }
-                buttonText="Confirm remove user"
+                buttonText="Confirm Remove User"
                 onConfirm={removeUser}
                 string={selectedUser?.email ?? ""}
-                title="Remove user from organization"
+                title="Remove User from Organization"
             />
 
             <InviteUserForm

+ 9 - 5
src/app/[orgId]/settings/access/users/page.tsx

@@ -8,6 +8,8 @@ import { cache } from "react";
 import OrgProvider from "@app/providers/OrgProvider";
 import UserProvider from "@app/providers/UserProvider";
 import { verifySession } from "@app/lib/auth/verifySession";
+import { SidebarSettings } from "@app/components/SidebarSettings";
+import AccessPageHeaderAndNav from "../components/AccessPageHeaderAndNav";
 
 type UsersPageProps = {
     params: Promise<{ orgId: string }>;
@@ -62,11 +64,13 @@ export default async function UsersPage(props: UsersPageProps) {
 
     return (
         <>
-            <UserProvider user={user!}>
-                <OrgProvider org={org}>
-                    <UsersTable users={userRows} />
-                </OrgProvider>
-            </UserProvider>
+            <AccessPageHeaderAndNav>
+                <UserProvider user={user!}>
+                    <OrgProvider org={org}>
+                        <UsersTable users={userRows} />
+                    </OrgProvider>
+                </UserProvider>
+            </AccessPageHeaderAndNav>
         </>
     );
 }

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

@@ -21,6 +21,7 @@ import {
     SelectValue,
 } from "@app/components/ui/select";
 import { useToast } from "@app/hooks/useToast";
+import { formatAxiosError } from "@app/lib/utils";
 import { ListOrgsResponse } from "@server/routers/org";
 import Link from "next/link";
 import { useRouter } from "next/navigation";
@@ -51,6 +52,7 @@ export default function Header({ email, orgName, name, orgs }: HeaderProps) {
                 console.error("Error logging out", e);
                 toast({
                     title: "Error logging out",
+                    description: formatAxiosError(e, "Error logging out"),
                 });
             })
             .then(() => {
@@ -95,7 +97,7 @@ export default function Header({ email, orgName, name, orgs }: HeaderProps) {
                             <DropdownMenuSeparator />
                             <DropdownMenuGroup>
                                 <DropdownMenuItem onClick={logout}>
-                                    Log out
+                                    Logout
                                 </DropdownMenuItem>
                             </DropdownMenuGroup>
                         </DropdownMenuContent>

+ 83 - 0
src/app/[orgId]/settings/general/layout.tsx

@@ -0,0 +1,83 @@
+import { internal } from "@app/api";
+import { authCookieHeader } from "@app/api/cookies";
+import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
+import { SidebarSettings } from "@app/components/SidebarSettings";
+import { verifySession } from "@app/lib/auth/verifySession";
+import OrgProvider from "@app/providers/OrgProvider";
+import OrgUserProvider from "@app/providers/OrgUserProvider";
+import { GetOrgResponse } from "@server/routers/org";
+import { GetOrgUserResponse } from "@server/routers/user";
+import { AxiosResponse } from "axios";
+import { redirect } from "next/navigation";
+import { cache } from "react";
+
+type GeneralSettingsProps = {
+    children: React.ReactNode;
+    params: Promise<{ orgId: string }>;
+};
+
+export default async function GeneralSettingsPage({
+    children,
+    params,
+}: GeneralSettingsProps) {
+    const { orgId } = await params;
+
+    const getUser = cache(verifySession);
+    const user = await getUser();
+
+    if (!user) {
+        redirect("/auth/login");
+    }
+
+    let orgUser = null;
+    try {
+        const getOrgUser = cache(async () =>
+            internal.get<AxiosResponse<GetOrgUserResponse>>(
+                `/org/${orgId}/user/${user.userId}`,
+                await authCookieHeader()
+            )
+        );
+        const res = await getOrgUser();
+        orgUser = res.data.data;
+    } catch {
+        redirect(`/${orgId}`);
+    }
+
+    let org = null;
+    try {
+        const getOrg = cache(async () =>
+            internal.get<AxiosResponse<GetOrgResponse>>(
+                `/org/${orgId}`,
+                await authCookieHeader()
+            )
+        );
+        const res = await getOrg();
+        org = res.data.data;
+    } catch {
+        redirect(`/${orgId}`);
+    }
+
+    const sidebarNavItems = [
+        {
+            title: "General",
+            href: `/{orgId}/settings/general`,
+        },
+    ];
+
+    return (
+        <>
+            <OrgProvider org={org}>
+                <OrgUserProvider orgUser={orgUser}>
+                    <SettingsSectionTitle
+                        title="General"
+                        description="Configure your organization's general settings"
+                    />
+
+                    <SidebarSettings sidebarNavItems={sidebarNavItems}>
+                        {children}
+                    </SidebarSettings>
+                </OrgUserProvider>
+            </OrgProvider>
+        </>
+    );
+}

+ 60 - 0
src/app/[orgId]/settings/general/page.tsx

@@ -0,0 +1,60 @@
+"use client";
+
+import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
+import { Button } from "@app/components/ui/button";
+import { useOrgContext } from "@app/hooks/useOrgContext";
+import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
+import { useState } from "react";
+
+export default function GeneralPage() {
+    const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+
+    const { orgUser } = userOrgUserContext();
+    const { org } = useOrgContext();
+
+    async function deleteOrg() {
+        console.log("not implemented");
+    }
+
+    return (
+        <>
+            <ConfirmDeleteDialog
+                open={isDeleteModalOpen}
+                setOpen={(val) => {
+                    setIsDeleteModalOpen(val);
+                }}
+                dialog={
+                    <div>
+                        <p className="mb-2">
+                            Are you sure you want to delete the organization{" "}
+                            <b>{org?.org.name}?</b>
+                        </p>
+
+                        <p className="mb-2">
+                            This action is irreversible and will delete all
+                            associated data.
+                        </p>
+
+                        <p>
+                            To confirm, type the name of the organization below.
+                        </p>
+                    </div>
+                }
+                buttonText="Confirm delete organization"
+                onConfirm={deleteOrg}
+                string={org?.org.name || ""}
+                title="Delete organization"
+            />
+
+            <div className="space-y-6">
+                {orgUser.isOwner ? (
+                    <Button onClick={() => setIsDeleteModalOpen(true)}>
+                        Delete Organization
+                    </Button>
+                ) : (
+                    <p>Nothing to see here</p>
+                )}
+            </div>
+        </>
+    );
+}

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

@@ -69,7 +69,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
         );
         const orgUser = await getOrgUser();
 
-        if (!orgUser.data.data.isAdmin || !orgUser.data.data.isOwner) {
+        if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) {
             throw new Error("User is not an admin or owner");
         }
     } catch {

+ 1 - 1
src/app/[orgId]/settings/page.tsx

@@ -6,7 +6,7 @@ type OrgPageProps = {
 
 export default async function SettingsPage(props: OrgPageProps) {
     const params = await props.params;
-    redirect(`/${params.orgId}/settings/sites`);
+    redirect(`/${params.orgId}/settings/resources`);
 
     return <></>;
 }

+ 204 - 0
src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx

@@ -0,0 +1,204 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import api from "@app/api";
+import { ListRolesResponse } from "@server/routers/role";
+import { useToast } from "@app/hooks/useToast";
+import { useOrgContext } from "@app/hooks/useOrgContext";
+import { useResourceContext } from "@app/hooks/useResourceContext";
+import { AxiosResponse } from "axios";
+import { formatAxiosError } from "@app/lib/utils";
+import { ListResourceRolesResponse } from "@server/routers/resource";
+import { Button } from "@app/components/ui/button";
+import { set, z } from "zod";
+import { Tag } from "emblor";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import {
+    Form,
+    FormControl,
+    FormDescription,
+    FormField,
+    FormItem,
+    FormLabel,
+    FormMessage,
+} from "@app/components/ui/form";
+import { TagInput } from "emblor";
+import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
+
+const FormSchema = z.object({
+    roles: z.array(
+        z.object({
+            id: z.string(),
+            text: z.string(),
+        })
+    ),
+});
+
+export default function ResourceAuthenticationPage() {
+    const { toast } = useToast();
+    const { org } = useOrgContext();
+    const { resource } = useResourceContext();
+
+    const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>(
+        []
+    );
+    const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
+
+    const [loading, setLoading] = useState(false);
+
+    const form = useForm<z.infer<typeof FormSchema>>({
+        resolver: zodResolver(FormSchema),
+        defaultValues: { roles: [] },
+    });
+
+    useEffect(() => {
+        api.get<AxiosResponse<ListRolesResponse>>(
+            `/org/${org?.org.orgId}/roles`
+        )
+            .then((res) => {
+                setAllRoles(
+                    res.data.data.roles
+                        .map((role) => ({
+                            id: role.roleId.toString(),
+                            text: role.name,
+                        }))
+                        .filter((role) => role.text !== "Admin")
+                );
+            })
+            .catch((e) => {
+                console.error(e);
+                toast({
+                    variant: "destructive",
+                    title: "Failed to fetch roles",
+                    description: formatAxiosError(
+                        e,
+                        "An error occurred while fetching the roles"
+                    ),
+                });
+            });
+
+        api.get<AxiosResponse<ListResourceRolesResponse>>(
+            `/resource/${resource.resourceId}/roles`
+        )
+            .then((res) => {
+                form.setValue(
+                    "roles",
+                    res.data.data.roles
+                        .map((i) => ({
+                            id: i.roleId.toString(),
+                            text: i.name,
+                        }))
+                        .filter((role) => role.text !== "Admin")
+                );
+            })
+            .catch((e) => {
+                console.error(e);
+                toast({
+                    variant: "destructive",
+                    title: "Failed to fetch roles",
+                    description: formatAxiosError(
+                        e,
+                        "An error occurred while fetching the roles"
+                    ),
+                });
+            });
+    }, []);
+
+    async function onSubmit(data: z.infer<typeof FormSchema>) {
+        try {
+            setLoading(true);
+            await api.post(`/resource/${resource.resourceId}/roles`, {
+                roleIds: data.roles.map((i) => parseInt(i.id)),
+            });
+
+            toast({
+                title: "Roles set",
+                description: "Roles set for resource successfully",
+            });
+        } catch (e) {
+            console.error(e);
+            toast({
+                variant: "destructive",
+                title: "Failed to set roles",
+                description: formatAxiosError(
+                    e,
+                    "An error occurred while setting the roles"
+                ),
+            });
+        } finally {
+            setLoading(false);
+        }
+    }
+
+    return (
+        <>
+            <div className="space-y-6 lg:max-w-2xl">
+                <SettingsSectionTitle
+                    title="Users & Roles"
+                    description="Configure who can visit this resource"
+                    size="1xl"
+                />
+
+                <Form {...form}>
+                    <form
+                        onSubmit={form.handleSubmit(onSubmit)}
+                        className="space-y-6"
+                    >
+                        <FormField
+                            control={form.control}
+                            name="roles"
+                            render={({ field }) => (
+                                <FormItem className="flex flex-col items-start">
+                                    <FormLabel>Roles</FormLabel>
+                                    <FormControl>
+                                        <TagInput
+                                            {...field}
+                                            activeTagIndex={activeTagIndex}
+                                            setActiveTagIndex={setActiveTagIndex}
+                                            placeholder="Enter a role"
+                                            tags={form.getValues().roles}
+                                            setTags={(newRoles) => {
+                                                form.setValue(
+                                                    "roles",
+                                                    newRoles as [Tag, ...Tag[]]
+                                                );
+                                            }}
+                                            enableAutocomplete={true}
+                                            autocompleteOptions={allRoles}
+                                            allowDuplicates={false}
+                                            restrictTagsToAutocompleteOptions={
+                                                true
+                                            }
+                                            sortTags={true}
+                                            styleClasses={{
+                                                tag: {
+                                                    body: "bg-muted hover:bg-accent text-foreground  p-2",
+                                                },
+                                                input: "border-none bg-transparent text-inherit placeholder:text-inherit shadow-none"
+                                            }}
+                                            inputFieldPosition={"top"}
+                                        />
+                                    </FormControl>
+                                    <FormDescription>
+                                        Users with these roles will be able to
+                                        access this resource. Admins can always
+                                        access this resource.
+                                    </FormDescription>
+                                    <FormMessage />
+                                </FormItem>
+                            )}
+                        />
+                        <Button
+                            type="submit"
+                            loading={loading}
+                            disabled={loading}
+                        >
+                            Save Changes
+                        </Button>
+                    </form>
+                </Form>
+            </div>
+        </>
+    );
+}

+ 0 - 241
src/app/[orgId]/settings/resources/[resourceId]/components/CreateResource.tsx

@@ -1,241 +0,0 @@
-"use client";
-
-import { zodResolver } from "@hookform/resolvers/zod";
-import {
-    CaretSortIcon,
-    CheckIcon,
-} from "@radix-ui/react-icons";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-import { cn } from "@/lib/utils";
-import { toast } from "@/hooks/useToast";
-import { Button} from "@/components/ui/button";
-import {
-    Form,
-    FormControl,
-    FormDescription,
-    FormField,
-    FormItem,
-    FormLabel,
-    FormMessage,
-} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import React, { useState, useEffect } from "react";
-import { api } from "@/api";
-import { useParams } from "next/navigation";
-import { useRouter } from "next/navigation";
-import {
-    Command,
-    CommandEmpty,
-    CommandGroup,
-    CommandInput,
-    CommandItem,
-    CommandList,
-} from "@/components/ui/command";
-import {
-    Popover,
-    PopoverContent,
-    PopoverTrigger,
-} from "@/components/ui/popover";
-import { ListSitesResponse } from "@server/routers/site";
-import { AxiosResponse } from "axios";
-import CustomDomainInput from "./CustomDomainInput";
-
-const method = [
-    { label: "Wireguard", value: "wg" },
-    { label: "Newt", value: "newt" },
-] as const;
-
-const accountFormSchema = z.object({
-    subdomain: z
-        .string()
-        .min(2, {
-            message: "Name must be at least 2 characters.",
-        })
-        .max(30, {
-            message: "Name must not be longer than 30 characters.",
-        }),
-    name: z.string(),
-    siteId: z.number(),
-});
-
-type AccountFormValues = z.infer<typeof accountFormSchema>;
-
-const defaultValues: Partial<AccountFormValues> = {
-    subdomain: "someanimalherefromapi",
-    name: "My Resource",
-};
-
-export function CreateResourceForm() {
-    const params = useParams();
-    const orgId = params.orgId;
-    const router = useRouter();
-
-    const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
-    const [domainSuffix, setDomainSuffix] = useState<string>(".example.com");
-
-    const form = useForm<AccountFormValues>({
-        resolver: zodResolver(accountFormSchema),
-        defaultValues,
-    });
-
-    useEffect(() => {
-        if (typeof window !== "undefined") {
-            const fetchSites = async () => {
-                const res = await api.get<AxiosResponse<ListSitesResponse>>(
-                    `/org/${orgId}/sites/`
-                );
-                setSites(res.data.data.sites);
-            };
-            fetchSites();
-        }
-    }, []);
-
-    async function onSubmit(data: AccountFormValues) {
-        console.log(data);
-
-        const res = await api
-            .put(`/org/${orgId}/site/${data.siteId}/resource/`, {
-                name: data.name,
-                subdomain: data.subdomain,
-                // subdomain: data.subdomain,
-            })
-            .catch((e) => {
-                toast({
-                    title: "Error creating resource...",
-                });
-            });
-
-        if (res && res.status === 201) {
-            const niceId = res.data.data.niceId;
-            // navigate to the resource page
-            router.push(`/${orgId}/settings/resources/${niceId}`);
-        }
-    }
-
-    return (
-        <>
-            <Form {...form}>
-                <form
-                    onSubmit={form.handleSubmit(onSubmit)}
-                    className="space-y-8"
-                >
-                    <FormField
-                        control={form.control}
-                        name="name"
-                        render={({ field }) => (
-                            <FormItem>
-                                <FormLabel>Name</FormLabel>
-                                <FormControl>
-                                    <Input placeholder="Your name" {...field} />
-                                </FormControl>
-                                <FormDescription>
-                                    This is the name that will be displayed for
-                                    this resource.
-                                </FormDescription>
-                                <FormMessage />
-                            </FormItem>
-                        )}
-                    />
-                    <FormField
-                        control={form.control}
-                        name="subdomain"
-                        render={({ field }) => (
-                            <FormItem>
-                                <FormLabel>Subdomain</FormLabel>
-                                <FormControl>
-                                    {/* <Input placeholder="Your name" {...field} /> */}
-                                    <CustomDomainInput
-                                        {...field}
-                                        domainSuffix={domainSuffix}
-                                        placeholder="Enter subdomain"
-                                    />
-                                </FormControl>
-                                <FormDescription>
-                                    This is the fully qualified domain name that
-                                    will be used to access the resource.
-                                </FormDescription>
-                                <FormMessage />
-                            </FormItem>
-                        )}
-                    />
-                    <FormField
-                        control={form.control}
-                        name="siteId"
-                        render={({ field }) => (
-                            <FormItem className="flex flex-col">
-                                <FormLabel>Site</FormLabel>
-                                <Popover>
-                                    <PopoverTrigger asChild>
-                                        <FormControl>
-                                            <Button
-                                                variant="outline"
-                                                role="combobox"
-                                                className={cn(
-                                                    "w-[350px] justify-between",
-                                                    !field.value &&
-                                                        "text-muted-foreground"
-                                                )}
-                                            >
-                                                {field.value
-                                                    ? sites.find(
-                                                          (site) =>
-                                                              site.siteId ===
-                                                              field.value
-                                                      )?.name
-                                                    : "Select site"}
-                                                <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
-                                            </Button>
-                                        </FormControl>
-                                    </PopoverTrigger>
-                                    <PopoverContent className="w-[350px] p-0">
-                                        <Command>
-                                            <CommandInput placeholder="Search site..." />
-                                            <CommandList>
-                                                <CommandEmpty>
-                                                    No site found.
-                                                </CommandEmpty>
-                                                <CommandGroup>
-                                                    {sites.map((site) => (
-                                                        <CommandItem
-                                                            value={site.name}
-                                                            key={site.siteId}
-                                                            onSelect={() => {
-                                                                form.setValue(
-                                                                    "siteId",
-                                                                    site.siteId
-                                                                );
-                                                            }}
-                                                        >
-                                                            <CheckIcon
-                                                                className={cn(
-                                                                    "mr-2 h-4 w-4",
-                                                                    site.siteId ===
-                                                                        field.value
-                                                                        ? "opacity-100"
-                                                                        : "opacity-0"
-                                                                )}
-                                                            />
-                                                            {site.name}
-                                                        </CommandItem>
-                                                    ))}
-                                                </CommandGroup>
-                                            </CommandList>
-                                        </Command>
-                                    </PopoverContent>
-                                </Popover>
-                                <FormDescription>
-                                    This is the site that will be used in the
-                                    dashboard.
-                                </FormDescription>
-                                <FormMessage />
-                            </FormItem>
-                        )}
-                    />
-
-                    <Button type="submit">Create Resource</Button>
-                </form>
-            </Form>
-        </>
-    );
-}

+ 9 - 11
src/app/[orgId]/settings/resources/[resourceId]/components/CustomDomainInput.tsx

@@ -6,19 +6,17 @@ import { Input } from "@/components/ui/input";
 interface CustomDomainInputProps {
     domainSuffix: string;
     placeholder?: string;
+    value: string;
     onChange?: (value: string) => void;
 }
 
-export default function CustomDomainInput(
-    {
-        domainSuffix,
-        placeholder = "Enter subdomain",
-        onChange,
-    }: CustomDomainInputProps = {
-        domainSuffix: ".example.com",
-    }
-) {
-    const [value, setValue] = React.useState("");
+export default function CustomDomainInput({
+    domainSuffix,
+    placeholder = "Enter subdomain",
+    value: defaultValue,
+    onChange,
+}: CustomDomainInputProps) {
+    const [value, setValue] = React.useState(defaultValue);
 
     const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
         const newValue = event.target.value;
@@ -29,7 +27,7 @@ export default function CustomDomainInput(
     };
 
     return (
-        <div className="relative w-full max-w-sm">
+        <div className="w-full">
             <div className="flex">
                 <Input
                     type="text"

+ 0 - 194
src/app/[orgId]/settings/resources/[resourceId]/components/GeneralForm.tsx

@@ -1,194 +0,0 @@
-"use client";
-
-import { zodResolver } from "@hookform/resolvers/zod";
-import { z } from "zod";
-import { cn } from "@/lib/utils";
-import { Button } from "@/components/ui/button";
-import {
-    Form,
-    FormControl,
-    FormDescription,
-    FormField,
-    FormItem,
-    FormLabel,
-    FormMessage,
-} from "@/components/ui/form";
-import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
-import { Input } from "@/components/ui/input";
-import {
-    Command,
-    CommandEmpty,
-    CommandGroup,
-    CommandInput,
-    CommandItem,
-    CommandList,
-} from "@/components/ui/command";
-
-import {
-    Popover,
-    PopoverContent,
-    PopoverTrigger,
-} from "@/components/ui/popover";
-import { useResourceContext } from "@app/hooks/useResourceContext";
-import { ListSitesResponse } from "@server/routers/site";
-import { useEffect, useState } from "react";
-import { AxiosResponse } from "axios";
-import api from "@app/api";
-import { useParams } from "next/navigation";
-import { useForm } from "react-hook-form";
-import { GetResourceResponse } from "@server/routers/resource";
-import { useToast } from "@app/hooks/useToast";
-
-const GeneralFormSchema = z.object({
-    name: z.string(),
-    siteId: z.number(),
-});
-
-type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
-
-export function GeneralForm() {
-    const params = useParams();
-    const orgId = params.orgId;
-    const { resource, updateResource } = useResourceContext();
-    const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
-    const { toast } = useToast();
-
-    const form = useForm<GeneralFormValues>({
-        resolver: zodResolver(GeneralFormSchema),
-        defaultValues: {
-            name: resource?.name,
-            siteId: resource?.siteId,
-        },
-        mode: "onChange",
-    });
-
-    useEffect(() => {
-        if (typeof window !== "undefined") {
-            const fetchSites = async () => {
-                const res = await api.get<AxiosResponse<ListSitesResponse>>(
-                    `/org/${orgId}/sites/`
-                );
-                setSites(res.data.data.sites);
-            };
-            fetchSites();
-        }
-    }, []);
-
-    async function onSubmit(data: GeneralFormValues) {
-        updateResource({ name: data.name, siteId: data.siteId });
-        await api
-            .post<AxiosResponse<GetResourceResponse>>(
-                `resource/${resource?.resourceId}`,
-                {
-                    name: data.name,
-                    siteId: data.siteId,
-                }
-            )
-            .catch((e) => {
-                toast({
-                    variant: "destructive",
-                    title: "Failed to update resource",
-                    description:
-                        e.response?.data?.message ||
-                        "An error occurred while updating the resource",
-                });
-            });
-    }
-
-    return (
-        <Form {...form}>
-            <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
-                <FormField
-                    control={form.control}
-                    name="name"
-                    render={({ field }) => (
-                        <FormItem>
-                            <FormLabel>Name</FormLabel>
-                            <FormControl>
-                                <Input {...field} />
-                            </FormControl>
-                            <FormDescription>
-                                This is the display name of the resource.
-                            </FormDescription>
-                            <FormMessage />
-                        </FormItem>
-                    )}
-                />
-                <FormField
-                    control={form.control}
-                    name="siteId"
-                    render={({ field }) => (
-                        <FormItem className="flex flex-col">
-                            <FormLabel>Site</FormLabel>
-                            <Popover>
-                                <PopoverTrigger asChild>
-                                    <FormControl>
-                                        <Button
-                                            variant="outline"
-                                            role="combobox"
-                                            className={cn(
-                                                "w-[350px] justify-between",
-                                                !field.value &&
-                                                    "text-muted-foreground"
-                                            )}
-                                        >
-                                            {field.value
-                                                ? sites.find(
-                                                      (site) =>
-                                                          site.siteId ===
-                                                          field.value
-                                                  )?.name
-                                                : "Select site"}
-                                            <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
-                                        </Button>
-                                    </FormControl>
-                                </PopoverTrigger>
-                                <PopoverContent className="w-[350px] p-0">
-                                    <Command>
-                                        <CommandInput placeholder="Search site..." />
-                                        <CommandList>
-                                            <CommandEmpty>
-                                                No site found.
-                                            </CommandEmpty>
-                                            <CommandGroup>
-                                                {sites.map((site) => (
-                                                    <CommandItem
-                                                        value={site.name}
-                                                        key={site.siteId}
-                                                        onSelect={() => {
-                                                            form.setValue(
-                                                                "siteId",
-                                                                site.siteId
-                                                            );
-                                                        }}
-                                                    >
-                                                        <CheckIcon
-                                                            className={cn(
-                                                                "mr-2 h-4 w-4",
-                                                                site.siteId ===
-                                                                    field.value
-                                                                    ? "opacity-100"
-                                                                    : "opacity-0"
-                                                            )}
-                                                        />
-                                                        {site.name}
-                                                    </CommandItem>
-                                                ))}
-                                            </CommandGroup>
-                                        </CommandList>
-                                    </Command>
-                                </PopoverContent>
-                            </Popover>
-                            <FormDescription>
-                                This is the site that will be used in the
-                                dashboard.
-                            </FormDescription>
-                            <FormMessage />
-                        </FormItem>
-                    )}
-                />
-                <Button type="submit">Update Resource</Button>
-            </form>
-        </Form>
-    );
-}

+ 87 - 0
src/app/[orgId]/settings/resources/[resourceId]/components/ResourceInfoBox.tsx

@@ -0,0 +1,87 @@
+"use client";
+
+import { useState } from "react";
+import { Card } from "@/components/ui/card";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { InfoIcon, LinkIcon, CheckIcon, CopyIcon } from "lucide-react";
+import { useOrgContext } from "@app/hooks/useOrgContext";
+import { useResourceContext } from "@app/hooks/useResourceContext";
+import Link from "next/link";
+
+type ResourceInfoBoxType = {};
+
+export default function ResourceInfoBox({}: ResourceInfoBoxType) {
+    const [copied, setCopied] = useState(false);
+
+    const { org } = useOrgContext();
+    const { resource } = useResourceContext();
+
+    const fullUrl = `${resource.ssl ? "https" : "http"}://${
+        resource.subdomain
+    }.${org.org.domain}`;
+
+    const copyToClipboard = async () => {
+        try {
+            await navigator.clipboard.writeText(fullUrl);
+            setCopied(true);
+            setTimeout(() => setCopied(false), 2000);
+        } catch (err) {
+            console.error("Failed to copy text: ", err);
+        }
+    };
+
+    return (
+        <Card>
+            <Alert>
+                <InfoIcon className="h-4 w-4" />
+                <AlertTitle className="font-semibold">
+                    Resource Information
+                </AlertTitle>
+                <AlertDescription className="mt-3">
+                    <p className="mb-2">
+                        The current full URL for this resource is:
+                    </p>
+                    <div className="flex items-center space-x-2 bg-muted p-2 rounded-md">
+                        <LinkIcon className="h-4 w-4" />
+                        <a
+                            href={fullUrl}
+                            target="_blank"
+                            rel="noopener noreferrer"
+                            className="text-sm font-mono flex-grow hover:underline"
+                        >
+                            {fullUrl}
+                        </a>
+                        <Button
+                            variant="outline"
+                            size="sm"
+                            onClick={copyToClipboard}
+                            className="ml-2"
+                            type="button"
+                        >
+                            {copied ? (
+                                <CheckIcon className="h-4 w-4 text-green-500" />
+                            ) : (
+                                <CopyIcon className="h-4 w-4" />
+                            )}
+                            <span className="ml-2">
+                                {copied ? "Copied!" : "Copy"}
+                            </span>
+                        </Button>
+                    </div>
+
+                    <p className="mt-3">
+                        To create a proxy to your private services,{" "}
+                        <Link
+                            href={`/${org.org.orgId}/settings/resources/${resource.resourceId}/connectivity`}
+                            className="text-primary hover:underline"
+                        >
+                            add targets
+                        </Link>{" "}
+                        to this resource
+                    </p>
+                </AlertDescription>
+            </Alert>
+        </Card>
+    );
+}

+ 541 - 0
src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx

@@ -0,0 +1,541 @@
+"use client";
+
+import { useEffect, useState, use } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+    Select,
+    SelectContent,
+    SelectItem,
+    SelectTrigger,
+    SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import api from "@app/api";
+import { AxiosResponse } from "axios";
+import { ListTargetsResponse } from "@server/routers/target/listTargets";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import {
+    Form,
+    FormControl,
+    FormDescription,
+    FormField,
+    FormItem,
+    FormLabel,
+    FormMessage,
+} from "@app/components/ui/form";
+import { CreateTargetResponse } from "@server/routers/target";
+import {
+    ColumnDef,
+    getFilteredRowModel,
+    getSortedRowModel,
+    getPaginationRowModel,
+    getCoreRowModel,
+    useReactTable,
+    flexRender,
+} from "@tanstack/react-table";
+import {
+    Table,
+    TableBody,
+    TableCell,
+    TableHead,
+    TableHeader,
+    TableRow,
+} from "@app/components/ui/table";
+import { useToast } from "@app/hooks/useToast";
+import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
+import { useResourceContext } from "@app/hooks/useResourceContext";
+import { ArrayElement } from "@server/types/ArrayElement";
+import { Dot } from "lucide-react";
+import { formatAxiosError } from "@app/lib/utils";
+
+const addTargetSchema = z.object({
+    ip: z.string().ip(),
+    method: z.string(),
+    port: z
+        .string()
+        .refine((val) => !isNaN(Number(val)), {
+            message: "Port must be a number",
+        })
+        .transform((val) => Number(val)),
+    // protocol: z.string(),
+});
+
+type AddTargetFormValues = z.infer<typeof addTargetSchema>;
+
+type LocalTarget = Omit<
+    ArrayElement<ListTargetsResponse["targets"]> & {
+        new?: boolean;
+        updated?: boolean;
+    },
+    "protocol"
+>;
+
+export default function ReverseProxyTargets(props: {
+    params: Promise<{ resourceId: number }>;
+}) {
+    const params = use(props.params);
+
+    const { toast } = useToast();
+    const { resource, updateResource } = useResourceContext();
+
+    const [targets, setTargets] = useState<LocalTarget[]>([]);
+    const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
+    const [sslEnabled, setSslEnabled] = useState(resource.ssl);
+
+    const [loading, setLoading] = useState(false);
+
+    const addTargetForm = useForm({
+        resolver: zodResolver(addTargetSchema),
+        defaultValues: {
+            ip: "",
+            method: "http",
+            port: "80",
+            // protocol: "TCP",
+        },
+    });
+
+    useEffect(() => {
+        const fetchSites = async () => {
+            const res = await api
+                .get<AxiosResponse<ListTargetsResponse>>(
+                    `/resource/${params.resourceId}/targets`
+                )
+                .catch((err) => {
+                    console.error(err);
+                    toast({
+                        variant: "destructive",
+                        title: "Failed to fetch targets",
+                        description: formatAxiosError(
+                            err,
+                            "An error occurred while fetching targets"
+                        ),
+                    });
+                });
+
+            if (res && res.status === 200) {
+                setTargets(res.data.data.targets);
+            }
+        };
+        fetchSites();
+    }, []);
+
+    async function addTarget(data: AddTargetFormValues) {
+        const newTarget: LocalTarget = {
+            ...data,
+            enabled: true,
+            targetId: new Date().getTime(),
+            new: true,
+            resourceId: resource.resourceId,
+        };
+
+        setTargets([...targets, newTarget]);
+        addTargetForm.reset();
+    }
+
+    const removeTarget = (targetId: number) => {
+        setTargets([
+            ...targets.filter((target) => target.targetId !== targetId),
+        ]);
+
+        if (!targets.find((target) => target.targetId === targetId)?.new) {
+            setTargetsToRemove([...targetsToRemove, targetId]);
+        }
+    };
+
+    async function updateTarget(targetId: number, data: Partial<LocalTarget>) {
+        setTargets(
+            targets.map((target) =>
+                target.targetId === targetId
+                    ? { ...target, ...data, updated: true }
+                    : target
+            )
+        );
+    }
+
+    async function saveAll() {
+        try {
+            setLoading(true);
+
+            const res = await api.post(`/resource/${params.resourceId}`, {
+                ssl: sslEnabled,
+            });
+
+            updateResource({ ssl: sslEnabled });
+
+            for (const target of targets) {
+                const data = {
+                    ip: target.ip,
+                    port: target.port,
+                    // protocol: target.protocol,
+                    method: target.method,
+                    enabled: target.enabled,
+                };
+
+                if (target.new) {
+                    const res = await api.put<
+                        AxiosResponse<CreateTargetResponse>
+                    >(`/resource/${params.resourceId}/target`, data);
+                } else if (target.updated) {
+                    const res = await api.post(
+                        `/target/${target.targetId}`,
+                        data
+                    );
+                }
+
+                setTargets([
+                    ...targets.map((t) => {
+                        return {
+                            ...t,
+                            new: false,
+                            updated: false,
+                        };
+                    }),
+                ]);
+            }
+
+            for (const targetId of targetsToRemove) {
+                await api.delete(`/target/${targetId}`);
+                setTargets(
+                    targets.filter((target) => target.targetId !== targetId)
+                );
+            }
+
+            toast({
+                title: "Resource updated",
+                description: "Resource and targets updated successfully",
+            });
+
+            setTargetsToRemove([]);
+        } catch (err) {
+            console.error(err);
+            toast({
+                variant: "destructive",
+                title: "Operation failed",
+                description: formatAxiosError(
+                    err,
+                    "An error occurred during the save operation"
+                ),
+            });
+        }
+
+        setLoading(false);
+    }
+
+    const columns: ColumnDef<LocalTarget>[] = [
+        {
+            accessorKey: "ip",
+            header: "IP Address",
+            cell: ({ row }) => (
+                <Input
+                    defaultValue={row.original.ip}
+                    onBlur={(e) =>
+                        updateTarget(row.original.targetId, {
+                            ip: e.target.value,
+                        })
+                    }
+                />
+            ),
+        },
+        {
+            accessorKey: "port",
+            header: "Port",
+            cell: ({ row }) => (
+                <Input
+                    type="number"
+                    defaultValue={row.original.port}
+                    onBlur={(e) =>
+                        updateTarget(row.original.targetId, {
+                            port: parseInt(e.target.value, 10),
+                        })
+                    }
+                />
+            ),
+        },
+        {
+            accessorKey: "method",
+            header: "Method",
+            cell: ({ row }) => (
+                <Select
+                    defaultValue={row.original.method}
+                    onValueChange={(value) =>
+                        updateTarget(row.original.targetId, { method: value })
+                    }
+                >
+                    <SelectTrigger>{row.original.method}</SelectTrigger>
+                    <SelectContent>
+                        <SelectItem value="http">http</SelectItem>
+                        <SelectItem value="https">https</SelectItem>
+                    </SelectContent>
+                </Select>
+            ),
+        },
+        // {
+        //     accessorKey: "protocol",
+        //     header: "Protocol",
+        //     cell: ({ row }) => (
+        //         <Select
+        //             defaultValue={row.original.protocol!}
+        //             onValueChange={(value) =>
+        //                 updateTarget(row.original.targetId, { protocol: value })
+        //             }
+        //         >
+        //             <SelectTrigger>{row.original.protocol}</SelectTrigger>
+        //             <SelectContent>
+        //                 <SelectItem value="TCP">TCP</SelectItem>
+        //                 <SelectItem value="UDP">UDP</SelectItem>
+        //             </SelectContent>
+        //         </Select>
+        //     ),
+        // },
+        {
+            accessorKey: "enabled",
+            header: "Enabled",
+            cell: ({ row }) => (
+                <Switch
+                    defaultChecked={row.original.enabled}
+                    onCheckedChange={(val) =>
+                        updateTarget(row.original.targetId, { enabled: val })
+                    }
+                />
+            ),
+        },
+        {
+            id: "actions",
+            cell: ({ row }) => (
+                <>
+                    <div className="flex items-center justify-end space-x-2">
+                        {/* <Dot
+                            className={
+                                row.original.new || row.original.updated
+                                    ? "opacity-100"
+                                    : "opacity-0"
+                            }
+                        /> */}
+
+                        <Button
+                            variant="outline"
+                            onClick={() => removeTarget(row.original.targetId)}
+                        >
+                            Delete
+                        </Button>
+                    </div>
+                </>
+            ),
+        },
+    ];
+
+    const table = useReactTable({
+        data: targets,
+        columns,
+        getCoreRowModel: getCoreRowModel(),
+        getPaginationRowModel: getPaginationRowModel(),
+        getSortedRowModel: getSortedRowModel(),
+        getFilteredRowModel: getFilteredRowModel(),
+    });
+
+    return (
+        <div>
+            <div className="space-y-6">
+                <SettingsSectionTitle
+                    title="SSL"
+                    description="Setup SSL to secure your connections with LetsEncrypt certificates"
+                    size="1xl"
+                />
+
+                <div className="flex items-center space-x-2">
+                    <Switch
+                        id="ssl-toggle"
+                        defaultChecked={resource.ssl}
+                        onCheckedChange={(val) => setSslEnabled(val)}
+                    />
+                    <Label htmlFor="ssl-toggle">Enable SSL (https)</Label>
+                </div>
+
+                <SettingsSectionTitle
+                    title="Targets"
+                    description="Setup targets to route traffic to your services"
+                    size="1xl"
+                />
+
+                <Form {...addTargetForm}>
+                    <form
+                        onSubmit={addTargetForm.handleSubmit(addTarget as any)}
+                    >
+                        <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+                            <FormField
+                                control={addTargetForm.control}
+                                name="ip"
+                                render={({ field }) => (
+                                    <FormItem>
+                                        <FormLabel>IP Address</FormLabel>
+                                        <FormControl>
+                                            <Input id="ip" {...field} />
+                                        </FormControl>
+                                        <FormDescription>
+                                            Enter the IP address of the target
+                                        </FormDescription>
+                                        <FormMessage />
+                                    </FormItem>
+                                )}
+                            />
+                            <FormField
+                                control={addTargetForm.control}
+                                name="method"
+                                render={({ field }) => (
+                                    <FormItem>
+                                        <FormLabel>Method</FormLabel>
+                                        <FormControl>
+                                            <Select
+                                                {...field}
+                                                onValueChange={(value) => {
+                                                    addTargetForm.setValue(
+                                                        "method",
+                                                        value
+                                                    );
+                                                }}
+                                            >
+                                                <SelectTrigger id="method">
+                                                    <SelectValue placeholder="Select method" />
+                                                </SelectTrigger>
+                                                <SelectContent>
+                                                    <SelectItem value="http">
+                                                        HTTP
+                                                    </SelectItem>
+                                                    <SelectItem value="https">
+                                                        HTTPS
+                                                    </SelectItem>
+                                                </SelectContent>
+                                            </Select>
+                                        </FormControl>
+                                        <FormDescription>
+                                            Choose the method for how the target
+                                            is accessed
+                                        </FormDescription>
+                                        <FormMessage />
+                                    </FormItem>
+                                )}
+                            />
+                            <FormField
+                                control={addTargetForm.control}
+                                name="port"
+                                render={({ field }) => (
+                                    <FormItem>
+                                        <FormLabel>Port</FormLabel>
+                                        <FormControl>
+                                            <Input
+                                                id="port"
+                                                type="number"
+                                                {...field}
+                                                required
+                                            />
+                                        </FormControl>
+                                        <FormDescription>
+                                            Specify the port number for the
+                                            target
+                                        </FormDescription>
+                                        <FormMessage />
+                                    </FormItem>
+                                )}
+                            />
+                            {/* <FormField
+                                    control={addTargetForm.control}
+                                    name="protocol"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>Protocol</FormLabel>
+                                            <FormControl>
+                                                <Select
+                                                    {...field}
+                                                    onValueChange={(value) => {
+                                                        addTargetForm.setValue(
+                                                            "protocol",
+                                                            value
+                                                        );
+                                                    }}
+                                                >
+                                                    <SelectTrigger id="protocol">
+                                                        <SelectValue placeholder="Select protocol" />
+                                                    </SelectTrigger>
+                                                    <SelectContent>
+                                                        <SelectItem value="UDP">
+                                                            UDP
+                                                        </SelectItem>
+                                                        <SelectItem value="TCP">
+                                                            TCP
+                                                        </SelectItem>
+                                                    </SelectContent>
+                                                </Select>
+                                            </FormControl>
+                                            <FormDescription>
+                                                Select the protocol used by the
+                                                target
+                                            </FormDescription>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                /> */}
+                        </div>
+                        <Button type="submit" variant="gray">
+                            Add Target
+                        </Button>
+                    </form>
+                </Form>
+
+                <div className="rounded-md border">
+                    <Table>
+                        <TableHeader>
+                            {table.getHeaderGroups().map((headerGroup) => (
+                                <TableRow key={headerGroup.id}>
+                                    {headerGroup.headers.map((header) => (
+                                        <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}>
+                                        {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 targets. Add a target using the form.
+                                    </TableCell>
+                                </TableRow>
+                            )}
+                        </TableBody>
+                    </Table>
+                </div>
+
+                <Button onClick={saveAll} loading={loading} disabled={loading}>
+                    Save Changes
+                </Button>
+            </div>
+        </div>
+    );
+}

+ 272 - 0
src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx

@@ -0,0 +1,272 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import { cn, formatAxiosError } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import {
+    Form,
+    FormControl,
+    FormDescription,
+    FormField,
+    FormItem,
+    FormLabel,
+    FormMessage,
+} from "@/components/ui/form";
+import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
+import { Input } from "@/components/ui/input";
+import {
+    Command,
+    CommandEmpty,
+    CommandGroup,
+    CommandInput,
+    CommandItem,
+    CommandList,
+} from "@/components/ui/command";
+
+import {
+    Popover,
+    PopoverContent,
+    PopoverTrigger,
+} from "@/components/ui/popover";
+import { useResourceContext } from "@app/hooks/useResourceContext";
+import { ListSitesResponse } from "@server/routers/site";
+import { useEffect, useState } from "react";
+import { AxiosResponse } from "axios";
+import api from "@app/api";
+import { useParams } from "next/navigation";
+import { useForm } from "react-hook-form";
+import { GetResourceResponse } from "@server/routers/resource";
+import { useToast } from "@app/hooks/useToast";
+import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
+import { useOrgContext } from "@app/hooks/useOrgContext";
+import CustomDomainInput from "../components/CustomDomainInput";
+import ResourceInfoBox from "../components/ResourceInfoBox";
+import { subdomainSchema } from "@server/schemas/subdomainSchema";
+
+const GeneralFormSchema = z.object({
+    name: z.string(),
+    subdomain: subdomainSchema,
+    // siteId: z.number(),
+});
+
+type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
+
+export default function GeneralForm() {
+    const params = useParams();
+    const { toast } = useToast();
+    const { resource, updateResource } = useResourceContext();
+    const { org } = useOrgContext();
+
+    const orgId = params.orgId;
+
+    const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
+    const [saveLoading, setSaveLoading] = useState(false);
+    const [domainSuffix, setDomainSuffix] = useState(org.org.domain);
+
+    const form = useForm<GeneralFormValues>({
+        resolver: zodResolver(GeneralFormSchema),
+        defaultValues: {
+            name: resource.name,
+            subdomain: resource.subdomain,
+            // siteId: resource.siteId!,
+        },
+        mode: "onChange",
+    });
+
+    useEffect(() => {
+        const fetchSites = async () => {
+            const res = await api.get<AxiosResponse<ListSitesResponse>>(
+                `/org/${orgId}/sites/`
+            );
+            setSites(res.data.data.sites);
+        };
+        fetchSites();
+    }, []);
+
+    async function onSubmit(data: GeneralFormValues) {
+        setSaveLoading(true);
+
+        api.post<AxiosResponse<GetResourceResponse>>(
+            `resource/${resource?.resourceId}`,
+            {
+                name: data.name,
+                subdomain: data.subdomain,
+                // siteId: data.siteId,
+            }
+        )
+            .catch((e) => {
+                toast({
+                    variant: "destructive",
+                    title: "Failed to update resource",
+                    description: formatAxiosError(
+                        e,
+                        "An error occurred while updating the resource"
+                    ),
+                });
+            })
+            .then(() => {
+                toast({
+                    title: "Resource updated",
+                    description: "The resource has been updated successfully",
+                });
+
+                updateResource({ name: data.name, subdomain: data.subdomain });
+            })
+            .finally(() => setSaveLoading(false));
+    }
+
+    return (
+        <>
+            <div className="lg:max-w-2xl space-y-6">
+                <SettingsSectionTitle
+                    title="General Settings"
+                    description="Configure the general settings for this resource"
+                    size="1xl"
+                />
+
+                <Form {...form}>
+                    <form
+                        onSubmit={form.handleSubmit(onSubmit)}
+                        className="space-y-6"
+                    >
+                        <FormField
+                            control={form.control}
+                            name="name"
+                            render={({ field }) => (
+                                <FormItem>
+                                    <FormLabel>Name</FormLabel>
+                                    <FormControl>
+                                        <Input {...field} />
+                                    </FormControl>
+                                    <FormDescription>
+                                        This is the display name of the resource
+                                    </FormDescription>
+                                    <FormMessage />
+                                </FormItem>
+                            )}
+                        />
+
+                        <SettingsSectionTitle
+                            title="Domain"
+                            description="Define the domain that users will use to access this resource"
+                            size="1xl"
+                        />
+
+                        <FormField
+                            control={form.control}
+                            name="subdomain"
+                            render={({ field }) => (
+                                <FormItem>
+                                    <FormLabel>Subdomain</FormLabel>
+                                    <FormControl>
+                                        <CustomDomainInput
+                                            value={field.value}
+                                            domainSuffix={domainSuffix}
+                                            placeholder="Enter subdomain"
+                                            onChange={(value) =>
+                                                form.setValue(
+                                                    "subdomain",
+                                                    value
+                                                )
+                                            }
+                                        />
+                                    </FormControl>
+                                    {/* <FormDescription>
+                                        This is the subdomain that will be used
+                                        to access the resource
+                                    </FormDescription> */}
+                                    <FormMessage />
+                                </FormItem>
+                            )}
+                        />
+                        {/* <FormField
+                            control={form.control}
+                            name="siteId"
+                            render={({ field }) => (
+                                <FormItem className="flex flex-col">
+                                    <FormLabel>Site</FormLabel>
+                                    <Popover>
+                                        <PopoverTrigger asChild>
+                                            <FormControl>
+                                                <Button
+                                                    variant="outline"
+                                                    role="combobox"
+                                                    className={cn(
+                                                        "w-[350px] justify-between",
+                                                        !field.value &&
+                                                            "text-muted-foreground"
+                                                    )}
+                                                >
+                                                    {field.value
+                                                        ? sites.find(
+                                                              (site) =>
+                                                                  site.siteId ===
+                                                                  field.value
+                                                          )?.name
+                                                        : "Select site"}
+                                                    <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+                                                </Button>
+                                            </FormControl>
+                                        </PopoverTrigger>
+                                        <PopoverContent className="w-[350px] p-0">
+                                            <Command>
+                                                <CommandInput placeholder="Search sites" />
+                                                <CommandList>
+                                                    <CommandEmpty>
+                                                        No sites found.
+                                                    </CommandEmpty>
+                                                    <CommandGroup>
+                                                        {sites.map((site) => (
+                                                            <CommandItem
+                                                                value={
+                                                                    site.name
+                                                                }
+                                                                key={
+                                                                    site.siteId
+                                                                }
+                                                                onSelect={() => {
+                                                                    form.setValue(
+                                                                        "siteId",
+                                                                        site.siteId
+                                                                    );
+                                                                }}
+                                                            >
+                                                                <CheckIcon
+                                                                    className={cn(
+                                                                        "mr-2 h-4 w-4",
+                                                                        site.siteId ===
+                                                                            field.value
+                                                                            ? "opacity-100"
+                                                                            : "opacity-0"
+                                                                    )}
+                                                                />
+                                                                {site.name}
+                                                            </CommandItem>
+                                                        ))}
+                                                    </CommandGroup>
+                                                </CommandList>
+                                            </Command>
+                                        </PopoverContent>
+                                    </Popover>
+                                    <FormDescription>
+                                        This is the site that will be used in
+                                        the dashboard.
+                                    </FormDescription>
+                                    <FormMessage />
+                                </FormItem>
+                            )}
+                        /> */}
+                        <Button
+                            type="submit"
+                            loading={saveLoading}
+                            disabled={saveLoading}
+                        >
+                            Save Changes
+                        </Button>
+                    </form>
+                </Form>
+            </div>
+        </>
+    );
+}

+ 70 - 34
src/app/[orgId]/settings/resources/[resourceId]/layout.tsx

@@ -5,6 +5,13 @@ import { AxiosResponse } from "axios";
 import { redirect } from "next/navigation";
 import { authCookieHeader } from "@app/api/cookies";
 import { SidebarSettings } from "@app/components/SidebarSettings";
+import Link from "next/link";
+import { ArrowLeft } from "lucide-react";
+import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
+import { GetOrgResponse } from "@server/routers/org";
+import OrgProvider from "@app/providers/OrgProvider";
+import { cache } from "react";
+import ResourceInfoBox from "./components/ResourceInfoBox";
 
 interface ResourceLayoutProps {
     children: React.ReactNode;
@@ -17,56 +24,85 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
     const { children } = props;
 
     let resource = null;
+    try {
+        const res = await internal.get<AxiosResponse<GetResourceResponse>>(
+            `/resource/${params.resourceId}`,
+            await authCookieHeader()
+        );
+        resource = res.data.data;
+    } catch {
+        redirect(`/${params.orgId}/settings/resources`);
+    }
+
+    if (!resource) {
+        redirect(`/${params.orgId}/settings/resources`);
+    }
 
-    if (params.resourceId !== "create") {
-        try {
-            const res = await internal.get<AxiosResponse<GetResourceResponse>>(
-                `/resource/${params.resourceId}`,
+    let org = null;
+    try {
+        const getOrg = cache(async () =>
+            internal.get<AxiosResponse<GetOrgResponse>>(
+                `/org/${params.orgId}`,
                 await authCookieHeader()
-            );
-            resource = res.data.data;
-        } catch {
-            redirect(`/${params.orgId}/settings/resources`);
-        }
+            )
+        );
+        const res = await getOrg();
+        org = res.data.data;
+    } catch {
+        redirect(`/${params.orgId}/settings/resources`);
+    }
+
+    if (!org) {
+        redirect(`/${params.orgId}/settings/resources`);
     }
 
     const sidebarNavItems = [
         {
             title: "General",
-            href: `/{orgId}/settings/resources/resourceId`,
+            href: `/{orgId}/settings/resources/{resourceId}/general`,
         },
         {
-            title: "Targets",
-            href: `/{orgId}/settings/resources/{resourceId}/targets`,
+            title: "Connectivity",
+            href: `/{orgId}/settings/resources/{resourceId}/connectivity`,
+        },
+        {
+            title: "Authentication",
+            href: `/{orgId}/settings/resources/{resourceId}/authentication`,
         },
     ];
 
-    const isCreate = params.resourceId === "create";
-
     return (
         <>
-            <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 className="mb-4">
+                <Link
+                    href="../../"
+                    className="text-muted-foreground hover:underline"
+                >
+                    <div className="flex flex-row items-center gap-1">
+                        <ArrowLeft className="w-4 h-4" />{" "}
+                        <span>All Resources</span>
+                    </div>
+                </Link>
             </div>
 
-            <ResourceProvider resource={resource}>
-                <SidebarSettings
-                    sidebarNavItems={sidebarNavItems}
-                    disabled={isCreate}
-                    limitWidth={true}
-                >
-                    {children}
-                </SidebarSettings>
-            </ResourceProvider>
+            <SettingsSectionTitle
+                title={`${resource?.name} Settings`}
+                description="Configure the settings on your resource"
+            />
+
+            <OrgProvider org={org}>
+                <ResourceProvider resource={resource}>
+                    <SidebarSettings
+                        sidebarNavItems={sidebarNavItems}
+                        limitWidth={false}
+                    >
+                        <div className="mb-8">
+                            <ResourceInfoBox />
+                        </div>
+                        {children}
+                    </SidebarSettings>
+                </ResourceProvider>
+            </OrgProvider>
         </>
     );
 }

+ 4 - 23
src/app/[orgId]/settings/resources/[resourceId]/page.tsx

@@ -1,29 +1,10 @@
-import React from "react";
-import { Separator } from "@/components/ui/separator";
-import { CreateResourceForm } from "./components/CreateResource";
-import { GeneralForm } from "./components/GeneralForm";
+import { redirect } from "next/navigation";
 
 export default async function ResourcePage(props: {
-    params: Promise<{ resourceId: number | string }>;
+    params: Promise<{ resourceId: number | string; orgId: string }>;
 }) {
     const params = await props.params;
-    const isCreate = params.resourceId === "create";
-
-    return (
-        <div className="space-y-6">
-            <div>
-                <h3 className="text-lg font-medium">
-                    {isCreate ? "Create Resource" : "General"}
-                </h3>
-                <p className="text-sm text-muted-foreground">
-                    {isCreate
-                        ? "Create a new resource"
-                        : "Edit basic resource settings"}
-                </p>
-            </div>
-            <Separator />
-
-            {isCreate ? <CreateResourceForm /> : <GeneralForm />}
-        </div>
+    redirect(
+        `/${params.orgId}/settings/resources/${params.resourceId}/connectivity`
     );
 }

+ 0 - 272
src/app/[orgId]/settings/resources/[resourceId]/targets/page.tsx

@@ -1,272 +0,0 @@
-"use client";
-
-import { useEffect, useState, use } from "react";
-import { PlusCircle, Trash2, Server, Globe, Cpu } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import {
-    Select,
-    SelectContent,
-    SelectItem,
-    SelectTrigger,
-    SelectValue,
-} from "@/components/ui/select";
-import { Switch } from "@/components/ui/switch";
-import { Badge } from "@/components/ui/badge";
-import api from "@app/api";
-import { AxiosResponse } from "axios";
-import { ListTargetsResponse } from "@server/routers/target/listTargets";
-
-const isValidIPAddress = (ip: string) => {
-    const ipv4Regex =
-        /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
-    return ipv4Regex.test(ip);
-};
-
-export default function ReverseProxyTargets(
-    props: {
-        params: Promise<{ resourceId: number }>;
-    }
-) {
-    const params = use(props.params);
-    const [targets, setTargets] = useState<ListTargetsResponse["targets"]>([]);
-    const [nextId, setNextId] = useState(1);
-    const [ipError, setIpError] = useState("");
-
-    useEffect(() => {
-        if (typeof window !== "undefined") {
-            const fetchSites = async () => {
-                const res = await api.get<AxiosResponse<ListTargetsResponse>>(
-                    `/resource/${params.resourceId}/targets`,
-                );
-                setTargets(res.data.data.targets);
-            };
-            fetchSites();
-        }
-    }, []);
-
-    const [newTarget, setNewTarget] = useState({
-        resourceId: params.resourceId,
-        ip: "",
-        method: "http",
-        port: 80,
-        protocol: "TCP",
-    });
-
-    const addTarget = () => {
-        if (!isValidIPAddress(newTarget.ip)) {
-            setIpError("Invalid IP address format");
-            return;
-        }
-        setIpError("");
-
-        api.put(`/resource/${params.resourceId}/target`, {
-            ...newTarget,
-            resourceId: undefined,
-        })
-            .catch((err) => {
-                console.error(err);
-            })
-            .then((res) => {
-                // console.log(res)
-                setTargets([
-                    ...targets,
-                    { ...newTarget, targetId: nextId, enabled: true },
-                ]);
-                setNextId(nextId + 1);
-                setNewTarget({
-                    resourceId: params.resourceId,
-                    ip: "",
-                    method: "GET",
-                    port: 80,
-                    protocol: "http",
-                });
-            });
-    };
-
-    const removeTarget = (targetId: number) => {
-        api.delete(`/target/${targetId}`)
-            .catch((err) => {
-                console.error(err);
-            })
-            .then((res) => {
-                setTargets(
-                    targets.filter((target) => target.targetId !== targetId),
-                );
-            });
-    };
-
-    const toggleTarget = (targetId: number) => {
-        setTargets(
-            targets.map((target) =>
-                target.targetId === targetId
-                    ? { ...target, enabled: !target.enabled }
-                    : target,
-            ),
-        );
-        api.post(`/target/${targetId}`, {
-            enabled: !targets.find((target) => target.targetId === targetId)
-                ?.enabled,
-        }).catch((err) => {
-            console.error(err);
-        });
-    };
-
-    return (
-        <div className="space-y-6">
-            <form
-                onSubmit={(e) => {
-                    e.preventDefault();
-                    addTarget();
-                }}
-                className="space-y-4"
-            >
-                <div className="grid grid-cols-2 gap-4">
-                    <div className="space-y-2">
-                        <Label htmlFor="ip">IP Address</Label>
-                        <Input
-                            id="ip"
-                            value={newTarget.ip}
-                            onChange={(e) => {
-                                setNewTarget({
-                                    ...newTarget,
-                                    ip: e.target.value,
-                                });
-                                setIpError("");
-                            }}
-                            required
-                        />
-                        {ipError && (
-                            <p className="text-red-500 text-sm">{ipError}</p>
-                        )}
-                    </div>
-                    <div className="space-y-2">
-                        <Label htmlFor="method">Method</Label>
-                        <Select
-                            value={newTarget.method}
-                            onValueChange={(value) =>
-                                setNewTarget({ ...newTarget, method: value })
-                            }
-                        >
-                            <SelectTrigger id="method">
-                                <SelectValue placeholder="Select method" />
-                            </SelectTrigger>
-                            <SelectContent>
-                                <SelectItem value="http">HTTP</SelectItem>
-                                <SelectItem value="https">HTTPS</SelectItem>
-                            </SelectContent>
-                        </Select>
-                    </div>
-                    <div className="space-y-2">
-                        <Label htmlFor="port">Port</Label>
-                        <Input
-                            id="port"
-                            type="number"
-                            value={newTarget.port}
-                            onChange={(e) =>
-                                setNewTarget({
-                                    ...newTarget,
-                                    port: parseInt(e.target.value),
-                                })
-                            }
-                            required
-                        />
-                    </div>
-                    <div className="space-y-2">
-                        <Label htmlFor="protocol">Protocol</Label>
-                        <Select
-                            value={newTarget.protocol}
-                            onValueChange={(value) =>
-                                setNewTarget({ ...newTarget, protocol: value })
-                            }
-                        >
-                            <SelectTrigger id="protocol">
-                                <SelectValue placeholder="Select protocol" />
-                            </SelectTrigger>
-                            <SelectContent>
-                                <SelectItem value="UDP">UDP</SelectItem>
-                                <SelectItem value="TCP">TCP</SelectItem>
-                            </SelectContent>
-                        </Select>
-                    </div>
-                </div>
-                <Button type="submit">
-                    <PlusCircle className="mr-2 h-4 w-4" /> Add Target
-                </Button>
-            </form>
-
-            <div className="space-y-4">
-                {targets.map((target, i) => (
-                    <Card
-                        key={i}
-                        id={`target-${target.targetId}`}
-                        className="w-full p-4"
-                    >
-                        <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2 px-0 pt-0">
-                            <CardTitle className="text-lg font-medium flex items-center">
-                                <Server className="mr-2 h-5 w-5" />
-                                Target {target.targetId}
-                            </CardTitle>
-                            <div className="flex flex-col items-end space-y-2">
-                                <Switch
-                                    checked={target.enabled}
-                                    onCheckedChange={() =>
-                                        toggleTarget(target.targetId)
-                                    }
-                                />
-                                <Button
-                                    variant="ghost"
-                                    size="sm"
-                                    className="text-destructive hover:text-destructive hover:bg-destructive/10"
-                                    onClick={() =>
-                                        removeTarget(target.targetId)
-                                    }
-                                >
-                                    <Trash2 className="h-4 w-4" />
-                                </Button>
-                            </div>
-                        </CardHeader>
-                        <CardContent className="px-0 py-2">
-                            <div className="grid gap-2 md:grid-cols-2 lg:grid-cols-3">
-                                <div className="flex items-center">
-                                    <Globe className="mr-2 h-4 w-4 text-muted-foreground" />
-                                    <span className="text-sm">
-                                        {target.ip}:{target.port}
-                                    </span>
-                                </div>
-                                <div className="flex items-center">
-                                    <Cpu className="mr-2 h-4 w-4 text-muted-foreground" />
-                                    <span className="text-sm">
-                                        {target.resourceId}
-                                    </span>
-                                </div>
-                                <div className="flex items-center space-x-2">
-                                    <Badge
-                                        variant={
-                                            target.enabled
-                                                ? "default"
-                                                : "secondary"
-                                        }
-                                    >
-                                        {target.method}
-                                    </Badge>
-                                    <Badge
-                                        variant={
-                                            target.enabled
-                                                ? "default"
-                                                : "secondary"
-                                        }
-                                    >
-                                        {target.protocol?.toUpperCase()}
-                                    </Badge>
-                                </div>
-                            </div>
-                        </CardContent>
-                    </Card>
-                ))}
-            </div>
-        </div>
-    );
-}

+ 314 - 0
src/app/[orgId]/settings/resources/components/CreateResourceForm.tsx

@@ -0,0 +1,314 @@
+"use client";
+
+import api from "@app/api";
+import { Button, buttonVariants } from "@app/components/ui/button";
+import {
+    Form,
+    FormControl,
+    FormDescription,
+    FormField,
+    FormItem,
+    FormLabel,
+    FormMessage,
+} from "@app/components/ui/form";
+import { Input } from "@app/components/ui/input";
+import { useToast } from "@app/hooks/useToast";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import {
+    Credenza,
+    CredenzaBody,
+    CredenzaClose,
+    CredenzaContent,
+    CredenzaDescription,
+    CredenzaFooter,
+    CredenzaHeader,
+    CredenzaTitle,
+} from "@app/components/Credenza";
+import { useParams, useRouter } from "next/navigation";
+import { ListSitesResponse } from "@server/routers/site";
+import { cn, formatAxiosError } from "@app/lib/utils";
+import { CheckIcon } from "lucide-react";
+import {
+    Popover,
+    PopoverContent,
+    PopoverTrigger,
+} from "@app/components/ui/popover";
+import {
+    Command,
+    CommandEmpty,
+    CommandGroup,
+    CommandInput,
+    CommandItem,
+    CommandList,
+} from "@app/components/ui/command";
+import { CaretSortIcon } from "@radix-ui/react-icons";
+import CustomDomainInput from "../[resourceId]/components/CustomDomainInput";
+import { Axios, AxiosResponse } from "axios";
+import { Resource } from "@server/db/schema";
+import { useOrgContext } from "@app/hooks/useOrgContext";
+import { subdomainSchema } from "@server/schemas/subdomainSchema";
+
+const accountFormSchema = z.object({
+    subdomain: subdomainSchema,
+    name: z.string(),
+    siteId: z.number(),
+});
+
+type AccountFormValues = z.infer<typeof accountFormSchema>;
+
+const defaultValues: Partial<AccountFormValues> = {
+    subdomain: "",
+    name: "My Resource",
+};
+
+type CreateResourceFormProps = {
+    open: boolean;
+    setOpen: (open: boolean) => void;
+};
+
+export default function CreateResourceForm({
+    open,
+    setOpen,
+}: CreateResourceFormProps) {
+    const { toast } = useToast();
+
+    const [loading, setLoading] = useState(false);
+    const params = useParams();
+
+    const orgId = params.orgId;
+    const router = useRouter();
+
+    const { org } = useOrgContext();
+
+    const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
+    const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain);
+
+    const form = useForm<AccountFormValues>({
+        resolver: zodResolver(accountFormSchema),
+        defaultValues,
+    });
+
+    useEffect(() => {
+        if (!open) {
+            return;
+        }
+
+        const fetchSites = async () => {
+            const res = await api.get<AxiosResponse<ListSitesResponse>>(
+                `/org/${orgId}/sites/`
+            );
+            setSites(res.data.data.sites);
+        };
+
+        fetchSites();
+    }, [open]);
+
+    async function onSubmit(data: AccountFormValues) {
+        console.log(data);
+
+        const res = await api
+            .put<AxiosResponse<Resource>>(
+                `/org/${orgId}/site/${data.siteId}/resource/`,
+                {
+                    name: data.name,
+                    subdomain: data.subdomain,
+                    // subdomain: data.subdomain,
+                }
+            )
+            .catch((e) => {
+                toast({
+                    variant: "destructive",
+                    title: "Error creating resource",
+                    description: formatAxiosError(
+                        e,
+                        "An error occurred when creating the resource"
+                    ),
+                });
+            });
+
+        if (res && res.status === 201) {
+            const id = res.data.data.resourceId;
+            // navigate to the resource page
+            router.push(`/${orgId}/settings/resources/${id}`);
+        }
+    }
+
+    return (
+        <>
+            <Credenza
+                open={open}
+                onOpenChange={(val) => {
+                    setOpen(val);
+                    setLoading(false);
+
+                    // reset all values
+                    form.reset();
+                }}
+            >
+                <CredenzaContent>
+                    <CredenzaHeader>
+                        <CredenzaTitle>Create Resource</CredenzaTitle>
+                        <CredenzaDescription>
+                            Create a new resource to proxy requests to your app
+                        </CredenzaDescription>
+                    </CredenzaHeader>
+                    <CredenzaBody>
+                        <Form {...form}>
+                            <form
+                                onSubmit={form.handleSubmit(onSubmit)}
+                                className="space-y-4"
+                                id="create-resource-form"
+                            >
+                                <FormField
+                                    control={form.control}
+                                    name="name"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>Name</FormLabel>
+                                            <FormControl>
+                                                <Input
+                                                    placeholder="Your name"
+                                                    {...field}
+                                                />
+                                            </FormControl>
+                                            <FormDescription>
+                                                This is the name that will be
+                                                displayed for this resource.
+                                            </FormDescription>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                                <FormField
+                                    control={form.control}
+                                    name="subdomain"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>Subdomain</FormLabel>
+                                            <FormControl>
+                                                <CustomDomainInput
+                                                    value={field.value}
+                                                    domainSuffix={domainSuffix}
+                                                    placeholder="Enter subdomain"
+                                                    onChange={(value) =>
+                                                        form.setValue(
+                                                            "subdomain",
+                                                            value
+                                                        )
+                                                    }
+                                                />
+                                            </FormControl>
+                                            <FormDescription>
+                                                This is the fully qualified
+                                                domain name that will be used to
+                                                access the resource.
+                                            </FormDescription>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                                <FormField
+                                    control={form.control}
+                                    name="siteId"
+                                    render={({ field }) => (
+                                        <FormItem className="flex flex-col">
+                                            <FormLabel>Site</FormLabel>
+                                            <Popover>
+                                                <PopoverTrigger asChild>
+                                                    <FormControl>
+                                                        <Button
+                                                            variant="outline"
+                                                            role="combobox"
+                                                            className={cn(
+                                                                "w-[350px] justify-between",
+                                                                !field.value &&
+                                                                    "text-muted-foreground"
+                                                            )}
+                                                        >
+                                                            {field.value
+                                                                ? sites.find(
+                                                                      (site) =>
+                                                                          site.siteId ===
+                                                                          field.value
+                                                                  )?.name
+                                                                : "Select site"}
+                                                            <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+                                                        </Button>
+                                                    </FormControl>
+                                                </PopoverTrigger>
+                                                <PopoverContent className="w-[350px] p-0">
+                                                    <Command>
+                                                        <CommandInput placeholder="Search site..." />
+                                                        <CommandList>
+                                                            <CommandEmpty>
+                                                                No site found.
+                                                            </CommandEmpty>
+                                                            <CommandGroup>
+                                                                {sites.map(
+                                                                    (site) => (
+                                                                        <CommandItem
+                                                                            value={
+                                                                                site.name
+                                                                            }
+                                                                            key={
+                                                                                site.siteId
+                                                                            }
+                                                                            onSelect={() => {
+                                                                                form.setValue(
+                                                                                    "siteId",
+                                                                                    site.siteId
+                                                                                );
+                                                                            }}
+                                                                        >
+                                                                            <CheckIcon
+                                                                                className={cn(
+                                                                                    "mr-2 h-4 w-4",
+                                                                                    site.siteId ===
+                                                                                        field.value
+                                                                                        ? "opacity-100"
+                                                                                        : "opacity-0"
+                                                                                )}
+                                                                            />
+                                                                            {
+                                                                                site.name
+                                                                            }
+                                                                        </CommandItem>
+                                                                    )
+                                                                )}
+                                                            </CommandGroup>
+                                                        </CommandList>
+                                                    </Command>
+                                                </PopoverContent>
+                                            </Popover>
+                                            <FormDescription>
+                                                This is the site that will be
+                                                used in the dashboard.
+                                            </FormDescription>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                            </form>
+                        </Form>
+                    </CredenzaBody>
+                    <CredenzaFooter>
+                        <Button
+                            type="submit"
+                            form="create-resource-form"
+                            loading={loading}
+                            disabled={loading}
+                        >
+                            Create Resource
+                        </Button>
+                        <CredenzaClose asChild>
+                            <Button variant="outline">Close</Button>
+                        </CredenzaClose>
+                    </CredenzaFooter>
+                </CredenzaContent>
+            </Credenza>
+        </>
+    );
+}

+ 181 - 93
src/app/[orgId]/settings/resources/components/ResourcesTable.tsx

@@ -9,10 +9,16 @@ import {
     DropdownMenuTrigger,
 } from "@app/components/ui/dropdown-menu";
 import { Button } from "@app/components/ui/button";
-import { ArrowUpDown, MoreHorizontal } from "lucide-react";
+import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
 import Link from "next/link";
 import { useRouter } from "next/navigation";
 import api from "@app/api";
+import CreateResourceForm from "./CreateResourceForm";
+import { useState } from "react";
+import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
+import { set } from "zod";
+import { formatAxiosError } from "@app/lib/utils";
+import { useToast } from "@app/hooks/useToast";
 
 export type ResourceRow = {
     id: number;
@@ -22,91 +28,6 @@ export type ResourceRow = {
     site: 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>
-            );
-        },
-    },
-    {
-        accessorKey: "site",
-        header: ({ column }) => {
-            return (
-                <Button
-                    variant="ghost"
-                    onClick={() =>
-                        column.toggleSorting(column.getIsSorted() === "asc")
-                    }
-                >
-                    Site
-                    <ArrowUpDown className="ml-2 h-4 w-4" />
-                </Button>
-            );
-        },
-    },
-    {
-        accessorKey: "domain",
-        header: "Domain",
-    },
-    {
-        id: "actions",
-        cell: ({ row }) => {
-            const router = useRouter();
-
-            const resourceRow = row.original;
-
-            const deleteResource = (resourceId: number) => {
-                api.delete(`/resource/${resourceId}`)
-                    .catch((e) => {
-                        console.error("Error deleting resource", e);
-                    })
-                    .then(() => {
-                        router.refresh();
-                    });
-            };
-
-            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}/settings/resources/${resourceRow.id}`}
-                            >
-                                View settings
-                            </Link>
-                        </DropdownMenuItem>
-                        <DropdownMenuItem>
-                            <button
-                                onClick={() => deleteResource(resourceRow.id)}
-                                className="text-red-600 hover:text-red-800 hover:underline cursor-pointer"
-                            >
-                                Delete
-                            </button>
-                        </DropdownMenuItem>
-                    </DropdownMenuContent>
-                </DropdownMenu>
-            );
-        },
-    },
-];
-
 type ResourcesTableProps = {
     resources: ResourceRow[];
     orgId: string;
@@ -115,13 +36,180 @@ type ResourcesTableProps = {
 export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
     const router = useRouter();
 
+    const { toast } = useToast();
+
+    const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+    const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+    const [selectedResource, setSelectedResource] =
+        useState<ResourceRow | null>();
+
+    const deleteResource = (resourceId: number) => {
+        api.delete(`/resource/${resourceId}`)
+            .catch((e) => {
+                console.error("Error deleting resource", e);
+                toast({
+                    variant: "destructive",
+                    title: "Error deleting resource",
+                    description: formatAxiosError(e, "Error deleting resource"),
+                });
+            })
+            .then(() => {
+                router.refresh();
+                setIsDeleteModalOpen(false);
+            });
+    };
+
+    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>
+                );
+            },
+        },
+        {
+            accessorKey: "site",
+            header: ({ column }) => {
+                return (
+                    <Button
+                        variant="ghost"
+                        onClick={() =>
+                            column.toggleSorting(column.getIsSorted() === "asc")
+                        }
+                    >
+                        Site
+                        <ArrowUpDown className="ml-2 h-4 w-4" />
+                    </Button>
+                );
+            },
+        },
+        {
+            accessorKey: "domain",
+            header: "Domain",
+        },
+        {
+            id: "actions",
+            cell: ({ row }) => {
+                const router = useRouter();
+
+                const resourceRow = row.original;
+
+                return (
+                    <>
+                        <div className="flex items-center justify-end">
+                            <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}/settings/resources/${resourceRow.id}`}
+                                        >
+                                            View settings
+                                        </Link>
+                                    </DropdownMenuItem>
+                                    <DropdownMenuItem>
+                                        <button
+                                            onClick={() => {
+                                                setSelectedResource(
+                                                    resourceRow
+                                                );
+                                                setIsDeleteModalOpen(true);
+                                            }}
+                                            className="text-red-600 hover:text-red-800 hover:underline cursor-pointer"
+                                        >
+                                            Delete
+                                        </button>
+                                    </DropdownMenuItem>
+                                </DropdownMenuContent>
+                            </DropdownMenu>
+                            <Button
+                                variant={"gray"}
+                                className="ml-2"
+                                onClick={() =>
+                                    router.push(
+                                        `/${resourceRow.orgId}/settings/resources/${resourceRow.id}`
+                                    )
+                                }
+                            >
+                                Edit <ArrowRight className="ml-2 w-4 h-4" />
+                            </Button>
+                        </div>
+                    </>
+                );
+            },
+        },
+    ];
+
     return (
-        <ResourcesDataTable
-            columns={columns}
-            data={resources}
-            addResource={() => {
-                router.push(`/${orgId}/settings/resources/create`);
-            }}
-        />
+        <>
+            <CreateResourceForm
+                open={isCreateModalOpen}
+                setOpen={setIsCreateModalOpen}
+            />
+
+            {selectedResource && (
+                <ConfirmDeleteDialog
+                    open={isDeleteModalOpen}
+                    setOpen={(val) => {
+                        setIsDeleteModalOpen(val);
+                        setSelectedResource(null);
+                    }}
+                    dialog={
+                        <div>
+                            <p className="mb-2">
+                                Are you sure you want to remove the resource{" "}
+                                <b>
+                                    {selectedResource?.name ||
+                                        selectedResource?.id}
+                                </b>{" "}
+                                from the organization?
+                            </p>
+
+                            <p className="mb-2">
+                                Once removed, the resource will no longer be
+                                accessible. All targets attached to the resource
+                                will be removed.
+                            </p>
+
+                            <p>
+                                To confirm, please type the name of the resource
+                                below.
+                            </p>
+                        </div>
+                    }
+                    buttonText="Confirm Delete Resource"
+                    onConfirm={async () => deleteResource(selectedResource!.id)}
+                    string={selectedResource.name}
+                    title="Delete Resource"
+                />
+            )}
+
+            <ResourcesDataTable
+                columns={columns}
+                data={resources}
+                addResource={() => {
+                    setIsCreateModalOpen(true);
+                }}
+            />
+        </>
     );
 }

+ 30 - 9
src/app/[orgId]/settings/resources/page.tsx

@@ -3,6 +3,11 @@ import { authCookieHeader } from "@app/api/cookies";
 import ResourcesTable, { ResourceRow } from "./components/ResourcesTable";
 import { AxiosResponse } from "axios";
 import { ListResourcesResponse } from "@server/routers/resource";
+import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
+import { redirect } from "next/navigation";
+import { cache } from "react";
+import { GetOrgResponse } from "@server/routers/org";
+import OrgProvider from "@app/providers/OrgProvider";
 
 type ResourcesPageProps = {
     params: Promise<{ orgId: string }>;
@@ -21,6 +26,24 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
         console.error("Error fetching resources", e);
     }
 
+    let org = null;
+    try {
+        const getOrg = cache(async () =>
+            internal.get<AxiosResponse<GetOrgResponse>>(
+                `/org/${params.orgId}`,
+                await authCookieHeader()
+            )
+        );
+        const res = await getOrg();
+        org = res.data.data;
+    } catch {
+        redirect(`/${params.orgId}/settings/resources`);
+    }
+
+    if (!org) {
+        redirect(`/${params.orgId}/settings/resources`);
+    }
+
     const resourceRows: ResourceRow[] = resources.map((resource) => {
         return {
             id: resource.resourceId,
@@ -33,16 +56,14 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
 
     return (
         <>
-            <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 applications.
-                </p>
-            </div>
+            <SettingsSectionTitle
+                title="Manage Resources"
+                description="Create secure proxies to your private applications"
+            />
 
-            <ResourcesTable resources={resourceRows} orgId={params.orgId} />
+            <OrgProvider org={org}>
+                <ResourcesTable resources={resourceRows} orgId={params.orgId} />
+            </OrgProvider>
         </>
     );
 }

+ 0 - 218
src/app/[orgId]/settings/sites/[niceId]/components/CreateSite.tsx

@@ -1,218 +0,0 @@
-"use client";
-
-import { zodResolver } from "@hookform/resolvers/zod";
-import { ChevronDownIcon } from "@radix-ui/react-icons";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-import { cn } from "@/lib/utils";
-import { toast } from "@/hooks/useToast";
-import { Button, buttonVariants } from "@/components/ui/button";
-import {
-    Form,
-    FormControl,
-    FormDescription,
-    FormField,
-    FormItem,
-    FormLabel,
-    FormMessage,
-} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import { generateKeypair } from "./wireguardConfig";
-import React, { useState, useEffect } from "react";
-import { api } from "@/api";
-import { useParams } from "next/navigation";
-import { useRouter } from "next/navigation";
-import { Checkbox } from "@app/components/ui/checkbox";
-import { PickSiteDefaultsResponse } from "@server/routers/site";
-import CopyTextBox from "@app/components/CopyTextBox";
-
-const method = [
-    { label: "Wireguard", value: "wg" },
-    { label: "Newt", value: "newt" },
-] as const;
-
-const accountFormSchema = z.object({
-    name: z
-        .string()
-        .min(2, {
-            message: "Name must be at least 2 characters.",
-        })
-        .max(30, {
-            message: "Name must not be longer than 30 characters.",
-        }),
-    method: z.enum(["wg", "newt"]),
-});
-
-type AccountFormValues = z.infer<typeof accountFormSchema>;
-
-const defaultValues: Partial<AccountFormValues> = {
-    name: "",
-    method: "wg",
-};
-
-export function CreateSiteForm() {
-    const params = useParams();
-    const orgId = params.orgId;
-    const router = useRouter();
-
-    const [keypair, setKeypair] = useState<{
-        publicKey: string;
-        privateKey: string;
-    } | null>(null);
-    const [isLoading, setIsLoading] = useState(true);
-    const [isChecked, setIsChecked] = useState(false);
-    const [siteDefaults, setSiteDefaults] =
-        useState<PickSiteDefaultsResponse | null>(null);
-
-    const handleCheckboxChange = (checked: boolean) => {
-        setIsChecked(checked);
-    };
-
-    const form = useForm<AccountFormValues>({
-        resolver: zodResolver(accountFormSchema),
-        defaultValues,
-    });
-
-    useEffect(() => {
-        if (typeof window !== "undefined") {
-            const generatedKeypair = generateKeypair();
-            setKeypair(generatedKeypair);
-            setIsLoading(false);
-
-            api.get(`/org/${orgId}/pick-site-defaults`)
-                .catch((e) => {
-                    toast({
-                        title: "Error creating site...",
-                    });
-                })
-                .then((res) => {
-                    if (res && res.status === 200) {
-                        setSiteDefaults(res.data.data);
-                    }
-                });
-        }
-    }, []);
-
-    async function onSubmit(data: AccountFormValues) {
-        const res = await api
-            .put(`/org/${orgId}/site/`, {
-                name: data.name,
-                subnet: siteDefaults?.subnet,
-                exitNodeId: siteDefaults?.exitNodeId,
-                pubKey: keypair?.publicKey,
-            })
-            .catch((e) => {
-                toast({
-                    title: "Error creating site...",
-                });
-            });
-
-        if (res && res.status === 201) {
-            const niceId = res.data.data.niceId;
-            // navigate to the site page
-            router.push(`/${orgId}/settings/sites/${niceId}`);
-        }
-    }
-
-    const wgConfig =
-        keypair && siteDefaults
-            ? `[Interface]
-Address = ${siteDefaults.subnet}
-ListenPort = 51820
-PrivateKey = ${keypair.privateKey}
-
-[Peer]
-PublicKey = ${siteDefaults.publicKey}
-AllowedIPs = ${siteDefaults.address.split("/")[0]}/32
-Endpoint = ${siteDefaults.endpoint}:${siteDefaults.listenPort}
-PersistentKeepalive = 5`
-            : "";
-
-    const newtConfig = `curl -fsSL https://get.docker.com -o get-docker.sh
-sh get-docker.sh`;
-
-    return (
-        <>
-            <Form {...form}>
-                <form
-                    onSubmit={form.handleSubmit(onSubmit)}
-                    className="space-y-8"
-                >
-                    <FormField
-                        control={form.control}
-                        name="name"
-                        render={({ field }) => (
-                            <FormItem>
-                                <FormLabel>Name</FormLabel>
-                                <FormControl>
-                                    <Input placeholder="Your name" {...field} />
-                                </FormControl>
-                                <FormDescription>
-                                    This is the name that will be displayed for
-                                    this site.
-                                </FormDescription>
-                                <FormMessage />
-                            </FormItem>
-                        )}
-                    />
-                    <FormField
-                        control={form.control}
-                        name="method"
-                        render={({ field }) => (
-                            <FormItem>
-                                <FormLabel>Method</FormLabel>
-                                <div className="relative w-max">
-                                    <FormControl>
-                                        <select
-                                            className={cn(
-                                                buttonVariants({
-                                                    variant: "outline",
-                                                }),
-                                                "w-[200px] appearance-none font-normal"
-                                            )}
-                                            {...field}
-                                        >
-                                            <option value="wg">
-                                                WireGuard
-                                            </option>
-                                            <option value="newt">Newt</option>
-                                        </select>
-                                    </FormControl>
-                                    <ChevronDownIcon className="absolute right-3 top-2.5 h-4 w-4 opacity-50" />
-                                </div>
-                                <FormDescription>
-                                    This is how you will connect your site to
-                                    Fossorial.
-                                </FormDescription>
-                                <FormMessage />
-                            </FormItem>
-                        )}
-                    />
-                    {form.watch("method") === "wg" && !isLoading ? (
-                        <CopyTextBox text={wgConfig} />
-                    ) : form.watch("method") === "wg" && isLoading ? (
-                        <p>Loading WireGuard configuration...</p>
-                    ) : (
-                        <CopyTextBox text={newtConfig} wrapText={false} />
-                    )}
-                    <div className="flex items-center space-x-2">
-                        <Checkbox
-                            id="terms"
-                            checked={isChecked}
-                            onCheckedChange={handleCheckboxChange}
-                        />
-                        <label
-                            htmlFor="terms"
-                            className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
-                        >
-                            I have copied the config
-                        </label>
-                    </div>
-                    <Button type="submit" disabled={!isChecked}>
-                        Create Site
-                    </Button>
-                </form>
-            </Form>
-        </>
-    );
-}

+ 0 - 80
src/app/[orgId]/settings/sites/[niceId]/components/GeneralForm.tsx

@@ -1,80 +0,0 @@
-"use client";
-
-import { zodResolver } from "@hookform/resolvers/zod";
-import { z } from "zod";
-import { Button } from "@/components/ui/button";
-import {
-    Form,
-    FormControl,
-    FormDescription,
-    FormField,
-    FormItem,
-    FormLabel,
-    FormMessage,
-} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import { useSiteContext } from "@app/hooks/useSiteContext";
-import { useForm } from "react-hook-form";
-import api from "@app/api";
-import { useToast } from "@app/hooks/useToast";
-
-const GeneralFormSchema = z.object({
-    name: z.string(),
-});
-
-type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
-
-export function GeneralForm() {
-    const { site, updateSite } = useSiteContext();
-    const { toast } = useToast();
-
-    const form = useForm<GeneralFormValues>({
-        resolver: zodResolver(GeneralFormSchema),
-        defaultValues: {
-            name: site?.name,
-        },
-        mode: "onChange",
-    });
-
-    async function onSubmit(data: GeneralFormValues) {
-        updateSite({ name: data.name });
-
-        await api
-            .post(`/site/${site?.siteId}`, {
-                name: data.name,
-            })
-            .catch((e) => {
-                toast({
-                    variant: "destructive",
-                    title: "Failed to update site",
-                    description:
-                        e.message ||
-                        "An error occurred while updating the site.",
-                });
-            });
-    }
-
-    return (
-        <Form {...form}>
-            <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
-                <FormField
-                    control={form.control}
-                    name="name"
-                    render={({ field }) => (
-                        <FormItem>
-                            <FormLabel>Name</FormLabel>
-                            <FormControl>
-                                <Input {...field} />
-                            </FormControl>
-                            <FormDescription>
-                                This is the display name of the site.
-                            </FormDescription>
-                            <FormMessage />
-                        </FormItem>
-                    )}
-                />
-                <Button type="submit">Update Site</Button>
-            </form>
-        </Form>
-    );
-}

+ 101 - 0
src/app/[orgId]/settings/sites/[niceId]/general/page.tsx

@@ -0,0 +1,101 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import { Button } from "@/components/ui/button";
+import {
+    Form,
+    FormControl,
+    FormDescription,
+    FormField,
+    FormItem,
+    FormLabel,
+    FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { useSiteContext } from "@app/hooks/useSiteContext";
+import { useForm } from "react-hook-form";
+import api from "@app/api";
+import { useToast } from "@app/hooks/useToast";
+import { useRouter } from "next/navigation";
+import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
+import { formatAxiosError } from "@app/lib/utils";
+
+const GeneralFormSchema = z.object({
+    name: z.string(),
+});
+
+type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
+
+export default function GeneralPage() {
+    const { site, updateSite } = useSiteContext();
+    const { toast } = useToast();
+
+    const router = useRouter();
+
+    const form = useForm<GeneralFormValues>({
+        resolver: zodResolver(GeneralFormSchema),
+        defaultValues: {
+            name: site?.name,
+        },
+        mode: "onChange",
+    });
+
+    async function onSubmit(data: GeneralFormValues) {
+        await api
+            .post(`/site/${site?.siteId}`, {
+                name: data.name,
+            })
+            .catch((e) => {
+                toast({
+                    variant: "destructive",
+                    title: "Failed to update site",
+                    description: formatAxiosError(
+                        e,
+                        "An error occurred while updating the site."
+                    ),
+                });
+            });
+
+        updateSite({ name: data.name });
+
+        router.refresh();
+    }
+
+    return (
+        <>
+            <div className="space-y-6">
+                <SettingsSectionTitle
+                    title="General Settings"
+                    description="Configure the general settings for this site"
+                    size="1xl"
+                />
+
+                <Form {...form}>
+                    <form
+                        onSubmit={form.handleSubmit(onSubmit)}
+                        className="space-y-6"
+                    >
+                        <FormField
+                            control={form.control}
+                            name="name"
+                            render={({ field }) => (
+                                <FormItem>
+                                    <FormLabel>Name</FormLabel>
+                                    <FormControl>
+                                        <Input {...field} />
+                                    </FormControl>
+                                    <FormDescription>
+                                        This is the display name of the site
+                                    </FormDescription>
+                                    <FormMessage />
+                                </FormItem>
+                            )}
+                        />
+                        <Button type="submit">Save Changes</Button>
+                    </form>
+                </Form>
+            </div>
+        </>
+    );
+}

+ 34 - 32
src/app/[orgId]/settings/sites/[niceId]/layout.tsx

@@ -5,6 +5,9 @@ import { AxiosResponse } from "axios";
 import { redirect } from "next/navigation";
 import { authCookieHeader } from "@app/api/cookies";
 import { SidebarSettings } from "@app/components/SidebarSettings";
+import Link from "next/link";
+import { ArrowLeft } from "lucide-react";
+import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
 
 interface SettingsLayoutProps {
     children: React.ReactNode;
@@ -17,50 +20,49 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
     const { children } = props;
 
     let site = null;
-
-    if (params.niceId !== "create") {
-        try {
-            const res = await internal.get<AxiosResponse<GetSiteResponse>>(
-                `/org/${params.orgId}/site/${params.niceId}`,
-                await authCookieHeader()
-            );
-            site = res.data.data;
-        } catch {
-            redirect(`/${params.orgId}/settings/sites`);
-        }
+    try {
+        const res = await internal.get<AxiosResponse<GetSiteResponse>>(
+            `/org/${params.orgId}/site/${params.niceId}`,
+            await authCookieHeader()
+        );
+        site = res.data.data;
+    } catch {
+        redirect(`/${params.orgId}/settings/sites`);
     }
 
     const sidebarNavItems = [
         {
             title: "General",
-            href: "/{orgId}/settings/sites/{niceId}",
+            href: "/{orgId}/settings/sites/{niceId}/general",
         },
     ];
 
-    const isCreate = params.niceId === "create";
-
     return (
         <>
-            <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 className="mb-4">
+                <Link
+                    href="../../"
+                    className="text-muted-foreground hover:underline"
+                >
+                    <div className="flex flex-row items-center gap-1">
+                        <ArrowLeft className="w-4 h-4" /> <span>All Sites</span>
+                    </div>
+                </Link>
             </div>
 
-            <SidebarSettings
-                sidebarNavItems={sidebarNavItems}
-                disabled={isCreate}
-                limitWidth={true}
-            >
-                {children}
-            </SidebarSettings>
+            <SettingsSectionTitle
+                title={`${site?.name} Settings`}
+                description="Configure the settings on your site"
+            />
+
+            <SiteProvider site={site}>
+                <SidebarSettings
+                    sidebarNavItems={sidebarNavItems}
+                    limitWidth={true}
+                >
+                    {children}
+                </SidebarSettings>
+            </SiteProvider>
         </>
     );
 }

+ 3 - 24
src/app/[orgId]/settings/sites/[niceId]/page.tsx

@@ -1,29 +1,8 @@
-import React from "react";
-import { Separator } from "@/components/ui/separator";
-import { CreateSiteForm } from "./components/CreateSite";
-import { GeneralForm } from "./components/GeneralForm";
+import { redirect } from "next/navigation";
 
 export default async function SitePage(props: {
-    params: Promise<{ niceId: string }>;
+    params: Promise<{ orgId: string; niceId: string }>;
 }) {
     const params = await props.params;
-    const isCreate = params.niceId === "create";
-
-    return (
-        <div className="space-y-6">
-            <div>
-                <h3 className="text-lg font-medium">
-                    {isCreate ? "Create Site" : "General"}
-                </h3>
-                <p className="text-sm text-muted-foreground">
-                    {isCreate
-                        ? "Create a new site"
-                        : "Edit basic site settings"}
-                </p>
-            </div>
-            <Separator />
-
-            {isCreate ? <CreateSiteForm /> : <GeneralForm />}
-        </div>
-    );
+    redirect(`/${params.orgId}/settings/sites/${params.niceId}/general`);
 }

+ 315 - 0
src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx

@@ -0,0 +1,315 @@
+"use client";
+
+import api from "@app/api";
+import { Button, buttonVariants } from "@app/components/ui/button";
+import {
+    Form,
+    FormControl,
+    FormDescription,
+    FormField,
+    FormItem,
+    FormLabel,
+    FormMessage,
+} from "@app/components/ui/form";
+import { Input } from "@app/components/ui/input";
+import { useToast } from "@app/hooks/useToast";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import {
+    Credenza,
+    CredenzaBody,
+    CredenzaClose,
+    CredenzaContent,
+    CredenzaDescription,
+    CredenzaFooter,
+    CredenzaHeader,
+    CredenzaTitle,
+} from "@app/components/Credenza";
+import { useOrgContext } from "@app/hooks/useOrgContext";
+import { useParams, useRouter } from "next/navigation";
+import { PickSiteDefaultsResponse } from "@server/routers/site";
+import { generateKeypair } from "../[niceId]/components/wireguardConfig";
+import CopyTextBox from "@app/components/CopyTextBox";
+import { Checkbox } from "@app/components/ui/checkbox";
+import {
+    Select,
+    SelectContent,
+    SelectItem,
+    SelectTrigger,
+    SelectValue,
+} from "@app/components/ui/select";
+import { formatAxiosError } from "@app/lib/utils";
+
+const method = [
+    { label: "Wireguard", value: "wg" },
+    { label: "Newt", value: "newt" },
+] as const;
+
+const accountFormSchema = z.object({
+    name: z
+        .string()
+        .min(2, {
+            message: "Name must be at least 2 characters.",
+        })
+        .max(30, {
+            message: "Name must not be longer than 30 characters.",
+        }),
+    method: z.enum(["wg", "newt"]),
+});
+
+type AccountFormValues = z.infer<typeof accountFormSchema>;
+
+const defaultValues: Partial<AccountFormValues> = {
+    name: "",
+    method: "wg",
+};
+
+type CreateSiteFormProps = {
+    open: boolean;
+    setOpen: (open: boolean) => void;
+};
+
+export default function CreateSiteForm({ open, setOpen }: CreateSiteFormProps) {
+    const { toast } = useToast();
+
+    const [loading, setLoading] = useState(false);
+
+    const params = useParams();
+    const orgId = params.orgId;
+    const router = useRouter();
+
+    const [keypair, setKeypair] = useState<{
+        publicKey: string;
+        privateKey: string;
+    } | null>(null);
+    const [isLoading, setIsLoading] = useState(true);
+    const [isChecked, setIsChecked] = useState(false);
+    const [siteDefaults, setSiteDefaults] =
+        useState<PickSiteDefaultsResponse | null>(null);
+
+    const handleCheckboxChange = (checked: boolean) => {
+        setIsChecked(checked);
+    };
+
+    const form = useForm<AccountFormValues>({
+        resolver: zodResolver(accountFormSchema),
+        defaultValues,
+    });
+
+    useEffect(() => {
+        if (!open) return;
+
+        if (typeof window !== "undefined") {
+            const generatedKeypair = generateKeypair();
+            setKeypair(generatedKeypair);
+            setIsLoading(false);
+
+            api.get(`/org/${orgId}/pick-site-defaults`)
+                .catch((e) => {
+                    toast({
+                        variant: "destructive",
+                        title: "Error picking site defaults",
+                        description: formatAxiosError(e),
+                    });
+                })
+                .then((res) => {
+                    if (res && res.status === 200) {
+                        setSiteDefaults(res.data.data);
+                    }
+                });
+        }
+    }, [open]);
+
+    async function onSubmit(data: AccountFormValues) {
+        setLoading(true);
+        const res = await api
+            .put(`/org/${orgId}/site/`, {
+                name: data.name,
+                subnet: siteDefaults?.subnet,
+                exitNodeId: siteDefaults?.exitNodeId,
+                pubKey: keypair?.publicKey,
+            })
+            .catch((e) => {
+                toast({
+                    variant: "destructive",
+                    title: "Error creating site",
+                    description: formatAxiosError(e),
+                });
+            });
+
+        if (res && res.status === 201) {
+            const niceId = res.data.data.niceId;
+            // navigate to the site page
+            router.push(`/${orgId}/settings/sites/${niceId}`);
+
+            // close the modal
+            setOpen(false);
+        }
+
+        setLoading(false);
+    }
+
+    const wgConfig =
+        keypair && siteDefaults
+            ? `[Interface]
+Address = ${siteDefaults.subnet}
+ListenPort = 51820
+PrivateKey = ${keypair.privateKey}
+
+[Peer]
+PublicKey = ${siteDefaults.publicKey}
+AllowedIPs = ${siteDefaults.address.split("/")[0]}/32
+Endpoint = ${siteDefaults.endpoint}:${siteDefaults.listenPort}
+PersistentKeepalive = 5`
+            : "";
+
+    const newtConfig = `curl -fsSL https://get.docker.com -o get-docker.sh
+sh get-docker.sh`;
+
+    return (
+        <>
+            <Credenza
+                open={open}
+                onOpenChange={(val) => {
+                    setOpen(val);
+                    setLoading(false);
+
+                    // reset all values
+                    form.reset();
+                    setIsChecked(false);
+                    setKeypair(null);
+                    setSiteDefaults(null);
+                }}
+            >
+                <CredenzaContent>
+                    <CredenzaHeader>
+                        <CredenzaTitle>Create Site</CredenzaTitle>
+                        <CredenzaDescription>
+                            Create a new site to start connecting your resources
+                        </CredenzaDescription>
+                    </CredenzaHeader>
+                    <CredenzaBody>
+                        <div className="space-y-6">
+                            <Form {...form}>
+                                <form
+                                    onSubmit={form.handleSubmit(onSubmit)}
+                                    className="space-y-6"
+                                    id="create-site-form"
+                                >
+                                    <FormField
+                                        control={form.control}
+                                        name="name"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>Name</FormLabel>
+                                                <FormControl>
+                                                    <Input
+                                                        placeholder="Site name"
+                                                        {...field}
+                                                    />
+                                                </FormControl>
+                                                <FormDescription>
+                                                    This is the name that will
+                                                    be displayed for this site.
+                                                </FormDescription>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                    <FormField
+                                        control={form.control}
+                                        name="method"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>Method</FormLabel>
+                                                <FormControl>
+                                                    <Select
+                                                        value={field.value}
+                                                        onValueChange={
+                                                            field.onChange
+                                                        }
+                                                    >
+                                                        <SelectTrigger>
+                                                            <SelectValue placeholder="Select method" />
+                                                        </SelectTrigger>
+                                                        <SelectContent>
+                                                            <SelectItem value="wg">
+                                                                WireGuard
+                                                            </SelectItem>
+                                                            <SelectItem value="newt">
+                                                                Newt
+                                                            </SelectItem>
+                                                        </SelectContent>
+                                                    </Select>
+                                                </FormControl>
+                                                <FormDescription>
+                                                    This is how you will connect
+                                                    your site to Fossorial.
+                                                </FormDescription>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+
+                                    <div className="max-w-md">
+                                        {form.watch("method") === "wg" &&
+                                        !isLoading ? (
+                                            <CopyTextBox text={wgConfig} />
+                                        ) : form.watch("method") === "wg" &&
+                                          isLoading ? (
+                                            <p>
+                                                Loading WireGuard
+                                                configuration...
+                                            </p>
+                                        ) : (
+                                            <CopyTextBox
+                                                text={newtConfig}
+                                                wrapText={false}
+                                            />
+                                        )}
+                                    </div>
+
+                                    <span className="text-sm text-muted-foreground">
+                                        You will only be able to see the
+                                        configuration once.
+                                    </span>
+
+                                    <div className="flex items-center space-x-2">
+                                        <Checkbox
+                                            id="terms"
+                                            checked={isChecked}
+                                            onCheckedChange={
+                                                handleCheckboxChange
+                                            }
+                                        />
+                                        <label
+                                            htmlFor="terms"
+                                            className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+                                        >
+                                            I have copied the config
+                                        </label>
+                                    </div>
+                                </form>
+                            </Form>
+                        </div>
+                    </CredenzaBody>
+                    <CredenzaFooter>
+                        <Button
+                            type="submit"
+                            form="create-site-form"
+                            loading={loading}
+                            disabled={loading || !isChecked}
+                        >
+                            Create Site
+                        </Button>
+                        <CredenzaClose asChild>
+                            <Button variant="outline">Close</Button>
+                        </CredenzaClose>
+                    </CredenzaFooter>
+                </CredenzaContent>
+            </Credenza>
+        </>
+    );
+}

+ 174 - 105
src/app/[orgId]/settings/sites/components/SitesTable.tsx

@@ -9,12 +9,16 @@ import {
     DropdownMenuTrigger,
 } from "@app/components/ui/dropdown-menu";
 import { Button } from "@app/components/ui/button";
-import { ArrowUpDown, MoreHorizontal } from "lucide-react";
+import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
 import Link from "next/link";
 import { useRouter } from "next/navigation";
 import api from "@app/api";
-import { authCookieHeader } from "@app/api/cookies";
 import { AxiosResponse } from "axios";
+import { useState } from "react";
+import CreateSiteForm from "./CreateSiteForm";
+import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
+import { useToast } from "@app/hooks/useToast";
+import { formatAxiosError } from "@app/lib/utils";
 
 export type SiteRow = {
     id: number;
@@ -25,95 +29,6 @@ export type SiteRow = {
     orgId: string;
 };
 
-export const columns: ColumnDef<SiteRow>[] = [
-    {
-        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: "nice",
-        header: ({ column }) => {
-            return (
-                <Button
-                    variant="ghost"
-                    onClick={() =>
-                        column.toggleSorting(column.getIsSorted() === "asc")
-                    }
-                >
-                    Site
-                    <ArrowUpDown className="ml-2 h-4 w-4" />
-                </Button>
-            );
-        },
-    },
-    {
-        accessorKey: "mbIn",
-        header: "MB In",
-    },
-    {
-        accessorKey: "mbOut",
-        header: "MB Out",
-    },
-    {
-        id: "actions",
-        cell: ({ row }) => {
-            const router = useRouter();
-
-            const siteRow = row.original;
-
-            const deleteSite = (siteId: number) => {
-                api.delete(`/site/${siteId}`)
-                    .catch((e) => {
-                        console.error("Error deleting site", e);
-                    })
-                    .then(() => {
-                        router.refresh();
-                    });
-            };
-
-            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={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
-                            >
-                                View settings
-                            </Link>
-                        </DropdownMenuItem>
-                        <DropdownMenuItem>
-                            <button
-                                onClick={() => deleteSite(siteRow.id)}
-                                className="text-red-600 hover:text-red-800"
-                            >
-                                Delete
-                            </button>
-                        </DropdownMenuItem>
-                    </DropdownMenuContent>
-                </DropdownMenu>
-            );
-        },
-    },
-];
-
 type SitesTableProps = {
     sites: SiteRow[];
     orgId: string;
@@ -122,26 +37,180 @@ type SitesTableProps = {
 export default function SitesTable({ sites, orgId }: SitesTableProps) {
     const router = useRouter();
 
-    const callApi = async () => {
+    const { toast } = useToast();
+
+    const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+    const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+    const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
 
-        const res = await api.put<AxiosResponse<any>>(
-            `/newt`
-        );
+    const callApi = async () => {
+        const res = await api.put<AxiosResponse<any>>(`/newt`);
         console.log(res);
-        
     };
 
+    const deleteSite = (siteId: number) => {
+        api.delete(`/site/${siteId}`)
+            .catch((e) => {
+                console.error("Error deleting site", e);
+                toast({
+                    variant: "destructive",
+                    title: "Error deleting site",
+                    description: formatAxiosError(e, "Error deleting site"),
+                });
+            })
+            .then(() => {
+                router.refresh();
+                setIsDeleteModalOpen(false);
+            });
+    };
+
+    const columns: ColumnDef<SiteRow>[] = [
+        {
+            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: "nice",
+            header: ({ column }) => {
+                return (
+                    <Button
+                        variant="ghost"
+                        onClick={() =>
+                            column.toggleSorting(column.getIsSorted() === "asc")
+                        }
+                    >
+                        Site
+                        <ArrowUpDown className="ml-2 h-4 w-4" />
+                    </Button>
+                );
+            },
+        },
+        {
+            accessorKey: "mbIn",
+            header: "MB In",
+        },
+        {
+            accessorKey: "mbOut",
+            header: "MB Out",
+        },
+        {
+            id: "actions",
+            cell: ({ row }) => {
+                const router = useRouter();
+
+                const siteRow = row.original;
+
+                return (
+                    <div className="flex items-center justify-end">
+                        <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={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
+                                    >
+                                        View settings
+                                    </Link>
+                                </DropdownMenuItem>
+                                <DropdownMenuItem>
+                                    <button
+                                        onClick={() => {
+                                            setSelectedSite(siteRow);
+                                            setIsDeleteModalOpen(true);
+                                        }}
+                                        className="text-red-600 hover:text-red-800"
+                                    >
+                                        Delete
+                                    </button>
+                                </DropdownMenuItem>
+                            </DropdownMenuContent>
+                        </DropdownMenu>
+                        <Button
+                            variant={"gray"}
+                            className="ml-2"
+                            onClick={() =>
+                                router.push(
+                                    `/${siteRow.orgId}/settings/sites/${siteRow.nice}`
+                                )
+                            }
+                        >
+                            Edit <ArrowRight className="ml-2 w-4 h-4" />
+                        </Button>
+                    </div>
+                );
+            },
+        },
+    ];
+
     return (
         <>
-        <SitesDataTable
-            columns={columns}
-            data={sites}
-            addSite={() => {
-                router.push(`/${orgId}/settings/sites/create`);
-            }}
-        />
-        <button onClick={callApi}>Create Newt</button>
-        </>
+            <CreateSiteForm
+                open={isCreateModalOpen}
+                setOpen={setIsCreateModalOpen}
+            />
+
+            {selectedSite && (
+                <ConfirmDeleteDialog
+                    open={isDeleteModalOpen}
+                    setOpen={(val) => {
+                        setIsDeleteModalOpen(val);
+                        setSelectedSite(null);
+                    }}
+                    dialog={
+                        <div className="space-y-4">
+                            <p>
+                                Are you sure you want to remove the site{" "}
+                                <b>{selectedSite?.name || selectedSite?.id}</b>{" "}
+                                from the organization?
+                            </p>
+
+                            <p>
+                                Once removed, the site will no longer be
+                                accessible.{" "}
+                                <b>
+                                    All resources and targets associated with
+                                    the site will also be removed.
+                                </b>
+                            </p>
 
+                            <p>
+                                To confirm, please type the name of the site
+                                below.
+                            </p>
+                        </div>
+                    }
+                    buttonText="Confirm Delete Site"
+                    onConfirm={async () => deleteSite(selectedSite!.id)}
+                    string={selectedSite.name}
+                    title="Delete Site"
+                />
+            )}
+
+            <SitesDataTable
+                columns={columns}
+                data={sites}
+                addSite={() => {
+                    setIsCreateModalOpen(true);
+                }}
+            />
+            {/* <button onClick={callApi}>Create Newt</button> */}
+        </>
     );
 }

+ 5 - 8
src/app/[orgId]/settings/sites/page.tsx

@@ -3,6 +3,7 @@ import { authCookieHeader } from "@app/api/cookies";
 import { ListSitesResponse } from "@server/routers/site";
 import { AxiosResponse } from "axios";
 import SitesTable, { SiteRow } from "./components/SitesTable";
+import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
 
 type SitesPageProps = {
     params: Promise<{ orgId: string }>;
@@ -34,14 +35,10 @@ export default async function SitesPage(props: SitesPageProps) {
 
     return (
         <>
-            <div className="space-y-0.5 select-none mb-6">
-                <h2 className="text-2xl font-bold tracking-tight">
-                    Manage Sites
-                </h2>
-                <p className="text-muted-foreground">
-                    Manage your existing sites here or create a new one.
-                </p>
-            </div>
+            <SettingsSectionTitle
+                title="Manage Sites"
+                description="Manage your existing sites here or create a new one."
+            />
 
             <SitesTable sites={siteRows} orgId={params.orgId} />
         </>

+ 7 - 3
src/app/auth/login/LoginForm.tsx

@@ -26,6 +26,7 @@ import { LoginResponse } from "@server/routers/auth";
 import { api } from "@app/api";
 import { useRouter } from "next/navigation";
 import { AxiosResponse } from "axios";
+import { formatAxiosError } from "@app/lib/utils";
 
 type LoginFormProps = {
     redirect?: string;
@@ -65,8 +66,7 @@ export default function LoginForm({ redirect }: LoginFormProps) {
             .catch((e) => {
                 console.error(e);
                 setError(
-                    e.response?.data?.message ||
-                        "An error occurred while logging in",
+                    formatAxiosError(e, "An error occurred while logging in")
                 );
             });
 
@@ -146,7 +146,11 @@ export default function LoginForm({ redirect }: LoginFormProps) {
                                 <AlertDescription>{error}</AlertDescription>
                             </Alert>
                         )}
-                        <Button type="submit" className="w-full" loading={loading}>
+                        <Button
+                            type="submit"
+                            className="w-full"
+                            loading={loading}
+                        >
                             Login
                         </Button>
                     </form>

+ 2 - 2
src/app/auth/signup/SignupForm.tsx

@@ -27,6 +27,7 @@ import { api } from "@app/api";
 import { useRouter } from "next/navigation";
 import { passwordSchema } from "@server/auth/passwordSchema";
 import { AxiosResponse } from "axios";
+import { formatAxiosError } from "@app/lib/utils";
 
 type SignupFormProps = {
     redirect?: string;
@@ -70,8 +71,7 @@ export default function SignupForm({ redirect }: SignupFormProps) {
             .catch((e) => {
                 console.error(e);
                 setError(
-                    e.response?.data?.message ||
-                        "An error occurred while signing up",
+                    formatAxiosError(e, "An error occurred while signing up")
                 );
             });
 

+ 4 - 3
src/app/auth/verify-email/VerifyEmailForm.tsx

@@ -34,6 +34,7 @@ import { Loader2 } from "lucide-react";
 import { Alert, AlertDescription } from "../../../components/ui/alert";
 import { useToast } from "@app/hooks/useToast";
 import { useRouter } from "next/navigation";
+import { formatAxiosError } from "@app/lib/utils";
 
 const FormSchema = z.object({
     email: z.string().email({ message: "Invalid email address" }),
@@ -76,14 +77,14 @@ export default function VerifyEmailForm({
                 code: data.pin,
             })
             .catch((e) => {
-                setError(e.response?.data?.message || "An error occurred");
+                setError(formatAxiosError(e, "An error occurred"));
                 console.error("Failed to verify email:", e);
             });
 
         if (res && res.data?.data?.valid) {
             setError(null);
             setSuccessMessage(
-                "Email successfully verified! Redirecting you...",
+                "Email successfully verified! Redirecting you..."
             );
             setTimeout(() => {
                 if (redirect && redirect.includes("http")) {
@@ -103,7 +104,7 @@ export default function VerifyEmailForm({
         setIsResending(true);
 
         const res = await api.post("/auth/verify-email/request").catch((e) => {
-            setError(e.response?.data?.message || "An error occurred");
+            setError(formatAxiosError(e, "An error occurred"));
             console.error("Failed to resend verification code:", e);
         });
 

+ 2 - 2
src/app/globals.css

@@ -38,7 +38,7 @@
         --chart-4: 23.33 8.82% 60%;
         --chart-5: 24 8.98% 67.25%;
 
-        --radius: 0.75rem;
+        --radius: 0.35rem;
     }
     .dark {
         --background: 0 0% 11.76%;
@@ -75,7 +75,7 @@
         --chart-4: 23.33 23.68% 14.9%;
         --chart-5: 24 23.81% 12.35%;
 
-        --radius: 0.75rem;
+        --radius: 0.35rem;
     }
 }
 

+ 3 - 2
src/app/invite/page.tsx

@@ -5,6 +5,7 @@ import { AcceptInviteResponse } from "@server/routers/user";
 import { AxiosResponse } from "axios";
 import { redirect } from "next/navigation";
 import InviteStatusCard from "./InviteStatusCard";
+import { formatAxiosError } from "@app/lib/utils";
 
 export default async function InvitePage(props: {
     searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
@@ -47,8 +48,8 @@ export default async function InvitePage(props: {
             await authCookieHeader()
         )
         .catch((e) => {
-            error = e.response?.data?.message;
-            console.log(error);
+            console.error(e);
+            error = formatAxiosError(e);
         });
 
     if (res && res.status === 200) {

+ 1 - 1
src/app/layout.tsx

@@ -1,6 +1,6 @@
 import type { Metadata } from "next";
 import "./globals.css";
-import { Inter } from "next/font/google";
+import { Fira_Sans, Inter } from "next/font/google";
 import { Toaster } from "@/components/ui/toaster";
 import { ThemeProvider } from "@app/providers/ThemeProvider";
 

+ 1 - 1
src/app/profile/account/account-form.tsx

@@ -88,7 +88,7 @@ export function AccountForm() {
 
   return (
     <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
+      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
         <FormField
           control={form.control}
           name="name"

+ 1 - 1
src/app/profile/appearance/appearance-form.tsx

@@ -55,7 +55,7 @@ export function AppearanceForm() {
 
   return (
     <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
+      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
         <FormField
           control={form.control}
           name="font"

+ 1 - 1
src/app/profile/display/display-form.tsx

@@ -76,7 +76,7 @@ export function DisplayForm() {
 
   return (
     <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
+      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
         <FormField
           control={form.control}
           name="items"

+ 1 - 1
src/app/profile/layout.tsx

@@ -64,7 +64,7 @@ export default function SettingsLayout({ children }: SettingsLayoutProps) {
           </p>
         </div>
         <Separator className="my-6" />
-        <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+        <div className="flex flex-col space-y-4 lg:flex-row lg:space-x-12 lg:space-y-0">
           <aside className="-mx-4 lg:w-1/5">
             <SidebarNav items={sidebarNavItems} />
           </aside>

+ 1 - 1
src/app/profile/notifications/notifications-form.tsx

@@ -60,7 +60,7 @@ export function NotificationsForm() {
 
   return (
     <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
+      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
         <FormField
           control={form.control}
           name="type"

+ 1 - 1
src/app/profile/profile-form.tsx

@@ -88,7 +88,7 @@ export function ProfileForm() {
 
   return (
     <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
+      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
         <FormField
           control={form.control}
           name="username"

+ 36 - 9
src/app/setup/page.tsx

@@ -15,6 +15,7 @@ import {
     CardTitle,
 } from "@app/components/ui/card";
 import CopyTextBox from "@app/components/CopyTextBox";
+import { formatAxiosError } from "@app/lib/utils";
 
 type Step = "org" | "site" | "resources";
 
@@ -43,7 +44,7 @@ export default function StepperForm() {
 
     const debouncedCheckOrgIdAvailability = useCallback(
         debounce(checkOrgIdAvailability, 300),
-        [checkOrgIdAvailability],
+        [checkOrgIdAvailability]
     );
 
     useEffect(() => {
@@ -76,7 +77,9 @@ export default function StepperForm() {
                 })
                 .catch((e) => {
                     toast({
-                        title: "Error creating org...",
+                        variant: "destructive",
+                        title: "Error creating org",
+                        description: formatAxiosError(e),
                     });
                 });
 
@@ -106,36 +109,60 @@ export default function StepperForm() {
                         <div className="flex justify-between mb-2">
                             <div className="flex flex-col items-center">
                                 <div
-                                    className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === "org" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
+                                    className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
+                                        currentStep === "org"
+                                            ? "bg-primary text-primary-foreground"
+                                            : "bg-muted text-muted-foreground"
+                                    }`}
                                 >
                                     1
                                 </div>
                                 <span
-                                    className={`text-sm font-medium ${currentStep === "org" ? "text-primary" : "text-muted-foreground"}`}
+                                    className={`text-sm font-medium ${
+                                        currentStep === "org"
+                                            ? "text-primary"
+                                            : "text-muted-foreground"
+                                    }`}
                                 >
                                     Create Org
                                 </span>
                             </div>
                             <div className="flex flex-col items-center">
                                 <div
-                                    className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === "site" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
+                                    className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
+                                        currentStep === "site"
+                                            ? "bg-primary text-primary-foreground"
+                                            : "bg-muted text-muted-foreground"
+                                    }`}
                                 >
                                     2
                                 </div>
                                 <span
-                                    className={`text-sm font-medium ${currentStep === "site" ? "text-primary" : "text-muted-foreground"}`}
+                                    className={`text-sm font-medium ${
+                                        currentStep === "site"
+                                            ? "text-primary"
+                                            : "text-muted-foreground"
+                                    }`}
                                 >
                                     Create Site
                                 </span>
                             </div>
                             <div className="flex flex-col items-center">
                                 <div
-                                    className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === "resources" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
+                                    className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
+                                        currentStep === "resources"
+                                            ? "bg-primary text-primary-foreground"
+                                            : "bg-muted text-muted-foreground"
+                                    }`}
                                 >
                                     3
                                 </div>
                                 <span
-                                    className={`text-sm font-medium ${currentStep === "resources" ? "text-primary" : "text-muted-foreground"}`}
+                                    className={`text-sm font-medium ${
+                                        currentStep === "resources"
+                                            ? "text-primary"
+                                            : "text-muted-foreground"
+                                    }`}
                                 >
                                     Create Resources
                                 </span>
@@ -251,7 +278,7 @@ export default function StepperForm() {
 
 function debounce<T extends (...args: any[]) => any>(
     func: T,
-    wait: number,
+    wait: number
 ): (...args: Parameters<T>) => void {
     let timeout: NodeJS.Timeout | null = null;
 

+ 1 - 0
src/components/CopyTextBox.tsx

@@ -37,6 +37,7 @@ export default function CopyTextBox({ text = "", wrapText = false }) {
             <Button
                 variant="outline"
                 size="icon"
+                type="button"
                 className="absolute top-1 right-1 z-10"
                 onClick={copyToClipboard}
                 aria-label="Copy to clipboard"

+ 24 - 0
src/components/SettingsSectionTitle.tsx

@@ -0,0 +1,24 @@
+type SettingsSectionTitleProps = {
+    title: string | React.ReactNode;
+    description: string | React.ReactNode;
+    size?: "2xl" | "1xl";
+};
+
+export default function SettingsSectionTitle({
+    title,
+    description,
+    size,
+}: SettingsSectionTitleProps) {
+    return (
+        <div className="space-y-0.5 select-none mb-6">
+            <h2
+                className={`text-${
+                    size ? size : "2xl"
+                } font-bold tracking-tight`}
+            >
+                {title}
+            </h2>
+            <p className="text-muted-foreground">{description}</p>
+        </div>
+    );
+}

+ 1 - 1
src/components/account-form.tsx

@@ -88,7 +88,7 @@ export function AccountForm() {
 
   return (
     <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
+      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
         <FormField
           control={form.control}
           name="name"

+ 1 - 1
src/components/appearance-form.tsx

@@ -62,7 +62,7 @@ export function AppearanceForm() {
 
     return (
         <Form {...form}>
-            <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
+            <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
                 <FormField
                     control={form.control}
                     name="font"

+ 1 - 1
src/components/display-form.tsx

@@ -76,7 +76,7 @@ export function DisplayForm() {
 
   return (
     <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
+      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
         <FormField
           control={form.control}
           name="items"

+ 1 - 1
src/components/notifications-form.tsx

@@ -60,7 +60,7 @@ export function NotificationsForm() {
 
   return (
     <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
+      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
         <FormField
           control={form.control}
           name="type"

+ 1 - 1
src/components/profile-form.tsx

@@ -88,7 +88,7 @@ export function ProfileForm() {
 
   return (
     <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
+      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
         <FormField
           control={form.control}
           name="username"

+ 1 - 1
src/components/sidebar-nav.tsx

@@ -59,9 +59,9 @@ export function SidebarNav({
         <div>
             <div className="block lg:hidden px-4">
                 <Select
+                    defaultValue={getSelectedValue()}
                     onValueChange={handleSelectChange}
                     disabled={disabled}
-                    defaultValue={getSelectedValue()}
                 >
                     <SelectTrigger>
                         <SelectValue placeholder="Select an option" />

+ 115 - 0
src/components/ui/breadcrumb.tsx

@@ -0,0 +1,115 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { ChevronRight, MoreHorizontal } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Breadcrumb = React.forwardRef<
+  HTMLElement,
+  React.ComponentPropsWithoutRef<"nav"> & {
+    separator?: React.ReactNode
+  }
+>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
+Breadcrumb.displayName = "Breadcrumb"
+
+const BreadcrumbList = React.forwardRef<
+  HTMLOListElement,
+  React.ComponentPropsWithoutRef<"ol">
+>(({ className, ...props }, ref) => (
+  <ol
+    ref={ref}
+    className={cn(
+      "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
+      className
+    )}
+    {...props}
+  />
+))
+BreadcrumbList.displayName = "BreadcrumbList"
+
+const BreadcrumbItem = React.forwardRef<
+  HTMLLIElement,
+  React.ComponentPropsWithoutRef<"li">
+>(({ className, ...props }, ref) => (
+  <li
+    ref={ref}
+    className={cn("inline-flex items-center gap-1.5", className)}
+    {...props}
+  />
+))
+BreadcrumbItem.displayName = "BreadcrumbItem"
+
+const BreadcrumbLink = React.forwardRef<
+  HTMLAnchorElement,
+  React.ComponentPropsWithoutRef<"a"> & {
+    asChild?: boolean
+  }
+>(({ asChild, className, ...props }, ref) => {
+  const Comp = asChild ? Slot : "a"
+
+  return (
+    <Comp
+      ref={ref}
+      className={cn("transition-colors hover:text-foreground", className)}
+      {...props}
+    />
+  )
+})
+BreadcrumbLink.displayName = "BreadcrumbLink"
+
+const BreadcrumbPage = React.forwardRef<
+  HTMLSpanElement,
+  React.ComponentPropsWithoutRef<"span">
+>(({ className, ...props }, ref) => (
+  <span
+    ref={ref}
+    role="link"
+    aria-disabled="true"
+    aria-current="page"
+    className={cn("font-normal text-foreground", className)}
+    {...props}
+  />
+))
+BreadcrumbPage.displayName = "BreadcrumbPage"
+
+const BreadcrumbSeparator = ({
+  children,
+  className,
+  ...props
+}: React.ComponentProps<"li">) => (
+  <li
+    role="presentation"
+    aria-hidden="true"
+    className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
+    {...props}
+  >
+    {children ?? <ChevronRight />}
+  </li>
+)
+BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
+
+const BreadcrumbEllipsis = ({
+  className,
+  ...props
+}: React.ComponentProps<"span">) => (
+  <span
+    role="presentation"
+    aria-hidden="true"
+    className={cn("flex h-9 w-9 items-center justify-center", className)}
+    {...props}
+  >
+    <MoreHorizontal className="h-4 w-4" />
+    <span className="sr-only">More</span>
+  </span>
+)
+BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
+
+export {
+  Breadcrumb,
+  BreadcrumbList,
+  BreadcrumbItem,
+  BreadcrumbLink,
+  BreadcrumbPage,
+  BreadcrumbSeparator,
+  BreadcrumbEllipsis,
+}

+ 1 - 0
src/components/ui/button.tsx

@@ -19,6 +19,7 @@ const buttonVariants = cva(
                 secondary:
                     "bg-secondary text-secondary-foreground hover:bg-secondary/80",
                 ghost: "hover:bg-accent hover:text-accent-foreground",
+                gray: "bg-accent text-accent-foreground hover:bg-accent/90",
                 link: "text-primary underline-offset-4 hover:underline",
             },
             size: {

+ 2 - 2
src/components/ui/dialog.tsx

@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
     <DialogPrimitive.Overlay
         ref={ref}
         className={cn(
-            "fixed inset-0 z-50 bg-black/50  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+            "fixed inset-0 z-50 bg-black/30  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
             className
         )}
         {...props}
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
         <DialogPrimitive.Content
             ref={ref}
             className={cn(
-                "fixed left-[50%] top-[30%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
+                "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
                 className
             )}
             {...props}

+ 1 - 1
src/components/ui/toaster.tsx

@@ -17,7 +17,7 @@ export function Toaster() {
     <ToastProvider>
       {toasts.map(function ({ id, title, description, action, ...props }) {
         return (
-          <Toast key={id} {...props}>
+          <Toast key={id} {...props} className="mt-2">
             <div className="grid gap-1">
               {title && <ToastTitle>{title}</ToastTitle>}
               {description && (

+ 1 - 1
src/contexts/orgContext.ts

@@ -2,7 +2,7 @@ import { GetOrgResponse } from "@server/routers/org";
 import { createContext } from "react";
 
 interface OrgContextType {
-    org: GetOrgResponse | null;
+    org: GetOrgResponse;
     updateOrg: (updateOrg: Partial<GetOrgResponse>) => void;
 }
 

+ 11 - 0
src/contexts/orgUserContext.ts

@@ -0,0 +1,11 @@
+import { GetOrgUserResponse } from "@server/routers/user";
+import { createContext } from "react";
+
+interface OrgUserContext {
+    orgUser: GetOrgUserResponse;
+    updateOrgUser: (updateOrgUser: Partial<GetOrgUserResponse>) => void;
+}
+
+const OrgUserContext = createContext<OrgUserContext | undefined>(undefined);
+
+export default OrgUserContext;

+ 1 - 1
src/contexts/resourceContext.ts

@@ -2,7 +2,7 @@ import { GetResourceResponse } from "@server/routers/resource/getResource";
 import { createContext } from "react";
 
 interface ResourceContextType {
-    resource: GetResourceResponse | null;
+    resource: GetResourceResponse;
     updateResource: (updatedResource: Partial<GetResourceResponse>) => void;
 }
 

+ 12 - 0
src/hooks/useOrgUserContext.ts

@@ -0,0 +1,12 @@
+import OrgUserContext from "@app/contexts/orgUserContext";
+import { useContext } from "react";
+
+export function userOrgUserContext() {
+    const context = useContext(OrgUserContext);
+    if (context === undefined) {
+        throw new Error(
+            "useOrgUserContext must be used within a OrgUserProvider"
+        );
+    }
+    return context;
+}

+ 151 - 153
src/hooks/useToast.ts

@@ -1,194 +1,192 @@
-"use client"
+"use client";
 
 // Inspired by react-hot-toast library
-import * as React from "react"
+import * as React from "react";
 
-import type {
-  ToastActionElement,
-  ToastProps,
-} from "@/components/ui/toast"
+import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
 
-const TOAST_LIMIT = 1
-const TOAST_REMOVE_DELAY = 1000000
+const TOAST_LIMIT = 3;
+const TOAST_REMOVE_DELAY = 5 * 1000;
 
 type ToasterToast = ToastProps & {
-  id: string
-  title?: React.ReactNode
-  description?: React.ReactNode
-  action?: ToastActionElement
-}
+    id: string;
+    title?: React.ReactNode;
+    description?: React.ReactNode;
+    action?: ToastActionElement;
+};
 
 const actionTypes = {
-  ADD_TOAST: "ADD_TOAST",
-  UPDATE_TOAST: "UPDATE_TOAST",
-  DISMISS_TOAST: "DISMISS_TOAST",
-  REMOVE_TOAST: "REMOVE_TOAST",
-} as const
+    ADD_TOAST: "ADD_TOAST",
+    UPDATE_TOAST: "UPDATE_TOAST",
+    DISMISS_TOAST: "DISMISS_TOAST",
+    REMOVE_TOAST: "REMOVE_TOAST",
+} as const;
 
-let count = 0
+let count = 0;
 
 function genId() {
-  count = (count + 1) % Number.MAX_SAFE_INTEGER
-  return count.toString()
+    count = (count + 1) % Number.MAX_SAFE_INTEGER;
+    return count.toString();
 }
 
-type ActionType = typeof actionTypes
+type ActionType = typeof actionTypes;
 
 type Action =
-  | {
-      type: ActionType["ADD_TOAST"]
-      toast: ToasterToast
-    }
-  | {
-      type: ActionType["UPDATE_TOAST"]
-      toast: Partial<ToasterToast>
-    }
-  | {
-      type: ActionType["DISMISS_TOAST"]
-      toastId?: ToasterToast["id"]
-    }
-  | {
-      type: ActionType["REMOVE_TOAST"]
-      toastId?: ToasterToast["id"]
-    }
+    | {
+          type: ActionType["ADD_TOAST"];
+          toast: ToasterToast;
+      }
+    | {
+          type: ActionType["UPDATE_TOAST"];
+          toast: Partial<ToasterToast>;
+      }
+    | {
+          type: ActionType["DISMISS_TOAST"];
+          toastId?: ToasterToast["id"];
+      }
+    | {
+          type: ActionType["REMOVE_TOAST"];
+          toastId?: ToasterToast["id"];
+      };
 
 interface State {
-  toasts: ToasterToast[]
+    toasts: ToasterToast[];
 }
 
-const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
+const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
 
 const addToRemoveQueue = (toastId: string) => {
-  if (toastTimeouts.has(toastId)) {
-    return
-  }
+    if (toastTimeouts.has(toastId)) {
+        return;
+    }
 
-  const timeout = setTimeout(() => {
-    toastTimeouts.delete(toastId)
-    dispatch({
-      type: "REMOVE_TOAST",
-      toastId: toastId,
-    })
-  }, TOAST_REMOVE_DELAY)
+    const timeout = setTimeout(() => {
+        toastTimeouts.delete(toastId);
+        dispatch({
+            type: "REMOVE_TOAST",
+            toastId: toastId,
+        });
+    }, TOAST_REMOVE_DELAY);
 
-  toastTimeouts.set(toastId, timeout)
-}
+    toastTimeouts.set(toastId, timeout);
+};
 
 export const reducer = (state: State, action: Action): State => {
-  switch (action.type) {
-    case "ADD_TOAST":
-      return {
-        ...state,
-        toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
-      }
-
-    case "UPDATE_TOAST":
-      return {
-        ...state,
-        toasts: state.toasts.map((t) =>
-          t.id === action.toast.id ? { ...t, ...action.toast } : t
-        ),
-      }
-
-    case "DISMISS_TOAST": {
-      const { toastId } = action
-
-      // ! Side effects ! - This could be extracted into a dismissToast() action,
-      // but I'll keep it here for simplicity
-      if (toastId) {
-        addToRemoveQueue(toastId)
-      } else {
-        state.toasts.forEach((toast) => {
-          addToRemoveQueue(toast.id)
-        })
-      }
-
-      return {
-        ...state,
-        toasts: state.toasts.map((t) =>
-          t.id === toastId || toastId === undefined
-            ? {
-                ...t,
-                open: false,
-              }
-            : t
-        ),
-      }
-    }
-    case "REMOVE_TOAST":
-      if (action.toastId === undefined) {
-        return {
-          ...state,
-          toasts: [],
+    switch (action.type) {
+        case "ADD_TOAST":
+            return {
+                ...state,
+                toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
+            };
+
+        case "UPDATE_TOAST":
+            return {
+                ...state,
+                toasts: state.toasts.map((t) =>
+                    t.id === action.toast.id ? { ...t, ...action.toast } : t
+                ),
+            };
+
+        case "DISMISS_TOAST": {
+            const { toastId } = action;
+
+            // ! Side effects ! - This could be extracted into a dismissToast() action,
+            // but I'll keep it here for simplicity
+            if (toastId) {
+                addToRemoveQueue(toastId);
+            } else {
+                state.toasts.forEach((toast) => {
+                    addToRemoveQueue(toast.id);
+                });
+            }
+
+            return {
+                ...state,
+                toasts: state.toasts.map((t) =>
+                    t.id === toastId || toastId === undefined
+                        ? {
+                              ...t,
+                              open: false,
+                          }
+                        : t
+                ),
+            };
         }
-      }
-      return {
-        ...state,
-        toasts: state.toasts.filter((t) => t.id !== action.toastId),
-      }
-  }
-}
+        case "REMOVE_TOAST":
+            if (action.toastId === undefined) {
+                return {
+                    ...state,
+                    toasts: [],
+                };
+            }
+            return {
+                ...state,
+                toasts: state.toasts.filter((t) => t.id !== action.toastId),
+            };
+    }
+};
 
-const listeners: Array<(state: State) => void> = []
+const listeners: Array<(state: State) => void> = [];
 
-let memoryState: State = { toasts: [] }
+let memoryState: State = { toasts: [] };
 
 function dispatch(action: Action) {
-  memoryState = reducer(memoryState, action)
-  listeners.forEach((listener) => {
-    listener(memoryState)
-  })
+    memoryState = reducer(memoryState, action);
+    listeners.forEach((listener) => {
+        listener(memoryState);
+    });
 }
 
-type Toast = Omit<ToasterToast, "id">
+type Toast = Omit<ToasterToast, "id">;
 
 function toast({ ...props }: Toast) {
-  const id = genId()
+    const id = genId();
+
+    const update = (props: ToasterToast) =>
+        dispatch({
+            type: "UPDATE_TOAST",
+            toast: { ...props, id },
+        });
+    const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
 
-  const update = (props: ToasterToast) =>
     dispatch({
-      type: "UPDATE_TOAST",
-      toast: { ...props, id },
-    })
-  const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
-
-  dispatch({
-    type: "ADD_TOAST",
-    toast: {
-      ...props,
-      id,
-      open: true,
-      onOpenChange: (open) => {
-        if (!open) dismiss()
-      },
-    },
-  })
-
-  return {
-    id: id,
-    dismiss,
-    update,
-  }
+        type: "ADD_TOAST",
+        toast: {
+            ...props,
+            id,
+            open: true,
+            onOpenChange: (open) => {
+                if (!open) dismiss();
+            },
+        },
+    });
+
+    return {
+        id: id,
+        dismiss,
+        update,
+    };
 }
 
 function useToast() {
-  const [state, setState] = React.useState<State>(memoryState)
-
-  React.useEffect(() => {
-    listeners.push(setState)
-    return () => {
-      const index = listeners.indexOf(setState)
-      if (index > -1) {
-        listeners.splice(index, 1)
-      }
-    }
-  }, [state])
-
-  return {
-    ...state,
-    toast,
-    dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
-  }
+    const [state, setState] = React.useState<State>(memoryState);
+
+    React.useEffect(() => {
+        listeners.push(setState);
+        return () => {
+            const index = listeners.indexOf(setState);
+            if (index > -1) {
+                listeners.splice(index, 1);
+            }
+        };
+    }, [state]);
+
+    return {
+        ...state,
+        toast,
+        dismiss: (toastId?: string) =>
+            dispatch({ type: "DISMISS_TOAST", toastId }),
+    };
 }
 
-export { useToast, toast }
+export { useToast, toast };

+ 12 - 3
src/lib/utils.ts

@@ -1,6 +1,15 @@
-import { clsx, type ClassValue } from "clsx"
-import { twMerge } from "tailwind-merge"
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
 
 export function cn(...inputs: ClassValue[]) {
-    return twMerge(clsx(inputs))
+    return twMerge(clsx(inputs));
+}
+
+export function formatAxiosError(error: any, defaultMessage?: string): string {
+    return (
+        error.response?.data?.message ||
+        error?.message ||
+        defaultMessage ||
+        "An error occurred"
+    );
 }

+ 4 - 0
src/providers/OrgProvider.tsx

@@ -12,6 +12,10 @@ interface OrgProviderProps {
 export function OrgProvider({ children, org: serverOrg }: OrgProviderProps) {
     const [org, setOrg] = useState<GetOrgResponse | null>(serverOrg);
 
+    if (!org) {
+        throw new Error("No org provided");
+    }
+
     const updateOrg = (updatedOrg: Partial<GetOrgResponse>) => {
         if (!org) {
             throw new Error("No org to update");

Some files were not shown because too many files changed in this diff