diff --git a/package.json b/package.json index 2fd771c2..4be29e72 100644 --- a/package.json +++ b/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", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 04b72a8d..0fbb6d8f 100644 --- a/server/auth/actions.ts +++ b/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", diff --git a/server/db/schema.ts b/server/db/schema.ts index 016b6c48..6b0c6e41 100644 --- a/server/db/schema.ts +++ b/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), }); diff --git a/server/routers/auth/verifyRoleAccess.ts b/server/routers/auth/verifyRoleAccess.ts index d714e1ba..7c1a9037 100644 --- a/server/routers/auth/verifyRoleAccess.ts +++ b/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 (userOrgRole.length === 0) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `User does not have access to organization for role ID ${role.roleId}` + ) + ); + } } - if (req.userOrg.orgId !== role[0].orgId) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "Role does not belong to the organization" - ) - ); - } - - req.userOrgRoleId = req.userOrg.roleId; - req.userOrgId = req.userOrg.orgId; - return next(); } catch (error) { logger.error("Error verifying role access:", error); diff --git a/server/routers/external.ts b/server/routers/external.ts index 5d22c753..46d89645 100644 --- a/server/routers/external.ts +++ b/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, diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index f5021417..63f1ef3c 100644 --- a/server/routers/resource/createResource.ts +++ b/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, diff --git a/server/routers/resource/listResourceRoles.ts b/server/routers/resource/listResourceRoles.ts index df8e41c6..ea9ebade 100644 --- a/server/routers/resource/listResourceRoles.ts +++ b/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>>; +}; + 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(res, { + data: { + roles: resourceRolesList, + }, success: true, error: false, message: "Resource roles retrieved successfully", diff --git a/server/routers/resource/setResourceRoles.ts b/server/routers/resource/setResourceRoles.ts new file mode 100644 index 00000000..7aa9edf1 --- /dev/null +++ b/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 { + 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") + ); + } +} diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 23ba87e9..664f8d84 100644 --- a/server/routers/resource/updateResource.ts +++ b/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(), }) diff --git a/server/routers/role/addRoleResource.ts b/server/routers/role/addRoleResource.ts deleted file mode 100644 index dc7cb6f5..00000000 --- a/server/routers/role/addRoleResource.ts +++ /dev/null @@ -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 { - 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") - ); - } -} diff --git a/server/routers/role/index.ts b/server/routers/role/index.ts index fff5448d..0194c1f0 100644 --- a/server/routers/role/index.ts +++ b/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"; diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 3ef0253a..e9747278 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/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; } - const domainParts = resource.fullDomain.split("."); + if (!org || !org.domain) { + continue; + } + + 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}\`)`, }; } diff --git a/server/schemas/subdomainSchema.ts b/server/schemas/subdomainSchema.ts new file mode 100644 index 00000000..4f761f4a --- /dev/null +++ b/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"); diff --git a/src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx b/src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx index d4f7368c..9659376e 100644 --- a/src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx +++ b/src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx @@ -123,7 +123,7 @@ export default function CreateRoleForm({
-

- You're about to delete the{" "} - {roleToDelete.name} role. You cannot undo - this action. -

-

- Before deleting this role, please select a new role - to transfer existing members to. -

- - - ( - - Role - - - - )} - /> - - +
+
+

+ You're about to delete the{" "} + {roleToDelete.name} role. You cannot + undo this action. +

+

+ Before deleting this role, please select a + new role to transfer existing members to. +

+
+
+ + ( + + Role + + + + )} + /> + + +
- - - - - - - - )} + + Open menu + + + + + + + + + + + )} + ); }, diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index e9a8e54e..fa548995 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -110,52 +110,58 @@ export default function AccessControlsPage() { return ( <> - +
+ -
- - ( - - Role - - - - )} - /> - - - +
+ + ( + + Role + + + + )} + /> + + + +
); } diff --git a/src/app/[orgId]/settings/access/users/components/InviteUserForm.tsx b/src/app/[orgId]/settings/access/users/components/InviteUserForm.tsx index 6f05eb8c..2f5a31dd 100644 --- a/src/app/[orgId]/settings/access/users/components/InviteUserForm.tsx +++ b/src/app/[orgId]/settings/access/users/components/InviteUserForm.tsx @@ -171,125 +171,135 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) { - {!inviteLink && ( -
- - ( - - Email - - - - - - )} - /> - ( - - Role - - - {roles.map((role) => ( - - {role.name} - - ))} - - - - - )} - /> - ( - - Valid For - - - - )} - /> - - - )} + + + )} + /> + ( + + Role + + + + )} + /> + ( + + + Valid For + + + + + )} + /> + + + )} - {inviteLink && ( -
-

- The user has been successfully invited. They - must access the link below to accept the - invitation. -

-

- The invite will expire in{" "} - - {expiresInDays}{" "} - {expiresInDays === 1 ? "day" : "days"} - - . -

- -
- )} + {inviteLink && ( +
+

+ The user has been successfully invited. + They must access the link below to + accept the invitation. +

+

+ The invite will expire in{" "} + + {expiresInDays}{" "} + {expiresInDays === 1 + ? "day" + : "days"} + + . +

+ +
+ )} +
- - - - - Manage User - - - {userRow.email !== user?.email && ( - - - - )} - - - )} + + Open menu + + + + + + + + Manage User + + + {userRow.email !== user?.email && ( + + + + )} + + + + + )} + ); }, @@ -194,13 +214,13 @@ export default function UsersTable({ users: u }: UsersTableProps) { setSelectedUser(null); }} dialog={ -
-

+

+

Are you sure you want to remove{" "} {selectedUser?.email} from the organization?

-

+

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) {

} - buttonText="Confirm remove user" + buttonText="Confirm Remove User" onConfirm={removeUser} string={selectedUser?.email ?? ""} - title="Remove user from organization" + title="Remove User from Organization" /> - Log out + Logout diff --git a/src/app/[orgId]/settings/general/layout.tsx b/src/app/[orgId]/settings/general/layout.tsx index 45702aea..ee413b9e 100644 --- a/src/app/[orgId]/settings/general/layout.tsx +++ b/src/app/[orgId]/settings/general/layout.tsx @@ -71,7 +71,6 @@ export default async function GeneralSettingsPage({ diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index c215337c..ec4fb729 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -46,13 +46,15 @@ export default function GeneralPage() { title="Delete organization" /> - {orgUser.isOwner ? ( - - ) : ( -

Nothing to see here

- )} +
+ {orgUser.isOwner ? ( + + ) : ( +

Nothing to see here

+ )} +
); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx new file mode 100644 index 00000000..53fb25be --- /dev/null +++ b/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(null); + + const [loading, setLoading] = useState(false); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { roles: [] }, + }); + + useEffect(() => { + api.get>( + `/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>( + `/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) { + 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 ( + <> +
+ + +
+ + ( + + Roles + + { + 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"} + /> + + + Users with these roles will be able to + access this resource. Admins can always + access this resource. + + + + )} + /> + + + +
+ + ); +} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/components/CustomDomainInput.tsx b/src/app/[orgId]/settings/resources/[resourceId]/components/CustomDomainInput.tsx index c21e3fae..29ae9639 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/components/CustomDomainInput.tsx +++ b/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) => { const newValue = event.target.value; diff --git a/src/app/[orgId]/settings/resources/[resourceId]/components/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/components/ResourceInfoBox.tsx new file mode 100644 index 00000000..f7176970 --- /dev/null +++ b/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 ( + + + + + Resource Information + + +

+ The current full URL for this resource is: +

+
+ + + {fullUrl} + + +
+ {/*
    +
  • + Protocol:{" "} + {protocol} +
  • +
  • + Subdomain:{" "} + {subdomain} +
  • +
  • + Domain:{" "} + {domain} +
  • +
*/} +
+
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index d5b97510..1d96d071 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -339,117 +339,109 @@ export default function ReverseProxyTargets(props: { return (
- {/*
*/} -
-
- +
+ -
- setSslEnabled(val)} - /> - -
+
+ setSslEnabled(val)} + /> +
-
- + -
- -
- ( - - IP Address - - - - - Enter the IP address of the - target - - - - )} - /> - ( - - Method - - - - - Choose the method for how the - target is accessed - - - - )} - /> - ( - - Port - - - - - Specify the port number for the - target - - - - )} - /> - {/* + +
+ ( + + IP Address + + + + + Enter the IP address of the target + + + + )} + /> + ( + + Method + + + + + Choose the method for how the target + is accessed + + + + )} + /> + ( + + Port + + + + + Specify the port number for the + target + + + + )} + /> + {/* ( @@ -486,15 +478,14 @@ export default function ReverseProxyTargets(props: { )} /> */} -
- - - -
+
+ + + -
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -540,9 +531,7 @@ export default function ReverseProxyTargets(props: {
-
-
diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index d3d1bb03..c17d1842 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/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; @@ -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([]); const [saveLoading, setSaveLoading] = useState(false); + const [domainSuffix, setDomainSuffix] = useState(org.org.domain); const form = useForm({ 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>( `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 ( <> -
+
- This is the display name of the - resource. + This is the display name of the resource )} /> + + + + ( + + Subdomain + + + form.setValue( + "subdomain", + value + ) + } + /> + + {/* + This is the subdomain that will be used + to access the resource + */} + + + )} + /> {/* >( `/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>( + `/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" /> - - - {children} - - + + + +
+ +
+ {children} +
+
+
); } diff --git a/src/app/[orgId]/settings/resources/components/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/components/CreateResourceForm.tsx index ae3ab111..bb3b1c6e 100644 --- a/src/app/[orgId]/settings/resources/components/CreateResourceForm.tsx +++ b/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; const defaultValues: Partial = { - 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([]); - const [domainSuffix, setDomainSuffix] = useState(".example.com"); + const [domainSuffix, setDomainSuffix] = useState(org.org.domain); const form = useForm({ resolver: zodResolver(accountFormSchema), @@ -193,9 +190,15 @@ export default function CreateResourceForm({ Subdomain + form.setValue( + "subdomain", + value + ) + } /> diff --git a/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx index 84dfae49..19208d1b 100644 --- a/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx @@ -196,10 +196,10 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {

} - buttonText="Confirm delete resource" + buttonText="Confirm Delete Resource" onConfirm={async () => deleteResource(selectedResource!.id)} string={selectedResource.name} - title="Delete resource" + title="Delete Resource" /> )} diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index ecec6cc0..fdd30487 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/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>( + `/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" /> - + + + ); } diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index f736b54f..4c6406fc 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -64,36 +64,38 @@ export default function GeneralPage() { return ( <> - +
+ - - - ( - - Name - - - - - This is the display name of the site - - - - )} - /> - - - +
+ + ( + + Name + + + + + This is the display name of the site + + + + )} + /> + + + +
); } diff --git a/src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx b/src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx index 6d5d886c..a27201ae 100644 --- a/src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx +++ b/src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx @@ -191,104 +191,109 @@ sh get-docker.sh`; -
- - ( - - Name - - - - - This is the name that will be - displayed for this site. - - - - )} - /> - ( - - Method - - - - - This is how you will connect - your site to Fossorial. - - - - )} - /> - -
- {form.watch("method") === "wg" && - !isLoading ? ( - - ) : form.watch("method") === "wg" && - isLoading ? ( -

- Loading WireGuard configuration... -

- ) : ( - - )} -
- - - You will only be able to see the - configuration once. - - -
- + + + ( + + Name + + + + + This is the name that will + be displayed for this site. + + + + )} /> - -
- - + ( + + Method + + + + + This is how you will connect + your site to Fossorial. + + + + )} + /> + +
+ {form.watch("method") === "wg" && + !isLoading ? ( + + ) : form.watch("method") === "wg" && + isLoading ? ( +

+ Loading WireGuard + configuration... +

+ ) : ( + + )} +
+ + + You will only be able to see the + configuration once. + + +
+ + +
+ + +