From 1a3d7705d9ccc14d13c30099a80b474681120644 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 10 Nov 2024 21:19:41 -0500 Subject: [PATCH 01/12] change user role --- server/routers/auth/verifyUserAccess.ts | 2 +- server/routers/user/addUserRole.ts | 11 +- .../components/AccessPageHeaderAndNav.tsx | 40 ++++ src/app/[orgId]/settings/access/layout.tsx | 36 +-- .../[orgId]/settings/access/roles/page.tsx | 10 +- .../users/[userId]/access-controls/page.tsx | 160 +++++++++++++ .../settings/access/users/[userId]/layout.tsx | 35 ++- .../settings/access/users/[userId]/page.tsx | 21 +- .../users/components/ManageUserForm.tsx | 226 ------------------ .../access/users/components/UsersTable.tsx | 4 +- .../[orgId]/settings/access/users/page.tsx | 14 +- src/contexts/orgUserContext.ts | 11 + src/hooks/useOrgUserContext.ts | 12 + src/providers/OrgUserProvider.tsx | 44 ++++ 14 files changed, 320 insertions(+), 306 deletions(-) create mode 100644 src/app/[orgId]/settings/access/components/AccessPageHeaderAndNav.tsx create mode 100644 src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx delete mode 100644 src/app/[orgId]/settings/access/users/components/ManageUserForm.tsx create mode 100644 src/contexts/orgUserContext.ts create mode 100644 src/hooks/useOrgUserContext.ts create mode 100644 src/providers/OrgUserProvider.tsx diff --git a/server/routers/auth/verifyUserAccess.ts b/server/routers/auth/verifyUserAccess.ts index 2aa73e69..f983f057 100644 --- a/server/routers/auth/verifyUserAccess.ts +++ b/server/routers/auth/verifyUserAccess.ts @@ -38,7 +38,7 @@ export async function verifyUserAccess( req.userOrg = res[0]; } - if (req.userOrg) { + if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/routers/user/addUserRole.ts b/server/routers/user/addUserRole.ts index 7cdb893a..81c7b07f 100644 --- a/server/routers/user/addUserRole.ts +++ b/server/routers/user/addUserRole.ts @@ -8,10 +8,11 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import stoi from "@server/utils/stoi"; const addUserRoleParamsSchema = z.object({ userId: z.string(), - roleId: z.number().int().positive(), + roleId: z.string().transform(stoi).pipe(z.number()), }); export type AddUserRoleResponse = z.infer; @@ -22,17 +23,17 @@ export async function addUserRole( next: NextFunction ): Promise { try { - const parsedBody = addUserRoleParamsSchema.safeParse(req.body); - if (!parsedBody.success) { + const parsedParams = addUserRoleParamsSchema.safeParse(req.params); + if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() + fromError(parsedParams.error).toString() ) ); } - const { userId, roleId } = parsedBody.data; + const { userId, roleId } = parsedParams.data; if (!req.userOrg) { return next( diff --git a/src/app/[orgId]/settings/access/components/AccessPageHeaderAndNav.tsx b/src/app/[orgId]/settings/access/components/AccessPageHeaderAndNav.tsx new file mode 100644 index 00000000..dfae7275 --- /dev/null +++ b/src/app/[orgId]/settings/access/components/AccessPageHeaderAndNav.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { SidebarSettings } from "@app/components/SidebarSettings"; + +type AccessPageHeaderAndNavProps = { + children: React.ReactNode; +}; + +export default function AccessPageHeaderAndNav({ + children, +}: AccessPageHeaderAndNavProps) { + const sidebarNavItems = [ + { + title: "Users", + href: `/{orgId}/settings/access/users`, + }, + { + title: "Roles", + href: `/{orgId}/settings/access/roles`, + }, + ]; + + return ( + <> + {" "} +
+

+ Users & Roles +

+

+ Invite users and add them to roles to manage access to your + organization +

+
+ + {children} + + + ); +} diff --git a/src/app/[orgId]/settings/access/layout.tsx b/src/app/[orgId]/settings/access/layout.tsx index fabd0380..2dd20177 100644 --- a/src/app/[orgId]/settings/access/layout.tsx +++ b/src/app/[orgId]/settings/access/layout.tsx @@ -1,40 +1,14 @@ -import { SidebarSettings } from "@app/components/SidebarSettings"; - interface AccessLayoutProps { children: React.ReactNode; - params: Promise<{ resourceId: number | string; orgId: string }>; + params: Promise<{ + resourceId: number | string; + orgId: string; + }>; } export default async function ResourceLayout(props: AccessLayoutProps) { const params = await props.params; const { children } = props; - const sidebarNavItems = [ - { - title: "Users", - href: `/{orgId}/settings/access/users`, - }, - { - title: "Roles", - href: `/{orgId}/settings/access/roles`, - }, - ]; - - return ( - <> -
-

- Users & Roles -

-

- Invite users and add them to roles to manage access to your - organization. -

-
- - - {children} - - - ); + return <>{children}; } diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx index 6ec5586d..0a665e28 100644 --- a/src/app/[orgId]/settings/access/roles/page.tsx +++ b/src/app/[orgId]/settings/access/roles/page.tsx @@ -6,6 +6,8 @@ import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import { ListRolesResponse } from "@server/routers/role"; import RolesTable, { RoleRow } from "./components/RolesTable"; +import { SidebarSettings } from "@app/components/SidebarSettings"; +import AccessPageHeaderAndNav from "../components/AccessPageHeaderAndNav"; type RolesPageProps = { params: Promise<{ orgId: string }>; @@ -49,9 +51,11 @@ export default async function RolesPage(props: RolesPageProps) { return ( <> - - - + + + + + ); } 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 new file mode 100644 index 00000000..e9d54c5a --- /dev/null +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -0,0 +1,160 @@ +"use client"; + +import api from "@app/api"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@app/components/ui/select"; +import { useToast } from "@app/hooks/useToast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { InviteUserResponse } from "@server/routers/user"; +import { AxiosResponse } from "axios"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { ListRolesResponse } from "@server/routers/role"; +import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; +import { useParams } from "next/navigation"; +import { Button } from "@app/components/ui/button"; + +const formSchema = z.object({ + email: z.string().email({ message: "Please enter a valid email" }), + roleId: z.string().min(1, { message: "Please select a role" }), +}); + +export default function AccessControlsPage() { + const { toast } = useToast(); + const { orgUser: user } = userOrgUserContext(); + + const { orgId } = useParams(); + + const [loading, setLoading] = useState(false); + const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: user.email!, + roleId: user.roleId?.toString(), + }, + }); + + useEffect(() => { + async function fetchRoles() { + const res = await api + .get>(`/org/${orgId}/roles`) + .catch((e) => { + console.error(e); + toast({ + variant: "destructive", + title: "Failed to fetch roles", + description: + e.message || + "An error occurred while fetching the roles", + }); + }); + + if (res?.status === 200) { + setRoles(res.data.data.roles); + } + } + + fetchRoles(); + + form.setValue("roleId", user.roleId.toString()); + }, []); + + async function onSubmit(values: z.infer) { + setLoading(true); + + const res = await api + .post>( + `/role/${values.roleId}/add/${user.userId}` + ) + .catch((e) => { + toast({ + variant: "destructive", + title: "Failed to add user to role", + description: + e.response?.data?.message || + "An error occurred while adding user to the role.", + }); + }); + + if (res && res.status === 200) { + toast({ + variant: "default", + title: "User invited", + description: "The user has been updated.", + }); + } + + setLoading(false); + } + + return ( + <> +
+

+ Access Controls +

+

+ Manage what this user can access and do in the organization +

+
+ +
+ + ( + + Role + + + + )} + /> + + + + + ); +} diff --git a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx index 82ccdc3e..b7abc544 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx @@ -1,11 +1,10 @@ -import SiteProvider from "@app/providers/SiteProvider"; import { internal } from "@app/api"; -import { GetSiteResponse } from "@server/routers/site"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/api/cookies"; import { SidebarSettings } from "@app/components/SidebarSettings"; import { GetOrgUserResponse } from "@server/routers/user"; +import OrgUserProvider from "@app/providers/OrgUserProvider"; interface UserLayoutProps { children: React.ReactNode; @@ -30,28 +29,28 @@ export default async function UserLayoutProps(props: UserLayoutProps) { const sidebarNavItems = [ { - title: "General", - href: "/{orgId}/settings/access/users/{userId}", + title: "Access Controls", + href: "/{orgId}/settings/access/users/{userId}/access-controls", }, ]; return ( <> -
-

- User {user?.email} -

-

- Manage user access and permissions -

-
+ +
+

+ User {user?.email} +

+

Manage user

+
- - {children} - + + {children} + +
); } diff --git a/src/app/[orgId]/settings/access/users/[userId]/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/page.tsx index 1c454ef4..195ce2a9 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/page.tsx @@ -1,20 +1,9 @@ -import React from "react"; -import { Separator } from "@/components/ui/separator"; +import { redirect } from "next/navigation"; export default async function UserPage(props: { - params: Promise<{ niceId: string }>; + params: Promise<{ orgId: string; userId: string }>; }) { - const params = await props.params; - - return ( -
-
-

Manage User

-

- Manage user access and permissions -

-
- -
- ); + const { orgId, userId } = await props.params; + redirect(`/${orgId}/settings/access/users/${userId}/access-controls`); + return <>; } diff --git a/src/app/[orgId]/settings/access/users/components/ManageUserForm.tsx b/src/app/[orgId]/settings/access/users/components/ManageUserForm.tsx deleted file mode 100644 index 49582f3c..00000000 --- a/src/app/[orgId]/settings/access/users/components/ManageUserForm.tsx +++ /dev/null @@ -1,226 +0,0 @@ -"use client"; - -import api from "@app/api"; -import { Button } from "@app/components/ui/button"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@app/components/ui/select"; -import { useToast } from "@app/hooks/useToast"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { InviteUserResponse, ListUsersResponse } from "@server/routers/user"; -import { AxiosResponse } from "axios"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle, -} from "@app/components/Credenza"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { ListRolesResponse } from "@server/routers/role"; -import { ArrayElement } from "@server/types/ArrayElement"; - -type ManageUserFormProps = { - open: boolean; - setOpen: (open: boolean) => void; - user: ArrayElement; - onUserUpdate(): ( - user: ArrayElement - ) => Promise; -}; - -const formSchema = z.object({ - email: z.string().email({ message: "Please enter a valid email" }), - roleId: z.string().min(1, { message: "Please select a role" }), -}); - -export default function ManageUserForm({ - open, - setOpen, - user, -}: ManageUserFormProps) { - const { toast } = useToast(); - const { org } = useOrgContext(); - - const [loading, setLoading] = useState(false); - const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - email: user.email, - roleId: user.roleId?.toString(), - }, - }); - - useEffect(() => { - if (!open) { - return; - } - - async function fetchRoles() { - const res = await api - .get>( - `/org/${org?.org.orgId}/roles` - ) - .catch((e) => { - console.error(e); - toast({ - variant: "destructive", - title: "Failed to fetch roles", - description: - e.message || - "An error occurred while fetching the roles", - }); - }); - - if (res?.status === 200) { - setRoles(res.data.data.roles); - // form.setValue( - // "roleId", - // res.data.data.roles[0].roleId.toString() - // ); - } - } - - fetchRoles(); - }, [open]); - - async function onSubmit(values: z.infer) { - setLoading(true); - - const res = await api - .post>( - `/role/${values.roleId}/add/${user.id}` - ) - .catch((e) => { - toast({ - variant: "destructive", - title: "Failed to add user to role", - description: - e.response?.data?.message || - "An error occurred while adding user to the role.", - }); - }); - - if (res && res.status === 200) { - toast({ - variant: "default", - title: "User invited", - description: "The user has been updated.", - }); - } - - setLoading(false); - } - - return ( - <> - { - setOpen(val); - setLoading(false); - form.reset(); - }} - > - - - Manage User - - Update the role of the user in the organization. - - - -
- - ( - - Email - - - - - - )} - /> - ( - - Role - - - - )} - /> - - -
- - - - - - -
-
- - ); -} diff --git a/src/app/[orgId]/settings/access/users/components/UsersTable.tsx b/src/app/[orgId]/settings/access/users/components/UsersTable.tsx index edd6d5ab..679b0010 100644 --- a/src/app/[orgId]/settings/access/users/components/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/components/UsersTable.tsx @@ -17,8 +17,8 @@ import { useUserContext } from "@app/hooks/useUserContext"; import api from "@app/api"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { useToast } from "@app/hooks/useToast"; -import ManageUserForm from "./ManageUserForm"; import Link from "next/link"; +import { useRouter } from "next/navigation"; export type UserRow = { id: string; @@ -39,6 +39,8 @@ export default function UsersTable({ users: u }: UsersTableProps) { const [users, setUsers] = useState(u); + const router = useRouter(); + const user = useUserContext(); const { org } = useOrgContext(); const { toast } = useToast(); diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index 68d85f5e..4c3ffe3b 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -8,6 +8,8 @@ import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import UserProvider from "@app/providers/UserProvider"; import { verifySession } from "@app/lib/auth/verifySession"; +import { SidebarSettings } from "@app/components/SidebarSettings"; +import AccessPageHeaderAndNav from "../components/AccessPageHeaderAndNav"; type UsersPageProps = { params: Promise<{ orgId: string }>; @@ -62,11 +64,13 @@ export default async function UsersPage(props: UsersPageProps) { return ( <> - - - - - + + + + + + + ); } diff --git a/src/contexts/orgUserContext.ts b/src/contexts/orgUserContext.ts new file mode 100644 index 00000000..8a000f09 --- /dev/null +++ b/src/contexts/orgUserContext.ts @@ -0,0 +1,11 @@ +import { GetOrgUserResponse } from "@server/routers/user"; +import { createContext } from "react"; + +interface OrgUserContext { + orgUser: GetOrgUserResponse; + updateOrgUser: (updateOrgUser: Partial) => void; +} + +const OrgUserContext = createContext(undefined); + +export default OrgUserContext; diff --git a/src/hooks/useOrgUserContext.ts b/src/hooks/useOrgUserContext.ts new file mode 100644 index 00000000..6de4a64d --- /dev/null +++ b/src/hooks/useOrgUserContext.ts @@ -0,0 +1,12 @@ +import OrgUserContext from "@app/contexts/orgUserContext"; +import { useContext } from "react"; + +export function userOrgUserContext() { + const context = useContext(OrgUserContext); + if (context === undefined) { + throw new Error( + "useOrgUserContext must be used within a OrgUserProvider" + ); + } + return context; +} diff --git a/src/providers/OrgUserProvider.tsx b/src/providers/OrgUserProvider.tsx new file mode 100644 index 00000000..1707cec0 --- /dev/null +++ b/src/providers/OrgUserProvider.tsx @@ -0,0 +1,44 @@ +"use client"; + +import OrgUserContext from "@app/contexts/orgUserContext"; +import { GetOrgUserResponse } from "@server/routers/user"; +import { useState } from "react"; + +interface OrgUserProviderProps { + children: React.ReactNode; + orgUser: GetOrgUserResponse | null; +} + +export function OrgUserProvider({ + children, + orgUser: serverOrgUser, +}: OrgUserProviderProps) { + const [orgUser, setOrgUser] = useState( + serverOrgUser + ); + + const updateOrgUser = (updateOrgUser: Partial) => { + if (!orgUser) { + throw new Error("No org to update"); + } + + setOrgUser((prev) => { + if (!prev) { + return prev; + } + + return { + ...prev, + ...updateOrgUser, + }; + }); + }; + + return ( + + {children} + + ); +} + +export default OrgUserProvider; From 22d9f6b37b23a9c66776d15a5cbe4d55fd006ac4 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 10 Nov 2024 21:52:50 -0500 Subject: [PATCH 02/12] move to new role before delete --- server/routers/role/deleteRole.ts | 48 +++- .../roles/components/DeleteRoleForm.tsx | 220 ++++++++++++++++++ .../access/roles/components/RolesTable.tsx | 71 ++---- 3 files changed, 275 insertions(+), 64 deletions(-) create mode 100644 src/app/[orgId]/settings/access/roles/components/DeleteRoleForm.tsx diff --git a/server/routers/role/deleteRole.ts b/server/routers/role/deleteRole.ts index 20cb98fe..12f8ff88 100644 --- a/server/routers/role/deleteRole.ts +++ b/server/routers/role/deleteRole.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { roles } from "@server/db/schema"; +import { roles, userOrgs } from "@server/db/schema"; import { eq } from "drizzle-orm"; import response from "@server/utils/response"; import HttpCode from "@server/types/HttpCode"; @@ -13,6 +13,10 @@ const deleteRoleSchema = z.object({ roleId: z.string().transform(Number).pipe(z.number().int().positive()), }); +const deelteRoleBodySchema = z.object({ + roleId: z.string().transform(Number).pipe(z.number().int().positive()), +}); + export async function deleteRole( req: Request, res: Response, @@ -29,7 +33,27 @@ export async function deleteRole( ); } + const parsedBody = deelteRoleBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + const { roleId } = parsedParams.data; + const { roleId: newRoleId } = parsedBody.data; + + if (roleId === newRoleId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Cannot delete a role and assign the same role` + ) + ); + } const role = await db .select() @@ -55,20 +79,30 @@ export async function deleteRole( ); } - const deletedRole = await db - .delete(roles) - .where(eq(roles.roleId, roleId)) - .returning(); + const newRole = await db + .select() + .from(roles) + .where(eq(roles.roleId, newRoleId)) + .limit(1); - if (deletedRole.length === 0) { + if (newRole.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, - `Role with ID ${roleId} not found` + `Role with ID ${newRoleId} not found` ) ); } + // move all users from the userOrgs table with roleId to newRoleId + await db + .update(userOrgs) + .set({ roleId: newRoleId }) + .where(eq(userOrgs.roleId, roleId)); + + // delete the old role + await db.delete(roles).where(eq(roles.roleId, roleId)); + return response(res, { data: null, success: true, diff --git a/src/app/[orgId]/settings/access/roles/components/DeleteRoleForm.tsx b/src/app/[orgId]/settings/access/roles/components/DeleteRoleForm.tsx new file mode 100644 index 00000000..706eb0a6 --- /dev/null +++ b/src/app/[orgId]/settings/access/roles/components/DeleteRoleForm.tsx @@ -0,0 +1,220 @@ +"use client"; + +import api from "@app/api"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@app/components/ui/form"; +import { useToast } from "@app/hooks/useToast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { AxiosResponse } from "axios"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle, +} from "@app/components/Credenza"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { ListRolesResponse } from "@server/routers/role"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@app/components/ui/select"; +import { RoleRow } from "./RolesTable"; + +type CreateRoleFormProps = { + open: boolean; + roleToDelete: RoleRow; + setOpen: (open: boolean) => void; + afterDelete?: () => void; +}; + +const formSchema = z.object({ + newRoleId: z.string({ message: "New role is required" }), +}); + +export default function DeleteRoleForm({ + open, + roleToDelete, + setOpen, + afterDelete, +}: CreateRoleFormProps) { + const { toast } = useToast(); + const { org } = useOrgContext(); + + const [loading, setLoading] = useState(false); + const [roles, setRoles] = useState([]); + + useEffect(() => { + async function fetchRoles() { + const res = await api + .get>( + `/org/${org?.org.orgId}/roles` + ) + .catch((e) => { + console.error(e); + toast({ + variant: "destructive", + title: "Failed to fetch roles", + description: + e.message || + "An error occurred while fetching the roles", + }); + }); + + if (res?.status === 200) { + setRoles( + res.data.data.roles.filter( + (r) => r.roleId !== roleToDelete.roleId + ) + ); + } + } + + fetchRoles(); + }, []); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + newRoleId: "", + }, + }); + + async function onSubmit(values: z.infer) { + setLoading(true); + + const res = await api + .delete(`/role/${roleToDelete.roleId}`, { + data: { + roleId: values.newRoleId, + }, + }) + .catch((e) => { + toast({ + variant: "destructive", + title: "Failed to remove role", + description: + e.response?.data?.message || + "An error occurred while removing the role.", + }); + }); + + if (res && res.status === 200) { + toast({ + variant: "default", + title: "Role removed", + description: "The role has been successfully removed.", + }); + + if (open) { + setOpen(false); + } + + if (afterDelete) { + afterDelete(); + } + } + + setLoading(false); + } + + return ( + <> + { + setOpen(val); + setLoading(false); + form.reset(); + }} + > + + + Remove Role + + Remove a role from the organization + + + +

+ 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 + + + + )} + /> + + +
+ + + + + + +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/access/roles/components/RolesTable.tsx b/src/app/[orgId]/settings/access/roles/components/RolesTable.tsx index a8218e23..7e39c3a2 100644 --- a/src/app/[orgId]/settings/access/roles/components/RolesTable.tsx +++ b/src/app/[orgId]/settings/access/roles/components/RolesTable.tsx @@ -17,6 +17,7 @@ import { useToast } from "@app/hooks/useToast"; import { RolesDataTable } from "./RolesDataTable"; import { Role } from "@server/db/schema"; import CreateRoleForm from "./CreateRoleForm"; +import DeleteRoleForm from "./DeleteRoleForm"; export type RoleRow = Role; @@ -97,35 +98,6 @@ export default function UsersTable({ roles: r }: RolesTableProps) { }, ]; - async function removeRole() { - if (roleToRemove) { - const res = await api - .delete(`/role/${roleToRemove.roleId}`) - .catch((e) => { - toast({ - variant: "destructive", - title: "Failed to remove role", - description: - e.message ?? - "An error occurred while removing the role.", - }); - }); - - if (res && res.status === 200) { - toast({ - variant: "default", - title: "Role removed", - description: `The role ${roleToRemove.name} has been removed from the organization.`, - }); - - setRoles((prev) => - prev.filter((role) => role.roleId !== roleToRemove.roleId) - ); - } - } - setIsDeleteModalOpen(false); - } - return ( <> - { - setIsDeleteModalOpen(val); - setUserToRemove(null); - }} - dialog={ -
-

- Are you sure you want to remove the role{" "} - {roleToRemove?.name} from the organization? -

- -

- You cannot undo this action. Please select a new - role to move existing users to after deletion. -

- -

- To confirm, please type the name of the role below. -

-
- } - buttonText="Confirm remove role" - onConfirm={removeRole} - string={roleToRemove?.name ?? ""} - title="Remove role from organization" - /> + {roleToRemove && ( + { + setRoles((prev) => + prev.filter((r) => r.roleId !== roleToRemove.roleId) + ); + setUserToRemove(null); + }} + /> + )} Date: Sun, 10 Nov 2024 22:29:20 -0500 Subject: [PATCH 03/12] create site modal --- server/routers/site/pickSiteDefaults.ts | 1 + .../settings/access/users/[userId]/layout.tsx | 21 ++ .../sites/components/CreateSiteForm.tsx | 294 ++++++++++++++++++ .../settings/sites/components/SitesTable.tsx | 212 ++++++------- src/components/ui/breadcrumb.tsx | 115 +++++++ 5 files changed, 539 insertions(+), 104 deletions(-) create mode 100644 src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx create mode 100644 src/components/ui/breadcrumb.tsx diff --git a/server/routers/site/pickSiteDefaults.ts b/server/routers/site/pickSiteDefaults.ts index 3e12ad61..cddddcea 100644 --- a/server/routers/site/pickSiteDefaults.ts +++ b/server/routers/site/pickSiteDefaults.ts @@ -76,6 +76,7 @@ export async function pickSiteDefaults( status: HttpCode.OK, }); } catch (error) { + throw error; logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") diff --git a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx index b7abc544..66bace60 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx @@ -5,6 +5,16 @@ import { authCookieHeader } from "@app/api/cookies"; import { SidebarSettings } from "@app/components/SidebarSettings"; import { GetOrgUserResponse } from "@server/routers/user"; import OrgUserProvider from "@app/providers/OrgUserProvider"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; interface UserLayoutProps { children: React.ReactNode; @@ -37,6 +47,17 @@ export default async function UserLayoutProps(props: UserLayoutProps) { return ( <> +
+ +
+ All Users +
+ +
+

User {user?.email} diff --git a/src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx b/src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx new file mode 100644 index 00000000..da3fe58f --- /dev/null +++ b/src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx @@ -0,0 +1,294 @@ +"use client"; + +import api from "@app/api"; +import { Button, buttonVariants } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { useToast } from "@app/hooks/useToast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { set, z } from "zod"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle, +} from "@app/components/Credenza"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { useParams, useRouter } from "next/navigation"; +import { PickSiteDefaultsResponse } from "@server/routers/site"; +import { generateKeypair } from "../[niceId]/components/wireguardConfig"; +import { cn } from "@app/lib/utils"; +import { ChevronDownIcon } from "lucide-react"; +import CopyTextBox from "@app/components/CopyTextBox"; +import { Checkbox } from "@app/components/ui/checkbox"; + +const method = [ + { label: "Wireguard", value: "wg" }, + { label: "Newt", value: "newt" }, +] as const; + +const accountFormSchema = z.object({ + name: z + .string() + .min(2, { + message: "Name must be at least 2 characters.", + }) + .max(30, { + message: "Name must not be longer than 30 characters.", + }), + method: z.enum(["wg", "newt"]), +}); + +type AccountFormValues = z.infer; + +const defaultValues: Partial = { + name: "", + method: "wg", +}; + +type CreateSiteFormProps = { + open: boolean; + setOpen: (open: boolean) => void; +}; + +export default function CreateSiteForm({ open, setOpen }: CreateSiteFormProps) { + const { toast } = useToast(); + + const [loading, setLoading] = useState(false); + + const params = useParams(); + const orgId = params.orgId; + const router = useRouter(); + + const [keypair, setKeypair] = useState<{ + publicKey: string; + privateKey: string; + } | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [isChecked, setIsChecked] = useState(false); + const [siteDefaults, setSiteDefaults] = + useState(null); + + const handleCheckboxChange = (checked: boolean) => { + setIsChecked(checked); + }; + + const form = useForm({ + resolver: zodResolver(accountFormSchema), + defaultValues, + }); + + useEffect(() => { + if (!open) return; + + if (typeof window !== "undefined") { + const generatedKeypair = generateKeypair(); + setKeypair(generatedKeypair); + setIsLoading(false); + + api.get(`/org/${orgId}/pick-site-defaults`) + .catch((e) => { + toast({ + title: "Error picking site defaults", + }); + }) + .then((res) => { + if (res && res.status === 200) { + setSiteDefaults(res.data.data); + } + }); + } + }, [open]); + + async function onSubmit(data: AccountFormValues) { + setLoading(true); + const res = await api + .put(`/org/${orgId}/site/`, { + name: data.name, + subnet: siteDefaults?.subnet, + exitNodeId: siteDefaults?.exitNodeId, + pubKey: keypair?.publicKey, + }) + .catch((e) => { + toast({ + title: "Error creating site", + }); + }); + + if (res && res.status === 201) { + const niceId = res.data.data.niceId; + // navigate to the site page + router.push(`/${orgId}/settings/sites/${niceId}`); + } + + setLoading(false); + } + + const wgConfig = + keypair && siteDefaults + ? `[Interface] +Address = ${siteDefaults.subnet} +ListenPort = 51820 +PrivateKey = ${keypair.privateKey} + +[Peer] +PublicKey = ${siteDefaults.publicKey} +AllowedIPs = ${siteDefaults.address.split("/")[0]}/32 +Endpoint = ${siteDefaults.endpoint}:${siteDefaults.listenPort} +PersistentKeepalive = 5` + : ""; + + const newtConfig = `curl -fsSL https://get.docker.com -o get-docker.sh +sh get-docker.sh`; + + return ( + <> + { + setOpen(val); + setLoading(false); + + // reset all values + form.reset(); + setIsChecked(false); + setKeypair(null); + setSiteDefaults(null); + }} + > + + + Create Site + + Create a new site to start connecting your resources + + + +
+ + ( + + 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... +

+ ) : ( + + )} +
+ +
+ + +
+ + +
+ + + + + + +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/sites/components/SitesTable.tsx b/src/app/[orgId]/settings/sites/components/SitesTable.tsx index 6634b41f..254f07e3 100644 --- a/src/app/[orgId]/settings/sites/components/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/components/SitesTable.tsx @@ -13,8 +13,9 @@ import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import api from "@app/api"; -import { authCookieHeader } from "@app/api/cookies"; import { AxiosResponse } from "axios"; +import { useState } from "react"; +import CreateSiteForm from "./CreateSiteForm"; export type SiteRow = { id: number; @@ -25,95 +26,6 @@ export type SiteRow = { orgId: string; }; -export const columns: ColumnDef[] = [ - { - accessorKey: "name", - header: ({ column }) => { - return ( - - ); - }, - }, - { - accessorKey: "nice", - header: ({ column }) => { - return ( - - ); - }, - }, - { - accessorKey: "mbIn", - header: "MB In", - }, - { - accessorKey: "mbOut", - header: "MB Out", - }, - { - id: "actions", - cell: ({ row }) => { - const router = useRouter(); - - const siteRow = row.original; - - const deleteSite = (siteId: number) => { - api.delete(`/site/${siteId}`) - .catch((e) => { - console.error("Error deleting site", e); - }) - .then(() => { - router.refresh(); - }); - }; - - return ( - - - - - - - - View settings - - - - - - - - ); - }, - }, -]; - type SitesTableProps = { sites: SiteRow[]; orgId: string; @@ -122,26 +34,118 @@ type SitesTableProps = { export default function SitesTable({ sites, orgId }: SitesTableProps) { const router = useRouter(); - const callApi = async () => { + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - const res = await api.put>( - `/newt` - ); + const callApi = async () => { + const res = await api.put>(`/newt`); console.log(res); - }; + const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + }, + }, + { + accessorKey: "nice", + header: ({ column }) => { + return ( + + ); + }, + }, + { + accessorKey: "mbIn", + header: "MB In", + }, + { + accessorKey: "mbOut", + header: "MB Out", + }, + { + id: "actions", + cell: ({ row }) => { + const router = useRouter(); + + const siteRow = row.original; + + const deleteSite = (siteId: number) => { + api.delete(`/site/${siteId}`) + .catch((e) => { + console.error("Error deleting site", e); + }) + .then(() => { + router.refresh(); + }); + }; + + return ( + + + + + + + + View settings + + + + + + + + ); + }, + }, + ]; + return ( <> - { - router.push(`/${orgId}/settings/sites/create`); - }} - /> - - + + { + // router.push(`/${orgId}/settings/sites/create`); + setIsCreateModalOpen(true); + }} + /> + + ); } diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..60e6c96f --- /dev/null +++ b/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>

+ + You will only be able to see the + configuration once. + +
{ - // router.push(`/${orgId}/settings/sites/create`); setIsCreateModalOpen(true); }} /> diff --git a/src/app/profile/account/account-form.tsx b/src/app/profile/account/account-form.tsx index a3f48044..25b22370 100644 --- a/src/app/profile/account/account-form.tsx +++ b/src/app/profile/account/account-form.tsx @@ -88,7 +88,7 @@ export function AccountForm() { return (
- + - + - +
-
+
diff --git a/src/app/profile/notifications/notifications-form.tsx b/src/app/profile/notifications/notifications-form.tsx index 0eb215b5..a5e8ab8c 100644 --- a/src/app/profile/notifications/notifications-form.tsx +++ b/src/app/profile/notifications/notifications-form.tsx @@ -60,7 +60,7 @@ export function NotificationsForm() { return ( - + - + - + - + - + - + - + Date: Mon, 11 Nov 2024 00:00:16 -0500 Subject: [PATCH 05/12] refactor resources --- .../components/CreateResource.tsx | 241 -------------- .../components/CustomDomainInput.tsx | 2 +- .../GeneralForm.tsx => general/page.tsx} | 2 +- .../resources/[resourceId]/layout.tsx | 44 +-- .../settings/resources/[resourceId]/page.tsx | 27 +- .../components/CreateResourceForm.tsx | 306 ++++++++++++++++++ .../resources/components/ResourcesTable.tsx | 197 +++++------ 7 files changed, 441 insertions(+), 378 deletions(-) delete mode 100644 src/app/[orgId]/settings/resources/[resourceId]/components/CreateResource.tsx rename src/app/[orgId]/settings/resources/[resourceId]/{components/GeneralForm.tsx => general/page.tsx} (99%) create mode 100644 src/app/[orgId]/settings/resources/components/CreateResourceForm.tsx diff --git a/src/app/[orgId]/settings/resources/[resourceId]/components/CreateResource.tsx b/src/app/[orgId]/settings/resources/[resourceId]/components/CreateResource.tsx deleted file mode 100644 index eaba41c9..00000000 --- a/src/app/[orgId]/settings/resources/[resourceId]/components/CreateResource.tsx +++ /dev/null @@ -1,241 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { - CaretSortIcon, - CheckIcon, -} from "@radix-ui/react-icons"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { cn } from "@/lib/utils"; -import { toast } from "@/hooks/useToast"; -import { Button} from "@/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import React, { useState, useEffect } from "react"; -import { api } from "@/api"; -import { useParams } from "next/navigation"; -import { useRouter } from "next/navigation"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { ListSitesResponse } from "@server/routers/site"; -import { AxiosResponse } from "axios"; -import CustomDomainInput from "./CustomDomainInput"; - -const method = [ - { label: "Wireguard", value: "wg" }, - { label: "Newt", value: "newt" }, -] as const; - -const accountFormSchema = z.object({ - subdomain: z - .string() - .min(2, { - message: "Name must be at least 2 characters.", - }) - .max(30, { - message: "Name must not be longer than 30 characters.", - }), - name: z.string(), - siteId: z.number(), -}); - -type AccountFormValues = z.infer; - -const defaultValues: Partial = { - subdomain: "someanimalherefromapi", - name: "My Resource", -}; - -export function CreateResourceForm() { - const params = useParams(); - const orgId = params.orgId; - const router = useRouter(); - - const [sites, setSites] = useState([]); - const [domainSuffix, setDomainSuffix] = useState(".example.com"); - - const form = useForm({ - resolver: zodResolver(accountFormSchema), - defaultValues, - }); - - useEffect(() => { - if (typeof window !== "undefined") { - const fetchSites = async () => { - const res = await api.get>( - `/org/${orgId}/sites/` - ); - setSites(res.data.data.sites); - }; - fetchSites(); - } - }, []); - - async function onSubmit(data: AccountFormValues) { - console.log(data); - - const res = await api - .put(`/org/${orgId}/site/${data.siteId}/resource/`, { - name: data.name, - subdomain: data.subdomain, - // subdomain: data.subdomain, - }) - .catch((e) => { - toast({ - title: "Error creating resource...", - }); - }); - - if (res && res.status === 201) { - const niceId = res.data.data.niceId; - // navigate to the resource page - router.push(`/${orgId}/settings/resources/${niceId}`); - } - } - - return ( - <> - - - ( - - Name - - - - - This is the name that will be displayed for - this resource. - - - - )} - /> - ( - - Subdomain - - {/* */} - - - - This is the fully qualified domain name that - will be used to access the resource. - - - - )} - /> - ( - - Site - - - - - - - - - - - - No site found. - - - {sites.map((site) => ( - { - form.setValue( - "siteId", - site.siteId - ); - }} - > - - {site.name} - - ))} - - - - - - - This is the site that will be used in the - dashboard. - - - - )} - /> - - - - - - ); -} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/components/CustomDomainInput.tsx b/src/app/[orgId]/settings/resources/[resourceId]/components/CustomDomainInput.tsx index b7b9c2ac..c21e3fae 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/components/CustomDomainInput.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/components/CustomDomainInput.tsx @@ -29,7 +29,7 @@ export default function CustomDomainInput( }; return ( -
+
; -export function GeneralForm() { +export default function GeneralForm() { const params = useParams(); const orgId = params.orgId; const { resource, updateResource } = useResourceContext(); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx index c7ae77d1..b08b3cb9 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx @@ -5,6 +5,8 @@ import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/api/cookies"; import { SidebarSettings } from "@app/components/SidebarSettings"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; interface ResourceLayoutProps { children: React.ReactNode; @@ -18,22 +20,20 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { let resource = null; - if (params.resourceId !== "create") { - try { - const res = await internal.get>( - `/resource/${params.resourceId}`, - await authCookieHeader() - ); - resource = res.data.data; - } catch { - redirect(`/${params.orgId}/settings/resources`); - } + try { + const res = await internal.get>( + `/resource/${params.resourceId}`, + await authCookieHeader() + ); + resource = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/resources`); } const sidebarNavItems = [ { title: "General", - href: `/{orgId}/settings/resources/resourceId`, + href: `/{orgId}/settings/resources/{resourceId}/general`, }, { title: "Targets", @@ -41,27 +41,31 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { }, ]; - const isCreate = params.resourceId === "create"; - return ( <> +
+ +
+ All Resources +
+ +
+

- {isCreate ? "New Resource" : resource?.name + " Settings"} + {resource?.name + " Settings"}

- {isCreate - ? "Create a new resource" - : "Configure the settings on your resource: " + - resource?.name || ""} - . + Configure the settings on your resource

{children} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/page.tsx index 9598ffcb..1751d779 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/page.tsx @@ -1,29 +1,10 @@ -import React from "react"; -import { Separator } from "@/components/ui/separator"; -import { CreateResourceForm } from "./components/CreateResource"; -import { GeneralForm } from "./components/GeneralForm"; +import { redirect } from "next/navigation"; export default async function ResourcePage(props: { - params: Promise<{ resourceId: number | string }>; + params: Promise<{ resourceId: number | string; orgId: string }>; }) { const params = await props.params; - const isCreate = params.resourceId === "create"; - - return ( -
-
-

- {isCreate ? "Create Resource" : "General"} -

-

- {isCreate - ? "Create a new resource" - : "Edit basic resource settings"} -

-
- - - {isCreate ? : } -
+ redirect( + `/${params.orgId}/settings/resources/${params.resourceId}/general` ); } diff --git a/src/app/[orgId]/settings/resources/components/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/components/CreateResourceForm.tsx new file mode 100644 index 00000000..93cca75f --- /dev/null +++ b/src/app/[orgId]/settings/resources/components/CreateResourceForm.tsx @@ -0,0 +1,306 @@ +"use client"; + +import api from "@app/api"; +import { Button, buttonVariants } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { useToast } from "@app/hooks/useToast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle, +} from "@app/components/Credenza"; +import { useParams, useRouter } from "next/navigation"; +import { ListSitesResponse } from "@server/routers/site"; +import { cn } from "@app/lib/utils"; +import { CheckIcon } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@app/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@app/components/ui/command"; +import { CaretSortIcon } from "@radix-ui/react-icons"; +import CustomDomainInput from "../[resourceId]/components/CustomDomainInput"; +import { Axios, AxiosResponse } from "axios"; +import { Resource } from "@server/db/schema"; + +const accountFormSchema = z.object({ + subdomain: z + .string() + .min(2, { + message: "Name must be at least 2 characters.", + }) + .max(30, { + message: "Name must not be longer than 30 characters.", + }), + name: z.string(), + siteId: z.number(), +}); + +type AccountFormValues = z.infer; + +const defaultValues: Partial = { + subdomain: "someanimalherefromapi", + name: "My Resource", +}; + +type CreateResourceFormProps = { + open: boolean; + setOpen: (open: boolean) => void; +}; + +export default function CreateResourceForm({ + open, + setOpen, +}: CreateResourceFormProps) { + const { toast } = useToast(); + + const [loading, setLoading] = useState(false); + const params = useParams(); + + const orgId = params.orgId; + const router = useRouter(); + + const [sites, setSites] = useState([]); + const [domainSuffix, setDomainSuffix] = useState(".example.com"); + + const form = useForm({ + resolver: zodResolver(accountFormSchema), + defaultValues, + }); + + useEffect(() => { + if (!open) { + return; + } + + const fetchSites = async () => { + const res = await api.get>( + `/org/${orgId}/sites/` + ); + setSites(res.data.data.sites); + }; + + fetchSites(); + }, [open]); + + async function onSubmit(data: AccountFormValues) { + console.log(data); + + const res = await api + .put>( + `/org/${orgId}/site/${data.siteId}/resource/`, + { + name: data.name, + subdomain: data.subdomain, + // subdomain: data.subdomain, + } + ) + .catch((e) => { + toast({ + title: "Error creating resource", + }); + }); + + if (res && res.status === 201) { + const id = res.data.data.resourceId; + // navigate to the resource page + router.push(`/${orgId}/settings/resources/${id}`); + } + } + + return ( + <> + { + setOpen(val); + setLoading(false); + + // reset all values + form.reset(); + }} + > + + + Create Resource + + Create a new resource to proxy requests to your app + + + +
+ + ( + + Name + + + + + This is the name that will be + displayed for this resource. + + + + )} + /> + ( + + Subdomain + + + + + This is the fully qualified + domain name that will be used to + access the resource. + + + + )} + /> + ( + + Site + + + + + + + + + + + + No site found. + + + {sites.map( + (site) => ( + { + form.setValue( + "siteId", + site.siteId + ); + }} + > + + { + site.name + } + + ) + )} + + + + + + + This is the site that will be + used in the dashboard. + + + + )} + /> + + +
+ + + + + + +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx index 63d2ec3e..f07d1e54 100644 --- a/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx @@ -13,6 +13,8 @@ import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import api from "@app/api"; +import CreateResourceForm from "./CreateResourceForm"; +import { useState } from "react"; export type ResourceRow = { id: number; @@ -22,91 +24,6 @@ export type ResourceRow = { site: string; }; -export const columns: ColumnDef[] = [ - { - accessorKey: "name", - header: ({ column }) => { - return ( - - ); - }, - }, - { - accessorKey: "site", - header: ({ column }) => { - return ( - - ); - }, - }, - { - accessorKey: "domain", - header: "Domain", - }, - { - id: "actions", - cell: ({ row }) => { - const router = useRouter(); - - const resourceRow = row.original; - - const deleteResource = (resourceId: number) => { - api.delete(`/resource/${resourceId}`) - .catch((e) => { - console.error("Error deleting resource", e); - }) - .then(() => { - router.refresh(); - }); - }; - - return ( - - - - - - - - View settings - - - - - - - - ); - }, - }, -]; - type ResourcesTableProps = { resources: ResourceRow[]; orgId: string; @@ -115,13 +32,109 @@ type ResourcesTableProps = { export default function SitesTable({ resources, orgId }: ResourcesTableProps) { const router = useRouter(); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + + const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + }, + }, + { + accessorKey: "site", + header: ({ column }) => { + return ( + + ); + }, + }, + { + accessorKey: "domain", + header: "Domain", + }, + { + id: "actions", + cell: ({ row }) => { + const router = useRouter(); + + const resourceRow = row.original; + + const deleteResource = (resourceId: number) => { + api.delete(`/resource/${resourceId}`) + .catch((e) => { + console.error("Error deleting resource", e); + }) + .then(() => { + router.refresh(); + }); + }; + + return ( + + + + + + + + View settings + + + + + + + + ); + }, + }, + ]; + return ( - { - router.push(`/${orgId}/settings/resources/create`); - }} - /> + <> + + + { + setIsCreateModalOpen(true); + }} + /> + ); } From 93ea7e462021d65fbf628d2768898bf2c21eb545 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Mon, 11 Nov 2024 23:00:51 -0500 Subject: [PATCH 06/12] add delete confirm modal to resources and sites --- src/app/[orgId]/settings/general/layout.tsx | 85 ++++++++ src/app/[orgId]/settings/general/page.tsx | 58 +++++ .../resources/[resourceId]/general/page.tsx | 202 ++++++++++-------- .../resources/[resourceId]/targets/page.tsx | 29 +-- .../resources/components/ResourcesTable.tsx | 70 ++++-- .../settings/sites/components/SitesTable.tsx | 66 +++++- 6 files changed, 379 insertions(+), 131 deletions(-) create mode 100644 src/app/[orgId]/settings/general/layout.tsx create mode 100644 src/app/[orgId]/settings/general/page.tsx diff --git a/src/app/[orgId]/settings/general/layout.tsx b/src/app/[orgId]/settings/general/layout.tsx new file mode 100644 index 00000000..f5ea8c72 --- /dev/null +++ b/src/app/[orgId]/settings/general/layout.tsx @@ -0,0 +1,85 @@ +import { internal } from "@app/api"; +import { authCookieHeader } from "@app/api/cookies"; +import { SidebarSettings } from "@app/components/SidebarSettings"; +import { verifySession } from "@app/lib/auth/verifySession"; +import OrgProvider from "@app/providers/OrgProvider"; +import OrgUserProvider from "@app/providers/OrgUserProvider"; +import { GetOrgResponse } from "@server/routers/org"; +import { GetOrgUserResponse } from "@server/routers/user"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { cache } from "react"; + +type GeneralSettingsProps = { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +}; + +export default async function GeneralSettingsPage({ + children, + params, +}: GeneralSettingsProps) { + const { orgId } = await params; + + const getUser = cache(verifySession); + const user = await getUser(); + + if (!user) { + redirect("/auth/login"); + } + + let orgUser = null; + try { + const getOrgUser = cache(async () => + internal.get>( + `/org/${orgId}/user/${user.userId}`, + await authCookieHeader() + ) + ); + const res = await getOrgUser(); + orgUser = res.data.data; + } catch { + redirect(`/${orgId}`); + } + + let org = null; + try { + const getOrg = cache(async () => + internal.get>( + `/org/${orgId}`, + await authCookieHeader() + ) + ); + const res = await getOrg(); + org = res.data.data; + } catch { + redirect(`/${orgId}`); + } + + const sidebarNavItems = [ + { + title: "General", + href: `/{orgId}/settings/general`, + }, + ]; + + return ( + <> + + +
+

+ General +

+

+ Configure your organization's general settings +

+
+ + {children} + +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx new file mode 100644 index 00000000..c215337c --- /dev/null +++ b/src/app/[orgId]/settings/general/page.tsx @@ -0,0 +1,58 @@ +"use client"; + +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { Button } from "@app/components/ui/button"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; +import { useState } from "react"; + +export default function GeneralPage() { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const { orgUser } = userOrgUserContext(); + const { org } = useOrgContext(); + + async function deleteOrg() { + console.log("not implemented"); + } + + return ( + <> + { + setIsDeleteModalOpen(val); + }} + dialog={ +
+

+ Are you sure you want to delete the organization{" "} + {org?.org.name}? +

+ +

+ This action is irreversible and will delete all + associated data. +

+ +

+ To confirm, type the name of the organization below. +

+
+ } + buttonText="Confirm delete organization" + onConfirm={deleteOrg} + string={org?.org.name || ""} + title="Delete organization" + /> + + {orgUser.isOwner ? ( + + ) : ( +

Nothing to see here

+ )} + + ); +} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 4814e34b..a745d52a 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -96,99 +96,113 @@ export default function GeneralForm() { } return ( -
- - ( - - Name - - - - - This is the display name of the resource. - - - - )} - /> - ( - - Site - - - - - - - - - - - - No site found. - - - {sites.map((site) => ( - { - form.setValue( - "siteId", - site.siteId - ); - }} - > - - {site.name} - - ))} - - - - - - - This is the site that will be used in the - dashboard. - - - - )} - /> - - - + <> +
+

+ General Settings +

+

+ Configure the general settings for this resource +

+
+ +
+ + ( + + Name + + + + + This is the display name of the resource. + + + + )} + /> + ( + + Site + + + + + + + + + + + + No site found. + + + {sites.map((site) => ( + { + form.setValue( + "siteId", + site.siteId + ); + }} + > + + {site.name} + + ))} + + + + + + + This is the site that will be used in the + dashboard. + + + + )} + /> + + + + ); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/targets/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/targets/page.tsx index 9a108941..ed763bf5 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/targets/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/targets/page.tsx @@ -25,11 +25,9 @@ const isValidIPAddress = (ip: string) => { return ipv4Regex.test(ip); }; -export default function ReverseProxyTargets( - props: { - params: Promise<{ resourceId: number }>; - } -) { +export default function ReverseProxyTargets(props: { + params: Promise<{ resourceId: number }>; +}) { const params = use(props.params); const [targets, setTargets] = useState([]); const [nextId, setNextId] = useState(1); @@ -39,7 +37,7 @@ export default function ReverseProxyTargets( if (typeof window !== "undefined") { const fetchSites = async () => { const res = await api.get>( - `/resource/${params.resourceId}/targets`, + `/resource/${params.resourceId}/targets` ); setTargets(res.data.data.targets); }; @@ -93,7 +91,7 @@ export default function ReverseProxyTargets( }) .then((res) => { setTargets( - targets.filter((target) => target.targetId !== targetId), + targets.filter((target) => target.targetId !== targetId) ); }); }; @@ -103,8 +101,8 @@ export default function ReverseProxyTargets( targets.map((target) => target.targetId === targetId ? { ...target, enabled: !target.enabled } - : target, - ), + : target + ) ); api.post(`/target/${targetId}`, { enabled: !targets.find((target) => target.targetId === targetId) @@ -115,7 +113,14 @@ export default function ReverseProxyTargets( }; return ( -
+
+
+

Targets

+

+ Setup the targets for the reverse proxy +

+
+
{ e.preventDefault(); @@ -192,9 +197,7 @@ export default function ReverseProxyTargets(
- +
diff --git a/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx index f07d1e54..7e431f20 100644 --- a/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx @@ -15,6 +15,8 @@ import { useRouter } from "next/navigation"; import api from "@app/api"; import CreateResourceForm from "./CreateResourceForm"; import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { set } from "zod"; export type ResourceRow = { id: number; @@ -33,6 +35,20 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { const router = useRouter(); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedResource, setSelectedResource] = + useState(); + + const deleteResource = (resourceId: number) => { + api.delete(`/resource/${resourceId}`) + .catch((e) => { + console.error("Error deleting resource", e); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + }); + }; const columns: ColumnDef[] = [ { @@ -78,16 +94,6 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { const resourceRow = row.original; - const deleteResource = (resourceId: number) => { - api.delete(`/resource/${resourceId}`) - .catch((e) => { - console.error("Error deleting resource", e); - }) - .then(() => { - router.refresh(); - }); - }; - return ( @@ -106,9 +112,10 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { + ), + }, + ]; + + const table = useReactTable({ + data: targets, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); + + return ( +
+ {/*
*/} +
+
+ + +
+ + +
+
+ +
+ + +
+ +
+ ( + + IP Address + + + + + Enter the IP address of the + target + + + + )} + /> + ( + + Method + + + + + Choose the method for the target + connection + + + + )} + /> + ( + + Port + + + + + Specify the port number for the + target + + + + )} + /> + ( + + Protocol + + + + + Select the protocol used by the + target + + + + )} + /> +
+ +
+ +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index a745d52a..d8cf656c 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -97,112 +97,119 @@ export default function GeneralForm() { return ( <> -
-

- General Settings -

-

- Configure the general settings for this resource -

-
+
+
+

+ General Settings +

+

+ Configure the general settings for this resource +

+
-
- - ( - - Name - - - - - This is the display name of the resource. - - - - )} - /> - ( - - Site - - - - - - - - - - - - No site found. - - - {sites.map((site) => ( - { - form.setValue( - "siteId", + + + ( + + Name + + + + + This is the display name of the + resource. + + + + )} + /> + ( + + Site + + + + + + + + + + + + No site found. + + + {sites.map((site) => ( + - - {site.name} - - ))} - - - - - - - This is the site that will be used in the - dashboard. - - - - )} - /> - - - + } + onSelect={() => { + form.setValue( + "siteId", + site.siteId + ); + }} + > + + {site.name} + + ))} + + + + + + + This is the site that will be used in + the dashboard. + + + + )} + /> + + + +
); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx index b08b3cb9..18a9f445 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx @@ -36,8 +36,12 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { href: `/{orgId}/settings/resources/{resourceId}/general`, }, { - title: "Targets", - href: `/{orgId}/settings/resources/{resourceId}/targets`, + title: "Connectivity", + href: `/{orgId}/settings/resources/{resourceId}/connectivity`, + }, + { + title: "Authentication", + href: `/{orgId}/settings/resources/{resourceId}/authentication`, }, ]; @@ -66,7 +70,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { {children} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/page.tsx index 1751d779..8eb27e4e 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/page.tsx @@ -5,6 +5,6 @@ export default async function ResourcePage(props: { }) { const params = await props.params; redirect( - `/${params.orgId}/settings/resources/${params.resourceId}/general` + `/${params.orgId}/settings/resources/${params.resourceId}/connectivity` ); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/targets/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/targets/page.tsx deleted file mode 100644 index ed763bf5..00000000 --- a/src/app/[orgId]/settings/resources/[resourceId]/targets/page.tsx +++ /dev/null @@ -1,275 +0,0 @@ -"use client"; - -import { useEffect, useState, use } from "react"; -import { PlusCircle, Trash2, Server, Globe, Cpu } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Badge } from "@/components/ui/badge"; -import api from "@app/api"; -import { AxiosResponse } from "axios"; -import { ListTargetsResponse } from "@server/routers/target/listTargets"; - -const isValidIPAddress = (ip: string) => { - const ipv4Regex = - /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; - return ipv4Regex.test(ip); -}; - -export default function ReverseProxyTargets(props: { - params: Promise<{ resourceId: number }>; -}) { - const params = use(props.params); - const [targets, setTargets] = useState([]); - const [nextId, setNextId] = useState(1); - const [ipError, setIpError] = useState(""); - - useEffect(() => { - if (typeof window !== "undefined") { - const fetchSites = async () => { - const res = await api.get>( - `/resource/${params.resourceId}/targets` - ); - setTargets(res.data.data.targets); - }; - fetchSites(); - } - }, []); - - const [newTarget, setNewTarget] = useState({ - resourceId: params.resourceId, - ip: "", - method: "http", - port: 80, - protocol: "TCP", - }); - - const addTarget = () => { - if (!isValidIPAddress(newTarget.ip)) { - setIpError("Invalid IP address format"); - return; - } - setIpError(""); - - api.put(`/resource/${params.resourceId}/target`, { - ...newTarget, - resourceId: undefined, - }) - .catch((err) => { - console.error(err); - }) - .then((res) => { - // console.log(res) - setTargets([ - ...targets, - { ...newTarget, targetId: nextId, enabled: true }, - ]); - setNextId(nextId + 1); - setNewTarget({ - resourceId: params.resourceId, - ip: "", - method: "GET", - port: 80, - protocol: "http", - }); - }); - }; - - const removeTarget = (targetId: number) => { - api.delete(`/target/${targetId}`) - .catch((err) => { - console.error(err); - }) - .then((res) => { - setTargets( - targets.filter((target) => target.targetId !== targetId) - ); - }); - }; - - const toggleTarget = (targetId: number) => { - setTargets( - targets.map((target) => - target.targetId === targetId - ? { ...target, enabled: !target.enabled } - : target - ) - ); - api.post(`/target/${targetId}`, { - enabled: !targets.find((target) => target.targetId === targetId) - ?.enabled, - }).catch((err) => { - console.error(err); - }); - }; - - return ( -
-
-

Targets

-

- Setup the targets for the reverse proxy -

-
- -
{ - e.preventDefault(); - addTarget(); - }} - className="space-y-4" - > -
-
- - { - setNewTarget({ - ...newTarget, - ip: e.target.value, - }); - setIpError(""); - }} - required - /> - {ipError && ( -

{ipError}

- )} -
-
- - -
-
- - - setNewTarget({ - ...newTarget, - port: parseInt(e.target.value), - }) - } - required - /> -
-
- - -
-
- -
- -
- {targets.map((target, i) => ( - - - - - Target {target.targetId} - -
- - toggleTarget(target.targetId) - } - /> - -
-
- -
-
- - - {target.ip}:{target.port} - -
-
- - - {target.resourceId} - -
-
- - {target.method} - - - {target.protocol?.toUpperCase()} - -
-
-
-
- ))} -
-
- ); -} diff --git a/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx index 7e431f20..e262fef5 100644 --- a/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx @@ -9,7 +9,7 @@ import { DropdownMenuTrigger, } from "@app/components/ui/dropdown-menu"; import { Button } from "@app/components/ui/button"; -import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import api from "@app/api"; @@ -95,34 +95,56 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { const resourceRow = row.original; return ( - - - + + + + + View settings + + + + + + + + - - - - - View settings - - - - - - - +
+ ); }, }, diff --git a/src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx b/src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx index 10a864c6..098194c2 100644 --- a/src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx +++ b/src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx @@ -16,7 +16,7 @@ import { useToast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; -import { set, z } from "zod"; +import { z } from "zod"; import { Credenza, CredenzaBody, @@ -31,10 +31,15 @@ import { useOrgContext } from "@app/hooks/useOrgContext"; import { useParams, useRouter } from "next/navigation"; import { PickSiteDefaultsResponse } from "@server/routers/site"; import { generateKeypair } from "../[niceId]/components/wireguardConfig"; -import { cn } from "@app/lib/utils"; -import { ChevronDownIcon } from "lucide-react"; import CopyTextBox from "@app/components/CopyTextBox"; import { Checkbox } from "@app/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@app/components/ui/select"; const method = [ { label: "Wireguard", value: "wg" }, @@ -213,28 +218,26 @@ sh get-docker.sh`; render={({ field }) => ( Method -
- - + + + + + WireGuard - - + Newt - - - - -
+ + + + This is how you will connect your site to Fossorial. diff --git a/src/app/[orgId]/settings/sites/components/SitesTable.tsx b/src/app/[orgId]/settings/sites/components/SitesTable.tsx index 8be7fcb7..44e92441 100644 --- a/src/app/[orgId]/settings/sites/components/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/components/SitesTable.tsx @@ -9,7 +9,7 @@ import { DropdownMenuTrigger, } from "@app/components/ui/dropdown-menu"; import { Button } from "@app/components/ui/button"; -import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import api from "@app/api"; @@ -104,34 +104,47 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { const siteRow = row.original; return ( - - - - - - - - View settings - - - - - - - +
+ + + + + + + + View settings + + + + + + + + +
); }, }, @@ -188,7 +201,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { setIsCreateModalOpen(true); }} /> - + {/* */} ); } diff --git a/src/app/globals.css b/src/app/globals.css index db3635e1..05b6167e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -38,7 +38,7 @@ --chart-4: 23.33 8.82% 60%; --chart-5: 24 8.98% 67.25%; - --radius: 0.75rem; + --radius: 0.35rem; } .dark { --background: 0 0% 11.76%; @@ -75,7 +75,7 @@ --chart-4: 23.33 23.68% 14.9%; --chart-5: 24 23.81% 12.35%; - --radius: 0.75rem; + --radius: 0.35rem; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9ce33521..0567f402 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; -import { Inter } from "next/font/google"; +import { Fira_Sans, Inter } from "next/font/google"; import { Toaster } from "@/components/ui/toaster"; import { ThemeProvider } from "@app/providers/ThemeProvider"; diff --git a/src/components/SettingsSectionTitle.tsx b/src/components/SettingsSectionTitle.tsx new file mode 100644 index 00000000..733c8501 --- /dev/null +++ b/src/components/SettingsSectionTitle.tsx @@ -0,0 +1,16 @@ +type SettingsSectionTitleProps = { + title: string | React.ReactNode; + description: string | React.ReactNode; +}; + +export default function SettingsSectionTitle({ + title, + description, +}: SettingsSectionTitleProps) { + return ( +
+

{title}

+

{description}

+
+ ); +} diff --git a/src/components/sidebar-nav.tsx b/src/components/sidebar-nav.tsx index df08cd57..945a849d 100644 --- a/src/components/sidebar-nav.tsx +++ b/src/components/sidebar-nav.tsx @@ -59,9 +59,9 @@ export function SidebarNav({
- Choose the method for the target - connection + Choose the method for how the + target is accessed @@ -422,7 +513,9 @@ export default function ReverseProxyTargets(props: { )} />
- +
@@ -466,7 +559,7 @@ export default function ReverseProxyTargets(props: { colSpan={columns.length} className="h-24 text-center" > - No results. + No targets. Add a target using the form. )} @@ -474,6 +567,10 @@ 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 d8cf656c..b865e126 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { cn } from "@/lib/utils"; +import { cn, formatAxiosError } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Form, @@ -38,6 +38,7 @@ import { useParams } from "next/navigation"; import { useForm } from "react-hook-form"; import { GetResourceResponse } from "@server/routers/resource"; import { useToast } from "@app/hooks/useToast"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; const GeneralFormSchema = z.object({ name: z.string(), @@ -48,64 +49,71 @@ type GeneralFormValues = z.infer; export default function GeneralForm() { const params = useParams(); - const orgId = params.orgId; - const { resource, updateResource } = useResourceContext(); - const [sites, setSites] = useState([]); const { toast } = useToast(); + const { resource, updateResource } = useResourceContext(); + + const orgId = params.orgId; + + const [sites, setSites] = useState([]); + const [saveLoading, setSaveLoading] = useState(false); const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { - name: resource?.name, - siteId: resource?.siteId, + name: resource.name, + siteId: resource.siteId!, }, mode: "onChange", }); useEffect(() => { - if (typeof window !== "undefined") { - const fetchSites = async () => { - const res = await api.get>( - `/org/${orgId}/sites/` - ); - setSites(res.data.data.sites); - }; - fetchSites(); - } + const fetchSites = async () => { + const res = await api.get>( + `/org/${orgId}/sites/` + ); + setSites(res.data.data.sites); + }; + fetchSites(); }, []); async function onSubmit(data: GeneralFormValues) { + setSaveLoading(true); updateResource({ name: data.name, siteId: data.siteId }); - await api - .post>( - `resource/${resource?.resourceId}`, - { - name: data.name, - siteId: data.siteId, - } - ) + + api.post>( + `resource/${resource?.resourceId}`, + { + name: data.name, + siteId: data.siteId, + } + ) .catch((e) => { toast({ variant: "destructive", title: "Failed to update resource", - description: - e.response?.data?.message || - "An error occurred while updating the resource", + description: formatAxiosError( + e, + "An error occurred while updating the resource" + ), }); - }); + }) + .then(() => { + toast({ + title: "Resource updated", + description: "The resource has been updated successfully", + }); + }) + .finally(() => setSaveLoading(false)); } return ( <>
-
-

- General Settings -

-

- Configure the general settings for this resource -

-
+
- + - No site found. + No sites found. {sites.map((site) => ( @@ -206,7 +214,13 @@ export default function GeneralForm() { )} /> - +
diff --git a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx index 18a9f445..30e5c72e 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx @@ -7,6 +7,7 @@ import { authCookieHeader } from "@app/api/cookies"; import { SidebarSettings } from "@app/components/SidebarSettings"; import Link from "next/link"; import { ArrowLeft } from "lucide-react"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; interface ResourceLayoutProps { children: React.ReactNode; @@ -53,19 +54,16 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { className="text-muted-foreground hover:underline" >
- All Resources + {" "} + All Resources
-
-

- {resource?.name + " Settings"} -

-

- Configure the settings on your resource -

-
+ { toast({ + variant: "destructive", title: "Error creating resource", + description: formatAxiosError( + e, + "An error occurred when creating the resource" + ), }); }); diff --git a/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx index e262fef5..84dfae49 100644 --- a/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx @@ -17,6 +17,8 @@ import CreateResourceForm from "./CreateResourceForm"; import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { set } from "zod"; +import { formatAxiosError } from "@app/lib/utils"; +import { useToast } from "@app/hooks/useToast"; export type ResourceRow = { id: number; @@ -34,6 +36,8 @@ type ResourcesTableProps = { export default function SitesTable({ resources, orgId }: ResourcesTableProps) { const router = useRouter(); + const { toast } = useToast(); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedResource, setSelectedResource] = @@ -43,6 +47,11 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { api.delete(`/resource/${resourceId}`) .catch((e) => { console.error("Error deleting resource", e); + toast({ + variant: "destructive", + title: "Error deleting resource", + description: formatAxiosError(e, "Error deleting resource"), + }); }) .then(() => { router.refresh(); diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index 6b6aa33c..ecec6cc0 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -3,6 +3,7 @@ import { authCookieHeader } from "@app/api/cookies"; import ResourcesTable, { ResourceRow } from "./components/ResourcesTable"; import { AxiosResponse } from "axios"; import { ListResourcesResponse } from "@server/routers/resource"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; type ResourcesPageProps = { params: Promise<{ orgId: string }>; @@ -33,14 +34,10 @@ export default async function ResourcesPage(props: ResourcesPageProps) { return ( <> -
-

- Manage Resources -

-

- 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 ae5f28c4..f736b54f 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -18,6 +18,8 @@ import { useForm } from "react-hook-form"; import api from "@app/api"; import { useToast } from "@app/hooks/useToast"; import { useRouter } from "next/navigation"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { formatAxiosError } from "@app/lib/utils"; const GeneralFormSchema = z.object({ name: z.string(), @@ -41,18 +43,19 @@ export default function GeneralPage() { async function onSubmit(data: GeneralFormValues) { await api - .post(`/site/${site?.siteId}`, { - name: data.name, - }) - .catch((e) => { - toast({ - variant: "destructive", - title: "Failed to update site", - description: - e.message || - "An error occurred while updating the site.", + .post(`/site/${site?.siteId}`, { + name: data.name, + }) + .catch((e) => { + toast({ + variant: "destructive", + title: "Failed to update site", + description: formatAxiosError( + e, + "An error occurred while updating the site." + ), + }); }); - }); updateSite({ name: data.name }); @@ -61,14 +64,11 @@ export default function GeneralPage() { return ( <> -
-

- General Settings -

-

- Configure the general settings for this site -

-
+
- All Sites + All Sites
-
-

- {site?.name + " Settings"} -

-

- Configure the settings on your site -

-
+ { toast({ + variant: "destructive", title: "Error picking site defaults", + description: formatAxiosError(e), }); }) .then((res) => { @@ -130,7 +133,9 @@ export default function CreateSiteForm({ open, setOpen }: CreateSiteFormProps) { }) .catch((e) => { toast({ + variant: "destructive", title: "Error creating site", + description: formatAxiosError(e), }); }); diff --git a/src/app/[orgId]/settings/sites/components/SitesTable.tsx b/src/app/[orgId]/settings/sites/components/SitesTable.tsx index 44e92441..c8d8c5f9 100644 --- a/src/app/[orgId]/settings/sites/components/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/components/SitesTable.tsx @@ -17,6 +17,8 @@ import { AxiosResponse } from "axios"; import { useState } from "react"; import CreateSiteForm from "./CreateSiteForm"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { useToast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/utils"; export type SiteRow = { id: number; @@ -35,6 +37,8 @@ type SitesTableProps = { export default function SitesTable({ sites, orgId }: SitesTableProps) { const router = useRouter(); + const { toast } = useToast(); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); @@ -48,6 +52,11 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { api.delete(`/site/${siteId}`) .catch((e) => { console.error("Error deleting site", e); + toast({ + variant: "destructive", + title: "Error deleting site", + description: formatAxiosError(e, "Error deleting site"), + }); }) .then(() => { router.refresh(); diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 9dcc6a45..fc758e92 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -3,6 +3,7 @@ import { authCookieHeader } from "@app/api/cookies"; import { ListSitesResponse } from "@server/routers/site"; import { AxiosResponse } from "axios"; import SitesTable, { SiteRow } from "./components/SitesTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; type SitesPageProps = { params: Promise<{ orgId: string }>; @@ -34,14 +35,10 @@ export default async function SitesPage(props: SitesPageProps) { return ( <> -
-

- Manage Sites -

-

- Manage your existing sites here or create a new one. -

-
+ diff --git a/src/app/auth/login/LoginForm.tsx b/src/app/auth/login/LoginForm.tsx index c40c6f7e..f19376e8 100644 --- a/src/app/auth/login/LoginForm.tsx +++ b/src/app/auth/login/LoginForm.tsx @@ -26,6 +26,7 @@ import { LoginResponse } from "@server/routers/auth"; import { api } from "@app/api"; import { useRouter } from "next/navigation"; import { AxiosResponse } from "axios"; +import { formatAxiosError } from "@app/lib/utils"; type LoginFormProps = { redirect?: string; @@ -65,8 +66,7 @@ export default function LoginForm({ redirect }: LoginFormProps) { .catch((e) => { console.error(e); setError( - e.response?.data?.message || - "An error occurred while logging in", + formatAxiosError(e, "An error occurred while logging in") ); }); @@ -146,7 +146,11 @@ export default function LoginForm({ redirect }: LoginFormProps) { {error} )} - diff --git a/src/app/auth/signup/SignupForm.tsx b/src/app/auth/signup/SignupForm.tsx index b8af07aa..33b2fed6 100644 --- a/src/app/auth/signup/SignupForm.tsx +++ b/src/app/auth/signup/SignupForm.tsx @@ -27,6 +27,7 @@ import { api } from "@app/api"; import { useRouter } from "next/navigation"; import { passwordSchema } from "@server/auth/passwordSchema"; import { AxiosResponse } from "axios"; +import { formatAxiosError } from "@app/lib/utils"; type SignupFormProps = { redirect?: string; @@ -70,8 +71,7 @@ export default function SignupForm({ redirect }: SignupFormProps) { .catch((e) => { console.error(e); setError( - e.response?.data?.message || - "An error occurred while signing up", + formatAxiosError(e, "An error occurred while signing up") ); }); diff --git a/src/app/auth/verify-email/VerifyEmailForm.tsx b/src/app/auth/verify-email/VerifyEmailForm.tsx index b1071462..d6829d8c 100644 --- a/src/app/auth/verify-email/VerifyEmailForm.tsx +++ b/src/app/auth/verify-email/VerifyEmailForm.tsx @@ -34,6 +34,7 @@ import { Loader2 } from "lucide-react"; import { Alert, AlertDescription } from "../../../components/ui/alert"; import { useToast } from "@app/hooks/useToast"; import { useRouter } from "next/navigation"; +import { formatAxiosError } from "@app/lib/utils"; const FormSchema = z.object({ email: z.string().email({ message: "Invalid email address" }), @@ -76,14 +77,14 @@ export default function VerifyEmailForm({ code: data.pin, }) .catch((e) => { - setError(e.response?.data?.message || "An error occurred"); + setError(formatAxiosError(e, "An error occurred")); console.error("Failed to verify email:", e); }); if (res && res.data?.data?.valid) { setError(null); setSuccessMessage( - "Email successfully verified! Redirecting you...", + "Email successfully verified! Redirecting you..." ); setTimeout(() => { if (redirect && redirect.includes("http")) { @@ -103,7 +104,7 @@ export default function VerifyEmailForm({ setIsResending(true); const res = await api.post("/auth/verify-email/request").catch((e) => { - setError(e.response?.data?.message || "An error occurred"); + setError(formatAxiosError(e, "An error occurred")); console.error("Failed to resend verification code:", e); }); diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index e8d83af8..689dccd5 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -5,6 +5,7 @@ import { AcceptInviteResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import InviteStatusCard from "./InviteStatusCard"; +import { formatAxiosError } from "@app/lib/utils"; export default async function InvitePage(props: { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; @@ -47,8 +48,7 @@ export default async function InvitePage(props: { await authCookieHeader() ) .catch((e) => { - error = e.response?.data?.message; - console.log(error); + console.error(e); }); if (res && res.status === 200) { diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index 483303e4..4d33812d 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -15,6 +15,7 @@ import { CardTitle, } from "@app/components/ui/card"; import CopyTextBox from "@app/components/CopyTextBox"; +import { formatAxiosError } from "@app/lib/utils"; type Step = "org" | "site" | "resources"; @@ -43,7 +44,7 @@ export default function StepperForm() { const debouncedCheckOrgIdAvailability = useCallback( debounce(checkOrgIdAvailability, 300), - [checkOrgIdAvailability], + [checkOrgIdAvailability] ); useEffect(() => { @@ -76,7 +77,9 @@ export default function StepperForm() { }) .catch((e) => { toast({ - title: "Error creating org...", + variant: "destructive", + title: "Error creating org", + description: formatAxiosError(e), }); }); @@ -106,36 +109,60 @@ export default function StepperForm() {
1
Create Org
2
Create Site
3
Create Resources @@ -251,7 +278,7 @@ export default function StepperForm() { function debounce any>( func: T, - wait: number, + wait: number ): (...args: Parameters) => void { let timeout: NodeJS.Timeout | null = null; diff --git a/src/components/SettingsSectionTitle.tsx b/src/components/SettingsSectionTitle.tsx index 733c8501..5280532e 100644 --- a/src/components/SettingsSectionTitle.tsx +++ b/src/components/SettingsSectionTitle.tsx @@ -1,15 +1,23 @@ type SettingsSectionTitleProps = { title: string | React.ReactNode; description: string | React.ReactNode; + size?: "2xl" | "1xl"; }; export default function SettingsSectionTitle({ title, description, + size, }: SettingsSectionTitleProps) { return (
-

{title}

+

+ {title} +

{description}

); diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx index d1bd3c3d..16c780bc 100644 --- a/src/components/ui/toaster.tsx +++ b/src/components/ui/toaster.tsx @@ -17,7 +17,7 @@ export function Toaster() { {toasts.map(function ({ id, title, description, action, ...props }) { return ( - +
{title && {title}} {description && ( diff --git a/src/contexts/resourceContext.ts b/src/contexts/resourceContext.ts index e3090b3d..40c43120 100644 --- a/src/contexts/resourceContext.ts +++ b/src/contexts/resourceContext.ts @@ -2,7 +2,7 @@ import { GetResourceResponse } from "@server/routers/resource/getResource"; import { createContext } from "react"; interface ResourceContextType { - resource: GetResourceResponse | null; + resource: GetResourceResponse; updateResource: (updatedResource: Partial) => void; } diff --git a/src/hooks/useToast.ts b/src/hooks/useToast.ts index 02e111d8..992106aa 100644 --- a/src/hooks/useToast.ts +++ b/src/hooks/useToast.ts @@ -1,194 +1,192 @@ -"use client" +"use client"; // Inspired by react-hot-toast library -import * as React from "react" +import * as React from "react"; -import type { - ToastActionElement, - ToastProps, -} from "@/components/ui/toast" +import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; -const TOAST_LIMIT = 1 -const TOAST_REMOVE_DELAY = 1000000 +const TOAST_LIMIT = 3; +const TOAST_REMOVE_DELAY = 5 * 1000; type ToasterToast = ToastProps & { - id: string - title?: React.ReactNode - description?: React.ReactNode - action?: ToastActionElement -} + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + action?: ToastActionElement; +}; const actionTypes = { - ADD_TOAST: "ADD_TOAST", - UPDATE_TOAST: "UPDATE_TOAST", - DISMISS_TOAST: "DISMISS_TOAST", - REMOVE_TOAST: "REMOVE_TOAST", -} as const + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const; -let count = 0 +let count = 0; function genId() { - count = (count + 1) % Number.MAX_SAFE_INTEGER - return count.toString() + count = (count + 1) % Number.MAX_SAFE_INTEGER; + return count.toString(); } -type ActionType = typeof actionTypes +type ActionType = typeof actionTypes; type Action = - | { - type: ActionType["ADD_TOAST"] - toast: ToasterToast - } - | { - type: ActionType["UPDATE_TOAST"] - toast: Partial - } - | { - type: ActionType["DISMISS_TOAST"] - toastId?: ToasterToast["id"] - } - | { - type: ActionType["REMOVE_TOAST"] - toastId?: ToasterToast["id"] - } + | { + type: ActionType["ADD_TOAST"]; + toast: ToasterToast; + } + | { + type: ActionType["UPDATE_TOAST"]; + toast: Partial; + } + | { + type: ActionType["DISMISS_TOAST"]; + toastId?: ToasterToast["id"]; + } + | { + type: ActionType["REMOVE_TOAST"]; + toastId?: ToasterToast["id"]; + }; interface State { - toasts: ToasterToast[] + toasts: ToasterToast[]; } -const toastTimeouts = new Map>() +const toastTimeouts = new Map>(); const addToRemoveQueue = (toastId: string) => { - if (toastTimeouts.has(toastId)) { - return - } + if (toastTimeouts.has(toastId)) { + return; + } - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId) - dispatch({ - type: "REMOVE_TOAST", - toastId: toastId, - }) - }, TOAST_REMOVE_DELAY) + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }); + }, TOAST_REMOVE_DELAY); - toastTimeouts.set(toastId, timeout) -} + toastTimeouts.set(toastId, timeout); +}; export const reducer = (state: State, action: Action): State => { - switch (action.type) { - case "ADD_TOAST": - return { - ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - } + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; - case "UPDATE_TOAST": - return { - ...state, - toasts: state.toasts.map((t) => - t.id === action.toast.id ? { ...t, ...action.toast } : t - ), - } + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + }; - case "DISMISS_TOAST": { - const { toastId } = action + case "DISMISS_TOAST": { + const { toastId } = action; - // ! Side effects ! - This could be extracted into a dismissToast() action, - // but I'll keep it here for simplicity - if (toastId) { - addToRemoveQueue(toastId) - } else { - state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id) - }) - } + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId); + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id); + }); + } - return { - ...state, - toasts: state.toasts.map((t) => - t.id === toastId || toastId === undefined - ? { - ...t, - open: false, - } - : t - ), - } - } - case "REMOVE_TOAST": - if (action.toastId === undefined) { - return { - ...state, - toasts: [], + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + }; } - } - return { - ...state, - toasts: state.toasts.filter((t) => t.id !== action.toastId), - } - } -} + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + }; + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + } +}; -const listeners: Array<(state: State) => void> = [] +const listeners: Array<(state: State) => void> = []; -let memoryState: State = { toasts: [] } +let memoryState: State = { toasts: [] }; function dispatch(action: Action) { - memoryState = reducer(memoryState, action) - listeners.forEach((listener) => { - listener(memoryState) - }) + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); } -type Toast = Omit +type Toast = Omit; function toast({ ...props }: Toast) { - const id = genId() + const id = genId(); + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }); + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); - const update = (props: ToasterToast) => dispatch({ - type: "UPDATE_TOAST", - toast: { ...props, id }, - }) - const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss(); + }, + }, + }); - dispatch({ - type: "ADD_TOAST", - toast: { - ...props, - id, - open: true, - onOpenChange: (open) => { - if (!open) dismiss() - }, - }, - }) - - return { - id: id, - dismiss, - update, - } + return { + id: id, + dismiss, + update, + }; } function useToast() { - const [state, setState] = React.useState(memoryState) + const [state, setState] = React.useState(memoryState); - React.useEffect(() => { - listeners.push(setState) - return () => { - const index = listeners.indexOf(setState) - if (index > -1) { - listeners.splice(index, 1) - } - } - }, [state]) + React.useEffect(() => { + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }, [state]); - return { - ...state, - toast, - dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), - } + return { + ...state, + toast, + dismiss: (toastId?: string) => + dispatch({ type: "DISMISS_TOAST", toastId }), + }; } -export { useToast, toast } +export { useToast, toast }; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index aa65f25e..3a5000ea 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,15 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); +} + +export function formatAxiosError(error: any, defaultMessage?: string): string { + return ( + error.response?.data?.message || + error?.message || + defaultMessage || + "An error occurred" + ); } diff --git a/src/providers/ResourceProvider.tsx b/src/providers/ResourceProvider.tsx index 28748697..cdf1c8d5 100644 --- a/src/providers/ResourceProvider.tsx +++ b/src/providers/ResourceProvider.tsx @@ -6,16 +6,15 @@ import { useState } from "react"; interface ResourceProviderProps { children: React.ReactNode; - resource: GetResourceResponse | null; + resource: GetResourceResponse; } export function ResourceProvider({ children, resource: serverResource, }: ResourceProviderProps) { - const [resource, setResource] = useState( - serverResource - ); + const [resource, setResource] = + useState(serverResource); const updateResource = (updatedResource: Partial) => { if (!resource) { From ba3505a3853588ff513694d6aebfa2fc7e8ce0e9 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Thu, 14 Nov 2024 00:00:17 -0500 Subject: [PATCH 09/12] use strict zod objects and hide proto on targets --- server/routers/auth/verifySiteAccess.ts | 2 + server/routers/external.ts | 1 - server/routers/org/createOrg.ts | 12 +- server/routers/org/updateOrg.ts | 1 + server/routers/resource/createResource.ts | 10 +- server/routers/resource/updateResource.ts | 3 +- server/routers/role/createRole.ts | 10 +- server/routers/role/updateRole.ts | 1 + server/routers/site/createSite.ts | 16 +- server/routers/site/updateSite.ts | 1 + server/routers/target/createTarget.ts | 17 +- server/routers/target/updateTarget.ts | 4 +- .../[resourceId]/connectivity/page.tsx | 230 ++++++++---------- .../resources/[resourceId]/general/page.tsx | 8 +- 14 files changed, 154 insertions(+), 162 deletions(-) diff --git a/server/routers/auth/verifySiteAccess.ts b/server/routers/auth/verifySiteAccess.ts index f0443baa..dc1a87d5 100644 --- a/server/routers/auth/verifySiteAccess.ts +++ b/server/routers/auth/verifySiteAccess.ts @@ -10,6 +10,7 @@ import { import { and, eq, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; export async function verifySiteAccess( req: Request, @@ -28,6 +29,7 @@ export async function verifySiteAccess( } if (isNaN(siteId)) { + logger.debug(JSON.stringify(req.body)); return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid site ID")); } diff --git a/server/routers/external.ts b/server/routers/external.ts index 47e9a4b0..5d22c753 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -150,7 +150,6 @@ authenticated.get( authenticated.post( "/resource/:resourceId", verifyResourceAccess, - verifySiteAccess, verifyUserHasAction(ActionsEnum.updateResource), resource.updateResource ); diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index 4401f0b2..07ee2240 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -12,11 +12,13 @@ import config from "@server/config"; import { fromError } from "zod-validation-error"; import { defaultRoleAllowedActions } from "../role"; -const createOrgSchema = z.object({ - orgId: z.string(), - name: z.string().min(1).max(255), - // domain: z.string().min(1).max(255).optional(), -}); +const createOrgSchema = z + .object({ + orgId: z.string(), + name: z.string().min(1).max(255), + // domain: z.string().min(1).max(255).optional(), + }) + .strict(); const MAX_ORGS = 5; diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index e3dfe2b8..4e700617 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -18,6 +18,7 @@ const updateOrgBodySchema = z name: z.string().min(1).max(255).optional(), domain: z.string().min(1).max(255).optional(), }) + .strict() .refine((data) => Object.keys(data).length > 0, { message: "At least one field must be provided for update", }); diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index c39929c8..f5021417 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -25,10 +25,12 @@ const createResourceParamsSchema = z.object({ orgId: z.string(), }); -const createResourceSchema = z.object({ - name: z.string().min(1).max(255), - subdomain: z.string().min(1).max(255).optional(), -}); +const createResourceSchema = z + .object({ + name: z.string().min(1).max(255), + subdomain: z.string().min(1).max(255).optional(), + }) + .strict(); export type CreateResourceResponse = Resource; diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 719aad97..23ba87e9 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -18,8 +18,9 @@ const updateResourceBodySchema = z name: z.string().min(1).max(255).optional(), subdomain: z.string().min(1).max(255).optional(), ssl: z.boolean().optional(), - siteId: z.number(), + // siteId: z.number(), }) + .strict() .refine((data) => Object.keys(data).length > 0, { message: "At least one field must be provided for update", }); diff --git a/server/routers/role/createRole.ts b/server/routers/role/createRole.ts index 60992c03..b63deabe 100644 --- a/server/routers/role/createRole.ts +++ b/server/routers/role/createRole.ts @@ -14,10 +14,12 @@ const createRoleParamsSchema = z.object({ orgId: z.string(), }); -const createRoleSchema = z.object({ - name: z.string().min(1).max(255), - description: z.string().optional(), -}); +const createRoleSchema = z + .object({ + name: z.string().min(1).max(255), + description: z.string().optional(), + }) + .strict(); export const defaultRoleAllowedActions: ActionsEnum[] = [ ActionsEnum.getOrg, diff --git a/server/routers/role/updateRole.ts b/server/routers/role/updateRole.ts index 796678b4..574e1c32 100644 --- a/server/routers/role/updateRole.ts +++ b/server/routers/role/updateRole.ts @@ -18,6 +18,7 @@ const updateRoleBodySchema = z name: z.string().min(1).max(255).optional(), description: z.string().optional(), }) + .strict() .refine((data) => Object.keys(data).length > 0, { message: "At least one field must be provided for update", }); diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index ce513040..13689b6e 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -15,13 +15,15 @@ const createSiteParamsSchema = z.object({ orgId: z.string(), }); -const createSiteSchema = z.object({ - name: z.string().min(1).max(255), - exitNodeId: z.number().int().positive(), - subdomain: z.string().min(1).max(255).optional(), - pubKey: z.string(), - subnet: z.string(), -}); +const createSiteSchema = z + .object({ + name: z.string().min(1).max(255), + exitNodeId: z.number().int().positive(), + subdomain: z.string().min(1).max(255).optional(), + pubKey: z.string(), + subnet: z.string(), + }) + .strict(); export type CreateSiteResponse = { name: string; diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index c436b07f..71b36144 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -23,6 +23,7 @@ const updateSiteBodySchema = z megabytesIn: z.number().int().nonnegative().optional(), megabytesOut: z.number().int().nonnegative().optional(), }) + .strict() .refine((data) => Object.keys(data).length > 0, { message: "At least one field must be provided for update", }); diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 63398e72..d98437f1 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -15,13 +15,15 @@ const createTargetParamsSchema = z.object({ resourceId: z.string().transform(Number).pipe(z.number().int().positive()), }); -const createTargetSchema = z.object({ - ip: z.string().ip(), - method: z.string().min(1).max(10), - port: z.number().int().min(1).max(65535), - protocol: z.string().optional(), - enabled: z.boolean().default(true), -}); +const createTargetSchema = z + .object({ + ip: z.string().ip(), + method: z.string().min(1).max(10), + port: z.number().int().min(1).max(65535), + protocol: z.string().optional(), + enabled: z.boolean().default(true), + }) + .strict(); export type CreateTargetResponse = Target; @@ -104,6 +106,7 @@ export async function createTarget( .insert(targets) .values({ resourceId, + protocol: "tcp", // hard code for now ...targetData, }) .returning(); diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 52e033b1..58fa4914 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -15,12 +15,12 @@ const updateTargetParamsSchema = z.object({ const updateTargetBodySchema = z .object({ - // ip: z.string().ip().optional(), // for now we cant update the ip; you will have to delete + ip: z.string().ip().optional(), // for now we cant update the ip; you will have to delete method: z.string().min(1).max(10).optional(), port: z.number().int().min(1).max(65535).optional(), - protocol: z.string().optional(), enabled: z.boolean().optional(), }) + .strict() .refine((data) => Object.keys(data).length > 0, { message: "At least one field must be provided for update", }); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index 75f38e47..7dc262b5 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -17,7 +17,7 @@ import { AxiosResponse } from "axios"; import { ListTargetsResponse } from "@server/routers/target/listTargets"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { set, z } from "zod"; +import { z } from "zod"; import { Form, FormControl, @@ -27,7 +27,7 @@ import { FormLabel, FormMessage, } from "@app/components/ui/form"; -import { CreateTargetResponse, updateTarget } from "@server/routers/target"; +import { CreateTargetResponse } from "@server/routers/target"; import { ColumnDef, getFilteredRowModel, @@ -51,7 +51,6 @@ import { useResourceContext } from "@app/hooks/useResourceContext"; import { ArrayElement } from "@server/types/ArrayElement"; import { Dot } from "lucide-react"; import { formatAxiosError } from "@app/lib/utils"; -import { escape } from "querystring"; const addTargetSchema = z.object({ ip: z.string().ip(), @@ -62,15 +61,18 @@ const addTargetSchema = z.object({ message: "Port must be a number", }) .transform((val) => Number(val)), - protocol: z.string(), + // protocol: z.string(), }); type AddTargetFormValues = z.infer; -type LocalTarget = ArrayElement & { - new?: boolean; - updated?: boolean; -}; +type LocalTarget = Omit< + ArrayElement & { + new?: boolean; + updated?: boolean; + }, + "protocol" +>; export default function ReverseProxyTargets(props: { params: Promise<{ resourceId: number }>; @@ -84,13 +86,15 @@ export default function ReverseProxyTargets(props: { const [targetsToRemove, setTargetsToRemove] = useState([]); const [sslEnabled, setSslEnabled] = useState(resource.ssl); + const [loading, setLoading] = useState(false); + const addTargetForm = useForm({ resolver: zodResolver(addTargetSchema), defaultValues: { ip: "", method: "http", port: "80", - protocol: "TCP", + // protocol: "TCP", }, }); @@ -153,109 +157,72 @@ export default function ReverseProxyTargets(props: { } async function saveAll() { - const res = await api - .post(`/resource/${params.resourceId}`, { ssl: sslEnabled }) - .catch((err) => { - console.error(err); - toast({ - variant: "destructive", - title: "Failed to update resource", - description: formatAxiosError( - err, - "Failed to update resource" - ), - }); - }) - .then(() => { - updateResource({ ssl: sslEnabled }); + try { + setLoading(true); + + const res = await api.post(`/resource/${params.resourceId}`, { + ssl: sslEnabled, }); - for (const target of targets) { - const data = { - ip: target.ip, - port: target.port, - method: target.method, - protocol: target.protocol, - enabled: target.enabled, - }; + updateResource({ ssl: sslEnabled }); - if (target.new) { - await api - .put>( - `/resource/${params.resourceId}/target`, + for (const target of targets) { + const data = { + ip: target.ip, + port: target.port, + // protocol: target.protocol, + method: target.method, + enabled: target.enabled, + }; + + if (target.new) { + const res = await api.put< + AxiosResponse + >(`/resource/${params.resourceId}/target`, data); + } else if (target.updated) { + const res = await api.post( + `/target/${target.targetId}`, data - ) - .then((res) => { - setTargets( - targets.map((t) => { - if ( - t.new && - t.targetId === res.data.data.targetId - ) { - return { - ...t, - new: false, - }; - } - return t; - }) - ); - }) - .catch((err) => { - console.error(err); - toast({ - variant: "destructive", - title: "Failed to add target", - description: formatAxiosError( - err, - "Failed to add target" - ), - }); - }); - } else if (target.updated) { - const res = await api - .post(`/target/${target.targetId}`, data) - .catch((err) => { - console.error(err); - toast({ - variant: "destructive", - title: "Failed to update target", - description: formatAxiosError( - err, - "Failed to update target" - ), - }); - }); - } - } - - for (const targetId of targetsToRemove) { - await api - .delete(`/target/${targetId}`) - .catch((err) => { - console.error(err); - toast({ - variant: "destructive", - title: "Failed to remove target", - description: formatAxiosError( - err, - "Failed to remove target" - ), - }); - }) - .then((res) => { - setTargets( - targets.filter((target) => target.targetId !== targetId) ); - }); + } + + setTargets([ + ...targets.map((t) => { + return { + ...t, + new: false, + updated: false, + }; + }), + ]); + } + + for (const targetId of targetsToRemove) { + await api.delete(`/target/${targetId}`); + setTargets( + targets.filter((target) => target.targetId !== targetId) + ); + } + + toast({ + title: "Resource updated", + description: "Resource and targets updated successfully", + }); + + setTargetsToRemove([]); + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: "Operation failed", + description: formatAxiosError( + err, + "An error occurred during the save operation" + ), + }); } - toast({ - title: "Resource updated", - description: "Resource and targets updated successfully", - }); - - setTargetsToRemove([]); + setLoading(false); } const columns: ColumnDef[] = [ @@ -306,24 +273,24 @@ export default function ReverseProxyTargets(props: { ), }, - { - accessorKey: "protocol", - header: "Protocol", - cell: ({ row }) => ( - - ), - }, + // { + // accessorKey: "protocol", + // header: "Protocol", + // cell: ({ row }) => ( + // + // ), + // }, { accessorKey: "enabled", header: "Enabled", @@ -341,7 +308,14 @@ export default function ReverseProxyTargets(props: { cell: ({ row }) => ( <>
- {row.original.new && } + +
- +
); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index b865e126..d3d1bb03 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -61,7 +61,7 @@ export default function GeneralForm() { resolver: zodResolver(GeneralFormSchema), defaultValues: { name: resource.name, - siteId: resource.siteId!, + // siteId: resource.siteId!, }, mode: "onChange", }); @@ -84,7 +84,7 @@ export default function GeneralForm() { `resource/${resource?.resourceId}`, { name: data.name, - siteId: data.siteId, + // siteId: data.siteId, } ) .catch((e) => { @@ -137,7 +137,7 @@ export default function GeneralForm() { )} /> - ( @@ -213,7 +213,7 @@ export default function GeneralForm() { )} - /> + /> */}
-
+
{table.getHeaderGroups().map((headerGroup) => ( diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index 689dccd5..4f5923cd 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -49,6 +49,7 @@ export default async function InvitePage(props: { ) .catch((e) => { console.error(e); + error = formatAxiosError(e); }); if (res && res.status === 200) { From 28bae403903ae7146526b33c536eec2de15a83da Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Fri, 15 Nov 2024 18:25:27 -0500 Subject: [PATCH 11/12] add roles input on resource and make spacing more consistent --- package.json | 1 + server/auth/actions.ts | 3 +- server/db/schema.ts | 3 +- server/routers/auth/verifyRoleAccess.ts | 70 ++--- server/routers/external.ts | 37 ++- server/routers/resource/createResource.ts | 6 +- server/routers/resource/listResourceRoles.ts | 34 ++- server/routers/resource/setResourceRoles.ts | 111 ++++++++ server/routers/resource/updateResource.ts | 3 +- server/routers/role/addRoleResource.ts | 70 ----- server/routers/role/index.ts | 2 +- server/routers/traefik/getTraefikConfig.ts | 22 +- server/schemas/subdomainSchema.ts | 9 + .../roles/components/CreateRoleForm.tsx | 2 +- .../roles/components/DeleteRoleForm.tsx | 102 ++++---- .../access/roles/components/RolesTable.tsx | 56 ++-- .../users/[userId]/access-controls/page.tsx | 96 +++---- .../users/components/InviteUserForm.tsx | 242 +++++++++--------- .../access/users/components/UsersTable.tsx | 104 +++++--- .../[orgId]/settings/components/Header.tsx | 2 +- src/app/[orgId]/settings/general/layout.tsx | 1 - src/app/[orgId]/settings/general/page.tsx | 16 +- .../[resourceId]/authentication/page.tsx | 204 +++++++++++++++ .../components/CustomDomainInput.tsx | 18 +- .../components/ResourceInfoBox.tsx | 89 +++++++ .../[resourceId]/connectivity/page.tsx | 223 ++++++++-------- .../resources/[resourceId]/general/page.tsx | 55 +++- .../resources/[resourceId]/layout.tsx | 48 +++- .../components/CreateResourceForm.tsx | 25 +- .../resources/components/ResourcesTable.tsx | 4 +- src/app/[orgId]/settings/resources/page.tsx | 26 +- .../settings/sites/[niceId]/general/page.tsx | 60 ++--- .../sites/components/CreateSiteForm.tsx | 199 +++++++------- .../settings/sites/components/SitesTable.tsx | 10 +- src/contexts/orgContext.ts | 2 +- src/providers/OrgProvider.tsx | 4 + 36 files changed, 1235 insertions(+), 724 deletions(-) create mode 100644 server/routers/resource/setResourceRoles.ts delete mode 100644 server/routers/role/addRoleResource.ts create mode 100644 server/schemas/subdomainSchema.ts create mode 100644 src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx create mode 100644 src/app/[orgId]/settings/resources/[resourceId]/components/ResourceInfoBox.tsx 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. + + +
+ + +
+ + +
- {/*
    -
  • - Protocol:{" "} - {protocol} -
  • -
  • - Subdomain:{" "} - {subdomain} -
  • -
  • - Domain:{" "} - {domain} -
  • -
*/} + +

+ To create a proxy to your private services,{" "} + + add targets + {" "} + to this resource +