set resource password and remove resource password from dashboard

This commit is contained in:
Milo Schwartz 2024-11-19 00:05:04 -05:00
parent ab6d59c163
commit cfce3dabb3
No known key found for this signature in database
10 changed files with 457 additions and 164 deletions

View file

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

View file

@ -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();

View file

@ -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<typeof setPasswordFormSchema>;
const defaultValues: Partial<SetPasswordFormValues> = {
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<SetPasswordFormValues>({
resolver: zodResolver(setPasswordFormSchema),
defaultValues,
});
useEffect(() => {
if (!open) {
return;
}
form.reset();
}, [open]);
async function onSubmit(data: SetPasswordFormValues) {
setLoading(true);
api.post<AxiosResponse<Resource>>(`/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 (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
setLoading(false);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Set Password</CredenzaTitle>
<CredenzaDescription>
Set a password to protect this resource
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="set-password-form"
>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
autoComplete="off"
type="password"
placeholder="Your secure password"
{...field}
/>
</FormControl>
<FormDescription>
Users will be able to access
this resource by entering this
password. It must be at least 4
characters long.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<Button
type="submit"
form="set-password-form"
loading={loading}
disabled={loading}
>
Enable Password Protection
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View file

@ -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<z.infer<typeof UsersRolesFormSchema>>({
resolver: zodResolver(UsersRolesFormSchema),
@ -77,103 +87,77 @@ export default function ResourceAuthenticationPage() {
});
useEffect(() => {
api.get<AxiosResponse<ListRolesResponse>>(
`/org/${org?.org.orgId}/roles`
)
.then((res) => {
const fetchData = async () => {
try {
const [
rolesResponse,
resourceRolesResponse,
usersResponse,
resourceUsersResponse,
] = await Promise.all([
api.get<AxiosResponse<ListRolesResponse>>(
`/org/${org?.org.orgId}/roles`
),
api.get<AxiosResponse<ListResourceRolesResponse>>(
`/resource/${resource.resourceId}/roles`
),
api.get<AxiosResponse<ListUsersResponse>>(
`/org/${org?.org.orgId}/users`
),
api.get<AxiosResponse<ListResourceUsersResponse>>(
`/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<AxiosResponse<ListResourceRolesResponse>>(
`/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<AxiosResponse<ListUsersResponse>>(
`/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<AxiosResponse<ListResourceUsersResponse>>(
`/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 && (
<SetResourcePasswordForm
open={isSetPasswordOpen}
setOpen={setIsSetPasswordOpen}
resourceId={resource.resourceId}
onSetPassword={() => {
setIsSetPasswordOpen(false);
updateAuthInfo({
password: true,
});
}}
/>
)}
<div className="space-y-6 lg:max-w-2xl">
{/* <div>
<div className="flex items-center space-x-2 mb-2">
<Switch
id="block-toggle"
defaultChecked={resource.blockAccess}
onCheckedChange={(val) => setBlockAccess(val)}
/>
<Label htmlFor="block-toggle">Block Access</Label>
</div>
<span className="text-muted-foreground text-sm">
When enabled, this will prevent anyone from accessing
the resource including SSO users.
</span>
</div> */}
<SettingsSectionTitle
title="Users & Roles"
description="Configure who can visit this resource (only applicable if SSO is used)"
size="1xl"
/>
<div>
<div className="flex items-center space-x-2 mb-2">
<Switch
id="sso-toggle"
defaultChecked={resource.sso}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
<Label htmlFor="sso-toggle">Allow SSO</Label>
</div>
<span className="text-muted-foreground text-sm">
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.
</span>
</div>
<Form {...usersRolesForm}>
<form
onSubmit={usersRolesForm.handleSubmit(
@ -368,51 +415,39 @@ export default function ResourceAuthenticationPage() {
<SettingsSectionTitle
title="Authentication Methods"
description="Configure how users can authenticate to this resource"
description="You can also allow users to access the resource via the below methods"
size="1xl"
/>
<div>
<div className="flex items-center space-x-2 mb-2">
<Switch
id="block-toggle"
defaultChecked={resource.blockAccess}
onCheckedChange={(val) => setBlockAccess(val)}
/>
<Label htmlFor="block-toggle">Block Access</Label>
</div>
<span className="text-muted-foreground text-sm">
When enabled, all auth methods will be disabled and
users will not able to access the resource. This is an
override.
</span>
{authInfo?.password ? (
<div className="flex items-center space-x-4">
<div className="flex items-center text-green-500 space-x-2">
<ShieldCheck />
<span>Password Protection Enabled</span>
</div>
<Button
variant="gray"
type="button"
loading={loadingRemoveResourcePassword}
disabled={loadingRemoveResourcePassword}
onClick={removeResourcePassword}
>
Remove Password
</Button>
</div>
) : (
<div>
<Button
variant="gray"
type="button"
onClick={() => setIsSetPasswordOpen(true)}
>
Add Password
</Button>
</div>
)}
</div>
<div>
<div className="flex items-center space-x-2 mb-2">
<Switch
id="sso-toggle"
defaultChecked={resource.sso}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
<Label htmlFor="sso-toggle">Allow SSO</Label>
</div>
<span className="text-muted-foreground text-sm">
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.
</span>
</div>
<Button
type="button"
onClick={onSubmitAuth}
loading={loadingSaveAuth}
disabled={loadingSaveAuth}
>
Save Authentication
</Button>
</div>
</>
);

View file

@ -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) {
</Button>
</div>
<p className="mt-3">
{/* <p className="mt-3">
To create a proxy to your private services,{" "}
<Link
href={`/${org.org.orgId}/settings/resources/${resource.resourceId}/connectivity`}
@ -79,7 +86,29 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
add targets
</Link>{" "}
to this resource
</p>
</p> */}
<div className="mt-3">
{authInfo.password ||
authInfo.pincode ||
authInfo.sso ? (
<div className="flex items-center space-x-2 text-green-500">
<ShieldCheck />
<span>
This resource is protected with at least one
auth method
</span>
</div>
) : (
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff />
<span>
This resource is not protected with any auth
method. Anyone can access this resource.
</span>
</div>
)}
</div>
</AlertDescription>
</Alert>
</Card>

View file

@ -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<AxiosResponse<ListTargetsResponse>>(
try {
const res = await api.get<AxiosResponse<ListTargetsResponse>>(
`/resource/${params.resourceId}/targets`
)
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: "Failed to fetch targets",
description: formatAxiosError(
err,
"An error occurred while fetching targets"
),
});
});
);
if (res && res.status === 200) {
setTargets(res.data.data.targets);
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 (
<div>
<div className="space-y-6">

View file

@ -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<AxiosResponse<GetResourceAuthInfoResponse>>(
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
`/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<GetResourceAuthInfoResponse>
>(`/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) {
/>
<OrgProvider org={org}>
<ResourceProvider resource={resource}>
<ResourceProvider resource={resource} authInfo={authInfo}>
<SidebarSettings
sidebarNavItems={sidebarNavItems}
limitWidth={false}

View file

@ -22,7 +22,7 @@ export function SidebarSettings({
}: SideBarSettingsProps) {
return (
<div className="space-y-6 0 pb-16k">
<div className="flex flex-col space-y-6 lg:flex-row lg:space-x-12 lg:space-y-0">
<div className="flex flex-col space-y-6 lg:flex-row lg:space-x-32 lg:space-y-0">
<aside className="-mx-4 lg:w-1/5">
<SidebarNav items={sidebarNavItems} disabled={disabled} />
</aside>

View file

@ -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<GetResourceResponse>) => void;
updateAuthInfo: (
updatedAuthInfo: Partial<GetResourceAuthInfoResponse>
) => void;
}
const ResourceContext = createContext<ResourceContextType | undefined>(

View file

@ -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<GetResourceResponse>(serverResource);
const [authInfo, setAuthInfo] =
useState<GetResourceAuthInfoResponse>(serverAuthInfo);
const updateResource = (updatedResource: Partial<GetResourceResponse>) => {
if (!resource) {
throw new Error("No resource to update");
@ -33,8 +39,29 @@ export function ResourceProvider({
});
};
const updateAuthInfo = (
updatedAuthInfo: Partial<GetResourceAuthInfoResponse>
) => {
if (!authInfo) {
throw new Error("No auth info to update");
}
setAuthInfo((prev) => {
if (!prev) {
return prev;
}
return {
...prev,
...updatedAuthInfo,
};
});
};
return (
<ResourceContext.Provider value={{ resource, updateResource }}>
<ResourceContext.Provider
value={{ resource, updateResource, authInfo, updateAuthInfo }}
>
{children}
</ResourceContext.Provider>
);