Selaa lähdekoodia

add roles input on resource and make spacing more consistent

Milo Schwartz 8 kuukautta sitten
vanhempi
commit
28bae40390
36 muutettua tiedostoa jossa 1232 lisäystä ja 721 poistoa
  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. 17 20
      server/routers/external.ts
  6. 2 4
      server/routers/resource/createResource.ts
  7. 22 12
      server/routers/resource/listResourceRoles.ts
  8. 111 0
      server/routers/resource/setResourceRoles.ts
  9. 2 1
      server/routers/resource/updateResource.ts
  10. 0 70
      server/routers/role/addRoleResource.ts
  11. 1 1
      server/routers/role/index.ts
  12. 17 5
      server/routers/traefik/getTraefikConfig.ts
  13. 9 0
      server/schemas/subdomainSchema.ts
  14. 1 1
      src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx
  15. 55 47
      src/app/[orgId]/settings/access/roles/components/DeleteRoleForm.tsx
  16. 29 27
      src/app/[orgId]/settings/access/roles/components/RolesTable.tsx
  17. 52 46
      src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx
  18. 126 116
      src/app/[orgId]/settings/access/users/components/InviteUserForm.tsx
  19. 60 40
      src/app/[orgId]/settings/access/users/components/UsersTable.tsx
  20. 1 1
      src/app/[orgId]/settings/components/Header.tsx
  21. 0 1
      src/app/[orgId]/settings/general/layout.tsx
  22. 9 7
      src/app/[orgId]/settings/general/page.tsx
  23. 204 0
      src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx
  24. 8 10
      src/app/[orgId]/settings/resources/[resourceId]/components/CustomDomainInput.tsx
  25. 89 0
      src/app/[orgId]/settings/resources/[resourceId]/components/ResourceInfoBox.tsx
  26. 106 117
      src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx
  27. 49 6
      src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx
  28. 39 9
      src/app/[orgId]/settings/resources/[resourceId]/layout.tsx
  29. 14 11
      src/app/[orgId]/settings/resources/components/CreateResourceForm.tsx
  30. 2 2
      src/app/[orgId]/settings/resources/components/ResourcesTable.tsx
  31. 25 1
      src/app/[orgId]/settings/resources/page.tsx
  32. 31 29
      src/app/[orgId]/settings/sites/[niceId]/general/page.tsx
  33. 100 95
      src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx
  34. 5 5
      src/app/[orgId]/settings/sites/components/SitesTable.tsx
  35. 1 1
      src/contexts/orgContext.ts
  36. 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);

+ 17 - 20
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,

+ 2 - 4
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
@@ -28,7 +29,7 @@ const createResourceParamsSchema = z.object({
 const createResourceSchema = z
     .object({
         name: z.string().min(1).max(255),
-        subdomain: z.string().min(1).max(255).optional(),
+        subdomain: subdomainSchema,
     })
     .strict();
 
@@ -87,12 +88,9 @@ export async function createResource(
             );
         }
 
-        const fullDomain = `${subdomain}.${org[0].domain}`;
-
         const newResource = await db
             .insert(resources)
             .values({
-                fullDomain,
                 siteId,
                 orgId,
                 name,

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

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

@@ -8,6 +8,7 @@ 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,7 +17,7 @@ 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(),
     })

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

+ 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";

+ 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}\`)`,
                 };
             }
 

+ 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");

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

@@ -123,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

+ 55 - 47
src/app/[orgId]/settings/access/roles/components/DeleteRoleForm.tsx

@@ -155,53 +155,61 @@ export default function DeleteRoleForm({
                         </CredenzaDescription>
                     </CredenzaHeader>
                     <CredenzaBody>
-                        <p className="mb-1">
-                            You're about to delete the{" "}
-                            <b>{roleToDelete.name}</b> role. You cannot undo
-                            this action.
-                        </p>
-                        <p className="mb-4">
-                            Before deleting this role, please select a new role
-                            to transfer existing members to.
-                        </p>
-                        <Form {...form}>
-                            <form
-                                onSubmit={form.handleSubmit(onSubmit)}
-                                className="space-y-4"
-                                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 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

+ 29 - 27
src/app/[orgId]/settings/access/roles/components/RolesTable.tsx

@@ -64,34 +64,36 @@ 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>
                     </>
                 );
             },

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

@@ -110,52 +110,58 @@ export default function AccessControlsPage() {
 
     return (
         <>
-            <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-4"
-                >
-                    <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 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>
         </>
     );
 }

+ 126 - 116
src/app/[orgId]/settings/access/users/components/InviteUserForm.tsx

@@ -171,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

+ 60 - 40
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";
@@ -112,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>
                     </>
                 );
             },
@@ -194,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
@@ -213,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

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

@@ -97,7 +97,7 @@ export default function Header({ email, orgName, name, orgs }: HeaderProps) {
                             <DropdownMenuSeparator />
                             <DropdownMenuGroup>
                                 <DropdownMenuItem onClick={logout}>
-                                    Log out
+                                    Logout
                                 </DropdownMenuItem>
                             </DropdownMenuGroup>
                         </DropdownMenuContent>

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

@@ -71,7 +71,6 @@ export default async function GeneralSettingsPage({
                     <SettingsSectionTitle
                         title="General"
                         description="Configure your organization's general settings"
-                        size="1xl"
                     />
 
                     <SidebarSettings sidebarNavItems={sidebarNavItems}>

+ 9 - 7
src/app/[orgId]/settings/general/page.tsx

@@ -46,13 +46,15 @@ export default function GeneralPage() {
                 title="Delete organization"
             />
 
-            {orgUser.isOwner ? (
-                <Button onClick={() => setIsDeleteModalOpen(true)}>
-                    Delete Organization
-                </Button>
-            ) : (
-                <p>Nothing to see here</p>
-            )}
+            <div className="space-y-6">
+                {orgUser.isOwner ? (
+                    <Button onClick={() => setIsDeleteModalOpen(true)}>
+                        Delete Organization
+                    </Button>
+                ) : (
+                    <p>Nothing to see here</p>
+                )}
+            </div>
         </>
     );
 }

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

+ 8 - 10
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;

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

@@ -0,0 +1,89 @@
+"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";
+
+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>
+                    {/* <ul className="mt-3 space-y-1 text-sm list-disc list-inside">
+                        <li>
+                            Protocol:{" "}
+                            <span className="font-semibold">{protocol}</span>
+                        </li>
+                        <li>
+                            Subdomain:{" "}
+                            <span className="font-semibold">{subdomain}</span>
+                        </li>
+                        <li>
+                            Domain:{" "}
+                            <span className="font-semibold">{domain}</span>
+                        </li>
+                    </ul> */}
+                </AlertDescription>
+            </Alert>
+        </Card>
+    );
+}

+ 106 - 117
src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx

@@ -339,117 +339,109 @@ export default function ReverseProxyTargets(props: {
 
     return (
         <div>
-            {/* <div className="lg:max-w-2xl"> */}
-            <div>
-                <div className="mb-8">
-                    <SettingsSectionTitle
-                        title="SSL"
-                        description="Setup SSL to secure your connections with LetsEncrypt certificates"
-                        size="1xl"
-                    />
+            <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>
+                <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>
 
-                <div className="mb-8">
-                    <SettingsSectionTitle
-                        title="Targets"
-                        description="Setup targets to route traffic to your services"
-                        size="1xl"
-                    />
+                <SettingsSectionTitle
+                    title="Targets"
+                    description="Setup targets to route traffic to your services"
+                    size="1xl"
+                />
 
-                    <Form {...addTargetForm}>
-                        <form
-                            onSubmit={addTargetForm.handleSubmit(
-                                addTarget as any
-                            )}
-                            className="space-y-4"
-                        >
-                            <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
+                <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 }) => (
@@ -486,15 +478,14 @@ export default function ReverseProxyTargets(props: {
                                         </FormItem>
                                     )}
                                 /> */}
-                            </div>
-                            <Button type="submit" variant="gray">
-                                Add Target
-                            </Button>
-                        </form>
-                    </Form>
-                </div>
+                        </div>
+                        <Button type="submit" variant="gray">
+                            Add Target
+                        </Button>
+                    </form>
+                </Form>
 
-                <div className="rounded-md mt-4">
+                <div className="rounded-md border">
                     <Table>
                         <TableHeader>
                             {table.getHeaderGroups().map((headerGroup) => (
@@ -540,9 +531,7 @@ export default function ReverseProxyTargets(props: {
                         </TableBody>
                     </Table>
                 </div>
-            </div>
 
-            <div className="mt-8">
                 <Button onClick={saveAll} loading={loading} disabled={loading}>
                     Save Changes
                 </Button>

+ 49 - 6
src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx

@@ -39,10 +39,15 @@ 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(),
-    siteId: z.number(),
+    subdomain: subdomainSchema,
+    // siteId: z.number(),
 });
 
 type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -51,16 +56,19 @@ 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",
@@ -78,12 +86,12 @@ export default function GeneralForm() {
 
     async function onSubmit(data: GeneralFormValues) {
         setSaveLoading(true);
-        updateResource({ name: data.name, siteId: data.siteId });
 
         api.post<AxiosResponse<GetResourceResponse>>(
             `resource/${resource?.resourceId}`,
             {
                 name: data.name,
+                subdomain: data.subdomain,
                 // siteId: data.siteId,
             }
         )
@@ -102,13 +110,15 @@ export default function GeneralForm() {
                     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">
+            <div className="lg:max-w-2xl space-y-6">
                 <SettingsSectionTitle
                     title="General Settings"
                     description="Configure the general settings for this resource"
@@ -118,7 +128,7 @@ export default function GeneralForm() {
                 <Form {...form}>
                     <form
                         onSubmit={form.handleSubmit(onSubmit)}
-                        className="space-y-4"
+                        className="space-y-6"
                     >
                         <FormField
                             control={form.control}
@@ -130,13 +140,46 @@ export default function GeneralForm() {
                                         <Input {...field} />
                                     </FormControl>
                                     <FormDescription>
-                                        This is the display name of the
-                                        resource.
+                                        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"

+ 39 - 9
src/app/[orgId]/settings/resources/[resourceId]/layout.tsx

@@ -8,6 +8,10 @@ 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;
@@ -20,7 +24,6 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
     const { children } = props;
 
     let resource = null;
-
     try {
         const res = await internal.get<AxiosResponse<GetResourceResponse>>(
             `/resource/${params.resourceId}`,
@@ -31,6 +34,28 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
         redirect(`/${params.orgId}/settings/resources`);
     }
 
+    if (!resource) {
+        redirect(`/${params.orgId}/settings/resources`);
+    }
+
+    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 sidebarNavItems = [
         {
             title: "General",
@@ -65,14 +90,19 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
                 description="Configure the settings on your resource"
             />
 
-            <ResourceProvider resource={resource}>
-                <SidebarSettings
-                    sidebarNavItems={sidebarNavItems}
-                    limitWidth={false}
-                >
-                    {children}
-                </SidebarSettings>
-            </ResourceProvider>
+            <OrgProvider org={org}>
+                <ResourceProvider resource={resource}>
+                    <SidebarSettings
+                        sidebarNavItems={sidebarNavItems}
+                        limitWidth={false}
+                    >
+                        <div className="mb-8">
+                            <ResourceInfoBox />
+                        </div>
+                        {children}
+                    </SidebarSettings>
+                </ResourceProvider>
+            </OrgProvider>
         </>
     );
 }

+ 14 - 11
src/app/[orgId]/settings/resources/components/CreateResourceForm.tsx

@@ -48,16 +48,11 @@ 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: z
-        .string()
-        .min(2, {
-            message: "Name must be at least 2 characters.",
-        })
-        .max(30, {
-            message: "Name must not be longer than 30 characters.",
-        }),
+    subdomain: subdomainSchema,
     name: z.string(),
     siteId: z.number(),
 });
@@ -65,7 +60,7 @@ const accountFormSchema = z.object({
 type AccountFormValues = z.infer<typeof accountFormSchema>;
 
 const defaultValues: Partial<AccountFormValues> = {
-    subdomain: "someanimalherefromapi",
+    subdomain: "",
     name: "My Resource",
 };
 
@@ -86,8 +81,10 @@ export default function CreateResourceForm({
     const orgId = params.orgId;
     const router = useRouter();
 
+    const { org } = useOrgContext();
+
     const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
-    const [domainSuffix, setDomainSuffix] = useState<string>(".example.com");
+    const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain);
 
     const form = useForm<AccountFormValues>({
         resolver: zodResolver(accountFormSchema),
@@ -193,9 +190,15 @@ export default function CreateResourceForm({
                                             <FormLabel>Subdomain</FormLabel>
                                             <FormControl>
                                                 <CustomDomainInput
-                                                    {...field}
+                                                    value={field.value}
                                                     domainSuffix={domainSuffix}
                                                     placeholder="Enter subdomain"
+                                                    onChange={(value) =>
+                                                        form.setValue(
+                                                            "subdomain",
+                                                            value
+                                                        )
+                                                    }
                                                 />
                                             </FormControl>
                                             <FormDescription>

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

@@ -196,10 +196,10 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
                             </p>
                         </div>
                     }
-                    buttonText="Confirm delete resource"
+                    buttonText="Confirm Delete Resource"
                     onConfirm={async () => deleteResource(selectedResource!.id)}
                     string={selectedResource.name}
-                    title="Delete resource"
+                    title="Delete Resource"
                 />
             )}
 

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

@@ -4,6 +4,10 @@ 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 }>;
@@ -22,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,
@@ -39,7 +61,9 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
                 description="Create secure proxies to your private applications"
             />
 
-            <ResourcesTable resources={resourceRows} orgId={params.orgId} />
+            <OrgProvider org={org}>
+                <ResourcesTable resources={resourceRows} orgId={params.orgId} />
+            </OrgProvider>
         </>
     );
 }

+ 31 - 29
src/app/[orgId]/settings/sites/[niceId]/general/page.tsx

@@ -64,36 +64,38 @@ export default function GeneralPage() {
 
     return (
         <>
-            <SettingsSectionTitle
-                title="General Settings"
-                description="Configure the general settings for this site"
-                size="1xl"
-            />
+            <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-4"
-                >
-                    <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>
+                <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>
         </>
     );
 }

+ 100 - 95
src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx

@@ -191,104 +191,109 @@ sh get-docker.sh`;
                         </CredenzaDescription>
                     </CredenzaHeader>
                     <CredenzaBody>
-                        <Form {...form}>
-                            <form
-                                onSubmit={form.handleSubmit(onSubmit)}
-                                className="space-y-4"
-                                id="create-site-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 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="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>
+                                    <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 mt-2">
-                                    You will only be able to see the
-                                    configuration once.
-                                </span>
+                                    <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 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

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

@@ -174,14 +174,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
                         setSelectedSite(null);
                     }}
                     dialog={
-                        <div>
-                            <p className="mb-2">
+                        <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 className="mb-2">
+                            <p>
                                 Once removed, the site will no longer be
                                 accessible.{" "}
                                 <b>
@@ -196,10 +196,10 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
                             </p>
                         </div>
                     }
-                    buttonText="Confirm delete site"
+                    buttonText="Confirm Delete Site"
                     onConfirm={async () => deleteSite(selectedSite!.id)}
                     string={selectedSite.name}
-                    title="Delete site"
+                    title="Delete Site"
                 />
             )}
 

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

+ 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");