From cfce3dabb3afc9e35180d34d6cf30fa471b286e6 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Tue, 19 Nov 2024 00:05:04 -0500 Subject: [PATCH] set resource password and remove resource password from dashboard --- server/db/schema.ts | 2 +- .../routers/resource/setResourcePassword.ts | 2 +- .../components/SetResourcePasswordForm.tsx | 172 ++++++++++ .../[resourceId]/authentication/page.tsx | 307 ++++++++++-------- .../components/ResourceInfoBox.tsx | 39 ++- .../[resourceId]/connectivity/page.tsx | 40 ++- .../resources/[resourceId]/layout.tsx | 23 +- src/components/SidebarSettings.tsx | 2 +- src/contexts/resourceContext.ts | 5 + src/providers/ResourceProvider.tsx | 29 +- 10 files changed, 457 insertions(+), 164 deletions(-) create mode 100644 src/app/[orgId]/settings/resources/[resourceId]/authentication/components/SetResourcePasswordForm.tsx diff --git a/server/db/schema.ts b/server/db/schema.ts index d9fa02a6..9d3ac0e2 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -44,7 +44,7 @@ export const resources = sqliteTable("resources", { blockAccess: integer("blockAccess", { mode: "boolean" }) .notNull() .default(false), - sso: integer("sso", { mode: "boolean" }).notNull().default(false), + sso: integer("sso", { mode: "boolean" }).notNull().default(true), twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" }) .notNull() .default(false), diff --git a/server/routers/resource/setResourcePassword.ts b/server/routers/resource/setResourcePassword.ts index f54de902..54fdcdf3 100644 --- a/server/routers/resource/setResourcePassword.ts +++ b/server/routers/resource/setResourcePassword.ts @@ -15,7 +15,7 @@ const setResourceAuthMethodsParamsSchema = z.object({ const setResourceAuthMethodsBodySchema = z .object({ - password: z.string().min(4).max(255).nullable(), + password: z.string().nullish(), }) .strict(); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/components/SetResourcePasswordForm.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/components/SetResourcePasswordForm.tsx new file mode 100644 index 00000000..521e76cf --- /dev/null +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/components/SetResourcePasswordForm.tsx @@ -0,0 +1,172 @@ +"use client"; + +import api from "@app/api"; +import { Button } 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 { formatAxiosError } from "@app/lib/utils"; +import { AxiosResponse } from "axios"; +import { Resource } from "@server/db/schema"; + +const setPasswordFormSchema = z.object({ + password: z.string().min(4).max(100), +}); + +type SetPasswordFormValues = z.infer; + +const defaultValues: Partial = { + password: "", +}; + +type SetPasswordFormProps = { + open: boolean; + setOpen: (open: boolean) => void; + resourceId: number; + onSetPassword?: () => void; +}; + +export default function SetResourcePasswordForm({ + open, + setOpen, + resourceId, + onSetPassword, +}: SetPasswordFormProps) { + const { toast } = useToast(); + + const [loading, setLoading] = useState(false); + + const form = useForm({ + resolver: zodResolver(setPasswordFormSchema), + defaultValues, + }); + + useEffect(() => { + if (!open) { + return; + } + + form.reset(); + }, [open]); + + async function onSubmit(data: SetPasswordFormValues) { + setLoading(true); + + api.post>(`/resource/${resourceId}/password`, { + password: data.password, + }) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error setting resource password", + description: formatAxiosError( + e, + "An error occurred while setting the resource password" + ), + }); + }) + .then(() => { + toast({ + title: "Resource password set", + description: + "The resource password has been set successfully", + }); + + if (onSetPassword) { + onSetPassword(); + } + }) + .finally(() => setLoading(false)); + } + + return ( + <> + { + setOpen(val); + setLoading(false); + form.reset(); + }} + > + + + Set Password + + Set a password to protect this resource + + + +
+ + ( + + Password + + + + + Users will be able to access + this resource by entering this + password. It must be at least 4 + characters long. + + + + )} + /> + + +
+ + + + + + +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index 4947e91b..c4971345 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -9,11 +9,12 @@ import { useResourceContext } from "@app/hooks/useResourceContext"; import { AxiosResponse } from "axios"; import { formatAxiosError } from "@app/lib/utils"; import { + GetResourceAuthInfoResponse, ListResourceRolesResponse, ListResourceUsersResponse, } from "@server/routers/resource"; import { Button } from "@app/components/ui/button"; -import { z } from "zod"; +import { set, z } from "zod"; import { Tag } from "emblor"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -31,6 +32,9 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { ListUsersResponse } from "@server/routers/user"; import { Switch } from "@app/components/ui/switch"; import { Label } from "@app/components/ui/label"; +import { Input } from "@app/components/ui/input"; +import { ShieldCheck } from "lucide-react"; +import SetResourcePasswordForm from "./components/SetResourcePasswordForm"; const UsersRolesFormSchema = z.object({ roles: z.array( @@ -50,7 +54,10 @@ const UsersRolesFormSchema = z.object({ export default function ResourceAuthenticationPage() { const { toast } = useToast(); const { org } = useOrgContext(); - const { resource, updateResource } = useResourceContext(); + const { resource, updateResource, authInfo, updateAuthInfo } = + useResourceContext(); + + const [pageLoading, setPageLoading] = useState(true); const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>( [] @@ -69,7 +76,10 @@ export default function ResourceAuthenticationPage() { const [blockAccess, setBlockAccess] = useState(resource.blockAccess); const [loadingSaveUsersRoles, setLoadingSaveUsersRoles] = useState(false); - const [loadingSaveAuth, setLoadingSaveAuth] = useState(false); + const [loadingRemoveResourcePassword, setLoadingRemoveResourcePassword] = + useState(false); + + const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); const usersRolesForm = useForm>({ resolver: zodResolver(UsersRolesFormSchema), @@ -77,103 +87,77 @@ export default function ResourceAuthenticationPage() { }); useEffect(() => { - api.get>( - `/org/${org?.org.orgId}/roles` - ) - .then((res) => { + const fetchData = async () => { + try { + const [ + rolesResponse, + resourceRolesResponse, + usersResponse, + resourceUsersResponse, + ] = await Promise.all([ + api.get>( + `/org/${org?.org.orgId}/roles` + ), + api.get>( + `/resource/${resource.resourceId}/roles` + ), + api.get>( + `/org/${org?.org.orgId}/users` + ), + api.get>( + `/resource/${resource.resourceId}/users` + ), + ]); + setAllRoles( - res.data.data.roles + rolesResponse.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) => { usersRolesForm.setValue( "roles", - res.data.data.roles + resourceRolesResponse.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" - ), - }); - }); - api.get>( - `/org/${org?.org.orgId}/users` - ) - .then((res) => { setAllUsers( - res.data.data.users.map((user) => ({ + usersResponse.data.data.users.map((user) => ({ id: user.id.toString(), text: user.email, })) ); - }) - .catch((e) => { - console.error(e); - toast({ - variant: "destructive", - title: "Failed to fetch users", - description: formatAxiosError( - e, - "An error occurred while fetching the users" - ), - }); - }); - api.get>( - `/resource/${resource.resourceId}/users` - ) - .then((res) => { usersRolesForm.setValue( "users", - res.data.data.users.map((i) => ({ + resourceUsersResponse.data.data.users.map((i) => ({ id: i.userId.toString(), text: i.email, })) ); - }) - .catch((e) => { + + setPageLoading(false); + } catch (e) { console.error(e); toast({ variant: "destructive", - title: "Failed to fetch users", + title: "Failed to fetch data", description: formatAxiosError( e, - "An error occurred while fetching the users" + "An error occurred while fetching the data" ), }); - }); + } + }; + + fetchData(); }, []); async function onSubmitUsersRoles( @@ -181,12 +165,28 @@ export default function ResourceAuthenticationPage() { ) { try { setLoadingSaveUsersRoles(true); - await api.post(`/resource/${resource.resourceId}/roles`, { - roleIds: data.roles.map((i) => parseInt(i.id)), + + const jobs = [ + api.post(`/resource/${resource.resourceId}/roles`, { + roleIds: data.roles.map((i) => parseInt(i.id)), + }), + api.post(`/resource/${resource.resourceId}/users`, { + userIds: data.users.map((i) => i.id), + }), + api.post(`/resource/${resource.resourceId}`, { + sso: ssoEnabled, + blockAccess, + }), + ]; + + await Promise.all(jobs); + + updateResource({ + sso: ssoEnabled, }); - await api.post(`/resource/${resource.resourceId}/users`, { - userIds: data.users.map((i) => i.id), + updateAuthInfo({ + sso: ssoEnabled, }); toast({ @@ -208,48 +208,95 @@ export default function ResourceAuthenticationPage() { } } - async function onSubmitAuth() { - try { - setLoadingSaveAuth(true); + function removeResourcePassword() { + setLoadingRemoveResourcePassword(true); - await api.post(`/resource/${resource.resourceId}`, { - sso: ssoEnabled, - blockAccess, - }); + api.post(`/resource/${resource.resourceId}/password`, { + password: null, + }) + .then(() => { + toast({ + title: "Resource password removed", + description: + "The resource password has been removed successfully", + }); - updateResource({ - blockAccess, - sso: ssoEnabled, - }); + updateAuthInfo({ + password: false, + }); + }) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error removing resource password", + description: formatAxiosError( + e, + "An error occurred while removing the resource password" + ), + }); + }) + .finally(() => setLoadingRemoveResourcePassword(false)); + } - toast({ - title: "Saved successfully", - description: "Authentication settings have been saved", - }); - } catch (e) { - console.error(e); - toast({ - variant: "destructive", - title: "Failed to save authentication", - description: formatAxiosError( - e, - "An error occurred while saving the authentication" - ), - }); - } finally { - setLoadingSaveAuth(false); - } + if (pageLoading) { + return <>; } return ( <> + {isSetPasswordOpen && ( + { + setIsSetPasswordOpen(false); + updateAuthInfo({ + password: true, + }); + }} + /> + )} +
+ {/*
+
+ setBlockAccess(val)} + /> + +
+ + When enabled, this will prevent anyone from accessing + the resource including SSO users. + +
*/} + +
+
+ setSsoEnabled(val)} + /> + +
+ + Users will be able to access the resource if they're + logged into the dashboard and have access to the + resource. Users will only have to login once for all + resources that have SSO enabled. + +
+
-
- setBlockAccess(val)} - /> - -
- - When enabled, all auth methods will be disabled and - users will not able to access the resource. This is an - override. - + {authInfo?.password ? ( +
+
+ + Password Protection Enabled +
+ +
+ ) : ( +
+ +
+ )}
- -
-
- setSsoEnabled(val)} - /> - -
- - Users will be able to access the resource if they're - logged into the dashboard and have access to the - resource. Users will only have to login once for all - resources that have SSO enabled. - -
- -
); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/components/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/components/ResourceInfoBox.tsx index bf86fc94..238bfe10 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/components/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/components/ResourceInfoBox.tsx @@ -1,10 +1,17 @@ "use client"; -import { useState } from "react"; +import { useEffect, 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 { + InfoIcon, + LinkIcon, + CheckIcon, + CopyIcon, + ShieldCheck, + ShieldOff, +} from "lucide-react"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { useResourceContext } from "@app/hooks/useResourceContext"; import Link from "next/link"; @@ -15,7 +22,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { const [copied, setCopied] = useState(false); const { org } = useOrgContext(); - const { resource } = useResourceContext(); + const { resource, authInfo } = useResourceContext(); const fullUrl = `${resource.ssl ? "https" : "http"}://${ resource.subdomain @@ -70,7 +77,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { -

+ {/*

To create a proxy to your private services,{" "} {" "} to this resource -

+

*/} + +
+ {authInfo.password || + authInfo.pincode || + authInfo.sso ? ( +
+ + + This resource is protected with at least one + auth method + +
+ ) : ( +
+ + + This resource is not protected with any auth + method. Anyone can access this resource. + +
+ )} +
diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index 1d96d071..04236872 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -88,6 +88,8 @@ export default function ReverseProxyTargets(props: { const [loading, setLoading] = useState(false); + const [pageLoading, setPageLoading] = useState(true); + const addTargetForm = useForm({ resolver: zodResolver(addTargetSchema), defaultValues: { @@ -100,24 +102,26 @@ export default function ReverseProxyTargets(props: { useEffect(() => { const fetchSites = async () => { - const res = await api - .get>( + try { + const res = await api.get>( `/resource/${params.resourceId}/targets` - ) - .catch((err) => { - console.error(err); - toast({ - variant: "destructive", - title: "Failed to fetch targets", - description: formatAxiosError( - err, - "An error occurred while fetching targets" - ), - }); - }); + ); - if (res && res.status === 200) { - setTargets(res.data.data.targets); + if (res.status === 200) { + setTargets(res.data.data.targets); + } + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: "Failed to fetch targets", + description: formatAxiosError( + err, + "An error occurred while fetching targets" + ), + }); + } finally { + setPageLoading(false); } }; fetchSites(); @@ -337,6 +341,10 @@ export default function ReverseProxyTargets(props: { getFilteredRowModel: getFilteredRowModel(), }); + if (pageLoading) { + return <>; + } + return (
diff --git a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx index 90da54f6..05f9550b 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx @@ -1,6 +1,9 @@ import ResourceProvider from "@app/providers/ResourceProvider"; import { internal } from "@app/api"; -import { GetResourceAuthInfoResponse } from "@server/routers/resource"; +import { + GetResourceAuthInfoResponse, + GetResourceResponse, +} from "@server/routers/resource"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/api/cookies"; @@ -23,9 +26,10 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { const { children } = props; + let authInfo = null; let resource = null; try { - const res = await internal.get>( + const res = await internal.get>( `/resource/${params.resourceId}`, await authCookieHeader() ); @@ -38,6 +42,19 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { redirect(`/${params.orgId}/settings/resources`); } + try { + const res = await internal.get< + AxiosResponse + >(`/resource/${resource.resourceId}/auth`, await authCookieHeader()); + authInfo = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/resources`); + } + + if (!authInfo) { + redirect(`/${params.orgId}/settings/resources`); + } + let org = null; try { const getOrg = cache(async () => @@ -94,7 +111,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { /> - + -
+
diff --git a/src/contexts/resourceContext.ts b/src/contexts/resourceContext.ts index 40c43120..bb5501a6 100644 --- a/src/contexts/resourceContext.ts +++ b/src/contexts/resourceContext.ts @@ -1,9 +1,14 @@ +import { GetResourceAuthInfoResponse } from "@server/routers/resource"; import { GetResourceResponse } from "@server/routers/resource/getResource"; import { createContext } from "react"; interface ResourceContextType { resource: GetResourceResponse; + authInfo: GetResourceAuthInfoResponse; updateResource: (updatedResource: Partial) => void; + updateAuthInfo: ( + updatedAuthInfo: Partial + ) => void; } const ResourceContext = createContext( diff --git a/src/providers/ResourceProvider.tsx b/src/providers/ResourceProvider.tsx index cdf1c8d5..cd6229a4 100644 --- a/src/providers/ResourceProvider.tsx +++ b/src/providers/ResourceProvider.tsx @@ -1,21 +1,27 @@ "use client"; import ResourceContext from "@app/contexts/resourceContext"; +import { GetResourceAuthInfoResponse } from "@server/routers/resource"; import { GetResourceResponse } from "@server/routers/resource/getResource"; import { useState } from "react"; interface ResourceProviderProps { children: React.ReactNode; resource: GetResourceResponse; + authInfo: GetResourceAuthInfoResponse; } export function ResourceProvider({ children, resource: serverResource, + authInfo: serverAuthInfo, }: ResourceProviderProps) { const [resource, setResource] = useState(serverResource); + const [authInfo, setAuthInfo] = + useState(serverAuthInfo); + const updateResource = (updatedResource: Partial) => { if (!resource) { throw new Error("No resource to update"); @@ -33,8 +39,29 @@ export function ResourceProvider({ }); }; + const updateAuthInfo = ( + updatedAuthInfo: Partial + ) => { + if (!authInfo) { + throw new Error("No auth info to update"); + } + + setAuthInfo((prev) => { + if (!prev) { + return prev; + } + + return { + ...prev, + ...updatedAuthInfo, + }; + }); + }; + return ( - + {children} );