disable eslint - new colors, and slimmer buttons/inputs??

This commit is contained in:
Milo Schwartz 2024-11-22 22:09:40 -05:00
parent bf04deb038
commit 5388c5d5b4
No known key found for this signature in database
20 changed files with 788 additions and 553 deletions

View file

@ -1,6 +0,0 @@
{
"extends": [
"next/core-web-vitals",
"next/typescript"
]
}

View file

@ -88,8 +88,6 @@
"drizzle-kit": "0.24.2", "drizzle-kit": "0.24.2",
"esbuild": "0.20.1", "esbuild": "0.20.1",
"esbuild-node-externals": "1.13.0", "esbuild-node-externals": "1.13.0",
"eslint": "^8",
"eslint-config-next": "15.0.1",
"postcss": "^8", "postcss": "^8",
"react-email": "3.0.1", "react-email": "3.0.1",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",

View file

@ -90,7 +90,7 @@ export async function verifyResourceSession(
return allowed(res); return allowed(res);
} }
const redirectUrl = `${config.app.base_url}/auth/resource/${resource.resourceId}/login?redirect=${originalRequestURL}`; const redirectUrl = `${config.app.base_url}/${resource.orgId}/auth/resource/${resource.resourceId}?redirect=${originalRequestURL}`;
if (sso && sessions.session) { if (sso && sessions.session) {
const { session, user } = await validateSessionToken( const { session, user } = await validateSessionToken(

View file

@ -15,7 +15,7 @@ const setResourceAuthMethodsParamsSchema = z.object({
const setResourceAuthMethodsBodySchema = z const setResourceAuthMethodsBodySchema = z
.object({ .object({
password: z.string().nullish(), password: z.string().min(4).max(100).nullable(),
}) })
.strict(); .strict();

View file

@ -0,0 +1,205 @@
"use client";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { LockIcon, KeyIcon, UserIcon, Binary, Key, User } from "lucide-react";
const pinSchema = z.object({
pin: z
.string()
.length(6, { message: "PIN must be exactly 6 digits" })
.regex(/^\d+$/, { message: "PIN must only contain numbers" }),
});
const passwordSchema = z.object({
email: z.string().email({ message: "Please enter a valid email address" }),
password: z
.string()
.min(8, { message: "Password must be at least 8 characters long" }),
});
export default function ResourceAuthPortal() {
const [activeTab, setActiveTab] = useState("pin");
const pinForm = useForm<z.infer<typeof pinSchema>>({
resolver: zodResolver(pinSchema),
defaultValues: {
pin: "",
},
});
const passwordForm = useForm<z.infer<typeof passwordSchema>>({
resolver: zodResolver(passwordSchema),
defaultValues: {
email: "",
password: "",
},
});
const onPinSubmit = (values: z.infer<typeof pinSchema>) => {
console.log("PIN authentication", values);
// Implement PIN authentication logic here
};
const onPasswordSubmit = (values: z.infer<typeof passwordSchema>) => {
console.log("Password authentication", values);
// Implement password authentication logic here
};
const handleSSOAuth = () => {
console.log("SSO authentication");
// Implement SSO authentication logic here
};
return (
<div className="w-full max-w-md mx-auto">
<Card>
<CardHeader>
<CardTitle>Welcome Back</CardTitle>
<CardDescription>
Choose your preferred login method
</CardDescription>
</CardHeader>
<CardContent>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="pin">
<Binary className="w-4 h-4 mr-1" /> PIN
</TabsTrigger>
<TabsTrigger value="password">
<Key className="w-4 h-4 mr-1" /> Password
</TabsTrigger>
<TabsTrigger value="sso">
<User className="w-4 h-4 mr-1" /> SSO
</TabsTrigger>
</TabsList>
<TabsContent value="pin">
<Form {...pinForm}>
<form
onSubmit={pinForm.handleSubmit(onPinSubmit)}
className="space-y-4"
>
<FormField
control={pinForm.control}
name="pin"
render={({ field }) => (
<FormItem>
<FormLabel>Enter PIN</FormLabel>
<FormControl>
<Input
placeholder="Enter your 6-digit PIN"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
<KeyIcon className="w-4 h-4 mr-2" />
Login with PIN
</Button>
</form>
</Form>
</TabsContent>
<TabsContent value="password">
<Form {...passwordForm}>
<form
onSubmit={passwordForm.handleSubmit(
onPasswordSubmit
)}
className="space-y-4"
>
<FormField
control={passwordForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Enter your email"
type="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={passwordForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
placeholder="Enter your password"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
<LockIcon className="w-4 h-4 mr-2" />
Login with Password
</Button>
</form>
</Form>
</TabsContent>
<TabsContent value="sso">
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Click the button below to login with your
organization's SSO provider.
</p>
<Button
onClick={handleSSOAuth}
className="w-full"
>
<UserIcon className="w-4 h-4 mr-2" />
Login with SSO
</Button>
</div>
</TabsContent>
</Tabs>
</CardContent>
<CardFooter className="flex justify-center">
<p className="text-sm text-muted-foreground">
Don't have an account?{" "}
<a href="#" className="underline">
Sign up
</a>
</p>
</CardFooter>
</Card>
</div>
);
}

View file

@ -0,0 +1,17 @@
import ResourceAuthPortal from "./components/ResourceAuthPortal";
export default async function ResourceAuthPage(props: {
params: Promise<{ resourceId: number; orgId: string }>;
}) {
const params = await props.params;
console.log(params);
return (
<>
<div className="p-3 md:mt-32">
<ResourceAuthPortal />
</div>
</>
);
}

View file

@ -32,22 +32,22 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListUsersResponse } from "@server/routers/user"; import { ListUsersResponse } from "@server/routers/user";
import { Switch } from "@app/components/ui/switch"; import { Switch } from "@app/components/ui/switch";
import { Label } from "@app/components/ui/label"; import { Label } from "@app/components/ui/label";
import { Input } from "@app/components/ui/input";
import { ShieldCheck } from "lucide-react"; import { ShieldCheck } from "lucide-react";
import SetResourcePasswordForm from "./components/SetResourcePasswordForm"; import SetResourcePasswordForm from "./components/SetResourcePasswordForm";
import { Separator } from "@app/components/ui/separator";
const UsersRolesFormSchema = z.object({ const UsersRolesFormSchema = z.object({
roles: z.array( roles: z.array(
z.object({ z.object({
id: z.string(), id: z.string(),
text: z.string(), text: z.string(),
}) }),
), ),
users: z.array( users: z.array(
z.object({ z.object({
id: z.string(), id: z.string(),
text: z.string(), text: z.string(),
}) }),
), ),
}); });
@ -60,10 +60,10 @@ export default function ResourceAuthenticationPage() {
const [pageLoading, setPageLoading] = useState(true); const [pageLoading, setPageLoading] = useState(true);
const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>( const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>(
[] [],
); );
const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>( const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>(
[] [],
); );
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
number | null number | null
@ -73,7 +73,7 @@ export default function ResourceAuthenticationPage() {
>(null); >(null);
const [ssoEnabled, setSsoEnabled] = useState(resource.sso); const [ssoEnabled, setSsoEnabled] = useState(resource.sso);
const [blockAccess, setBlockAccess] = useState(resource.blockAccess); // const [blockAccess, setBlockAccess] = useState(resource.blockAccess);
const [loadingSaveUsersRoles, setLoadingSaveUsersRoles] = useState(false); const [loadingSaveUsersRoles, setLoadingSaveUsersRoles] = useState(false);
const [loadingRemoveResourcePassword, setLoadingRemoveResourcePassword] = const [loadingRemoveResourcePassword, setLoadingRemoveResourcePassword] =
@ -96,16 +96,16 @@ export default function ResourceAuthenticationPage() {
resourceUsersResponse, resourceUsersResponse,
] = await Promise.all([ ] = await Promise.all([
api.get<AxiosResponse<ListRolesResponse>>( api.get<AxiosResponse<ListRolesResponse>>(
`/org/${org?.org.orgId}/roles` `/org/${org?.org.orgId}/roles`,
), ),
api.get<AxiosResponse<ListResourceRolesResponse>>( api.get<AxiosResponse<ListResourceRolesResponse>>(
`/resource/${resource.resourceId}/roles` `/resource/${resource.resourceId}/roles`,
), ),
api.get<AxiosResponse<ListUsersResponse>>( api.get<AxiosResponse<ListUsersResponse>>(
`/org/${org?.org.orgId}/users` `/org/${org?.org.orgId}/users`,
), ),
api.get<AxiosResponse<ListResourceUsersResponse>>( api.get<AxiosResponse<ListResourceUsersResponse>>(
`/resource/${resource.resourceId}/users` `/resource/${resource.resourceId}/users`,
), ),
]); ]);
@ -115,7 +115,7 @@ export default function ResourceAuthenticationPage() {
id: role.roleId.toString(), id: role.roleId.toString(),
text: role.name, text: role.name,
})) }))
.filter((role) => role.text !== "Admin") .filter((role) => role.text !== "Admin"),
); );
usersRolesForm.setValue( usersRolesForm.setValue(
@ -125,14 +125,14 @@ export default function ResourceAuthenticationPage() {
id: i.roleId.toString(), id: i.roleId.toString(),
text: i.name, text: i.name,
})) }))
.filter((role) => role.text !== "Admin") .filter((role) => role.text !== "Admin"),
); );
setAllUsers( setAllUsers(
usersResponse.data.data.users.map((user) => ({ usersResponse.data.data.users.map((user) => ({
id: user.id.toString(), id: user.id.toString(),
text: user.email, text: user.email,
})) })),
); );
usersRolesForm.setValue( usersRolesForm.setValue(
@ -140,7 +140,7 @@ export default function ResourceAuthenticationPage() {
resourceUsersResponse.data.data.users.map((i) => ({ resourceUsersResponse.data.data.users.map((i) => ({
id: i.userId.toString(), id: i.userId.toString(),
text: i.email, text: i.email,
})) })),
); );
setPageLoading(false); setPageLoading(false);
@ -151,7 +151,7 @@ export default function ResourceAuthenticationPage() {
title: "Failed to fetch data", title: "Failed to fetch data",
description: formatAxiosError( description: formatAxiosError(
e, e,
"An error occurred while fetching the data" "An error occurred while fetching the data",
), ),
}); });
} }
@ -161,7 +161,7 @@ export default function ResourceAuthenticationPage() {
}, []); }, []);
async function onSubmitUsersRoles( async function onSubmitUsersRoles(
data: z.infer<typeof UsersRolesFormSchema> data: z.infer<typeof UsersRolesFormSchema>,
) { ) {
try { try {
setLoadingSaveUsersRoles(true); setLoadingSaveUsersRoles(true);
@ -175,7 +175,6 @@ export default function ResourceAuthenticationPage() {
}), }),
api.post(`/resource/${resource.resourceId}`, { api.post(`/resource/${resource.resourceId}`, {
sso: ssoEnabled, sso: ssoEnabled,
blockAccess,
}), }),
]; ];
@ -200,7 +199,7 @@ export default function ResourceAuthenticationPage() {
title: "Failed to set roles", title: "Failed to set roles",
description: formatAxiosError( description: formatAxiosError(
e, e,
"An error occurred while setting the roles" "An error occurred while setting the roles",
), ),
}); });
} finally { } finally {
@ -231,7 +230,7 @@ export default function ResourceAuthenticationPage() {
title: "Error removing resource password", title: "Error removing resource password",
description: formatAxiosError( description: formatAxiosError(
e, e,
"An error occurred while removing the resource password" "An error occurred while removing the resource password",
), ),
}); });
}) })
@ -258,168 +257,170 @@ export default function ResourceAuthenticationPage() {
/> />
)} )}
<div className="space-y-6 lg:max-w-2xl"> <div className="space-y-12 lg:max-w-2xl">
{/* <div> <section className="space-y-6">
<div className="flex items-center space-x-2 mb-2"> <SettingsSectionTitle
<Switch title="Users & Roles"
id="block-toggle" description="Configure who can visit this resource (only applicable if SSO is used)"
defaultChecked={resource.blockAccess} size="1xl"
onCheckedChange={(val) => setBlockAccess(val)} />
/>
<Label htmlFor="block-toggle">Block Access</Label> <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> </div>
<span className="text-muted-foreground text-sm">
When enabled, this will prevent anyone from accessing
the resource including SSO users.
</span>
</div> */}
<SettingsSectionTitle <Form {...usersRolesForm}>
title="Users & Roles" <form
description="Configure who can visit this resource (only applicable if SSO is used)" onSubmit={usersRolesForm.handleSubmit(
size="1xl" onSubmitUsersRoles,
/>
<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(
onSubmitUsersRoles
)}
className="space-y-6"
>
<FormField
control={usersRolesForm.control}
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>Roles</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={activeRolesTagIndex}
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder="Enter a role"
tags={
usersRolesForm.getValues().roles
}
setTags={(newRoles) => {
usersRolesForm.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",
inlineTagsContainer:
"bg-transparent",
}}
/>
</FormControl>
<FormDescription>
Users with these roles will be able to
access this resource. Admins can always
access this resource.
</FormDescription>
<FormMessage />
</FormItem>
)} )}
/> className="space-y-6"
<FormField
control={usersRolesForm.control}
name="users"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>Users</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={activeUsersTagIndex}
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder="Enter a user"
tags={
usersRolesForm.getValues().users
}
setTags={(newUsers) => {
usersRolesForm.setValue(
"users",
newUsers as [Tag, ...Tag[]]
);
}}
enableAutocomplete={true}
autocompleteOptions={allUsers}
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",
inlineTagsContainer:
"bg-transparent",
}}
/>
</FormControl>
<FormDescription>
Users added here will be able to access
this resource. A user will always have
access to a resource if they have a role
that has access to it.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
loading={loadingSaveUsersRoles}
disabled={loadingSaveUsersRoles}
> >
Save Users & Roles <FormField
</Button> control={usersRolesForm.control}
</form> name="roles"
</Form> render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>Roles</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeRolesTagIndex
}
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder="Enter a role"
tags={
usersRolesForm.getValues()
.roles
}
setTags={(newRoles) => {
usersRolesForm.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 py-2 px-3 rounded-full",
},
input: "border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent",
}}
/>
</FormControl>
<FormDescription>
Users with these roles will be able
to access this resource. Admins can
always access this resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={usersRolesForm.control}
name="users"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>Users</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeUsersTagIndex
}
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder="Enter a user"
tags={
usersRolesForm.getValues()
.users
}
setTags={(newUsers) => {
usersRolesForm.setValue(
"users",
newUsers as [
Tag,
...Tag[],
],
);
}}
enableAutocomplete={true}
autocompleteOptions={allUsers}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full",
},
input: "border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent",
}}
/>
</FormControl>
<FormDescription>
Users added here will be able to
access this resource. A user will
always have access to a resource if
they have a role that has access to
it.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
loading={loadingSaveUsersRoles}
disabled={loadingSaveUsersRoles}
>
Save Users & Roles
</Button>
</form>
</Form>
</section>
<SettingsSectionTitle <Separator />
title="Authentication Methods"
description="You can also allow users to access the resource via the below methods" <section className="space-y-6">
size="1xl" <SettingsSectionTitle
/> title="Authentication Methods"
description="You can also allow users to access the resource via the below methods"
size="1xl"
/>
<div>
{authInfo?.password ? ( {authInfo?.password ? (
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="flex items-center text-green-500 space-x-2"> <div className="flex items-center text-green-500 space-x-2">
@ -447,7 +448,7 @@ export default function ResourceAuthenticationPage() {
</Button> </Button>
</div> </div>
)} )}
</div> </section>
</div> </div>
</> </>
); );

View file

@ -39,45 +39,66 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
}; };
return ( return (
<Card> <Card className="shadow-none">
<Alert> <Alert>
<InfoIcon className="h-4 w-4" /> <InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold"> <AlertTitle className="font-semibold">
Resource Information Resource Information
</AlertTitle> </AlertTitle>
<AlertDescription className="mt-3"> <AlertDescription className="mt-3">
<p className="mb-2"> <div className="space-y-3">
The current full URL for this resource is: <div>
</p> {authInfo.password ||
<div className="flex items-center space-x-2 bg-muted p-2 rounded-md"> authInfo.pincode ||
<LinkIcon className="h-4 w-4" /> authInfo.sso ? (
<a <div className="flex items-center space-x-2 text-green-500">
href={fullUrl} <ShieldCheck />
target="_blank" <span>
rel="noopener noreferrer" This resource is protected with at least
className="text-sm font-mono flex-grow hover:underline truncate" one auth method.
> </span>
{fullUrl} </div>
</a>
<Button
variant="outline"
size="sm"
onClick={copyToClipboard}
className="ml-2"
type="button"
>
{copied ? (
<CheckIcon className="h-4 w-4 text-green-500" />
) : ( ) : (
<CopyIcon className="h-4 w-4" /> <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>
)} )}
<span className="ml-2"> </div>
{copied ? "Copied!" : "Copy"}
</span>
</Button>
</div>
{/* <p className="mt-3"> <div className="flex items-center space-x-2 bg-muted p-1 pl-3 rounded-md">
<LinkIcon className="h-4 w-4" />
<a
href={fullUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-mono flex-grow hover:underline truncate"
>
{fullUrl}
</a>
<Button
variant="outline"
size="sm"
onClick={copyToClipboard}
className="ml-2"
type="button"
>
{copied ? (
<CheckIcon className="h-4 w-4 text-green-500" />
) : (
<CopyIcon className="h-4 w-4" />
)}
<span className="ml-2">
{copied ? "Copied!" : "Copy"}
</span>
</Button>
</div>
{/* <p className="mt-3">
To create a proxy to your private services,{" "} To create a proxy to your private services,{" "}
<Link <Link
href={`/${org.org.orgId}/settings/resources/${resource.resourceId}/connectivity`} href={`/${org.org.orgId}/settings/resources/${resource.resourceId}/connectivity`}
@ -87,27 +108,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</Link>{" "} </Link>{" "}
to this resource 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> </div>
</AlertDescription> </AlertDescription>
</Alert> </Alert>

View file

@ -51,6 +51,7 @@ import { useResourceContext } from "@app/hooks/useResourceContext";
import { ArrayElement } from "@server/types/ArrayElement"; import { ArrayElement } from "@server/types/ArrayElement";
import { Dot } from "lucide-react"; import { Dot } from "lucide-react";
import { formatAxiosError } from "@app/lib/utils"; import { formatAxiosError } from "@app/lib/utils";
import { Separator } from "@radix-ui/react-separator";
const addTargetSchema = z.object({ const addTargetSchema = z.object({
ip: z.string().ip(), ip: z.string().ip(),
@ -104,7 +105,7 @@ export default function ReverseProxyTargets(props: {
const fetchSites = async () => { const fetchSites = async () => {
try { try {
const res = await api.get<AxiosResponse<ListTargetsResponse>>( const res = await api.get<AxiosResponse<ListTargetsResponse>>(
`/resource/${params.resourceId}/targets` `/resource/${params.resourceId}/targets`,
); );
if (res.status === 200) { if (res.status === 200) {
@ -117,7 +118,7 @@ export default function ReverseProxyTargets(props: {
title: "Failed to fetch targets", title: "Failed to fetch targets",
description: formatAxiosError( description: formatAxiosError(
err, err,
"An error occurred while fetching targets" "An error occurred while fetching targets",
), ),
}); });
} finally { } finally {
@ -155,8 +156,8 @@ export default function ReverseProxyTargets(props: {
targets.map((target) => targets.map((target) =>
target.targetId === targetId target.targetId === targetId
? { ...target, ...data, updated: true } ? { ...target, ...data, updated: true }
: target : target,
) ),
); );
} }
@ -186,7 +187,7 @@ export default function ReverseProxyTargets(props: {
} else if (target.updated) { } else if (target.updated) {
const res = await api.post( const res = await api.post(
`/target/${target.targetId}`, `/target/${target.targetId}`,
data data,
); );
} }
@ -204,7 +205,7 @@ export default function ReverseProxyTargets(props: {
for (const targetId of targetsToRemove) { for (const targetId of targetsToRemove) {
await api.delete(`/target/${targetId}`); await api.delete(`/target/${targetId}`);
setTargets( setTargets(
targets.filter((target) => target.targetId !== targetId) targets.filter((target) => target.targetId !== targetId),
); );
} }
@ -221,7 +222,7 @@ export default function ReverseProxyTargets(props: {
title: "Operation failed", title: "Operation failed",
description: formatAxiosError( description: formatAxiosError(
err, err,
"An error occurred during the save operation" "An error occurred during the save operation",
), ),
}); });
} }
@ -346,110 +347,123 @@ export default function ReverseProxyTargets(props: {
} }
return ( return (
<div> <>
<div className="space-y-6"> <div className="space-y-12">
<SettingsSectionTitle <section className="space-y-6 lg:max-w-2xl">
title="SSL" <SettingsSectionTitle
description="Setup SSL to secure your connections with LetsEncrypt certificates" title="SSL"
size="1xl" description="Setup SSL to secure your connections with LetsEncrypt certificates"
/> size="1xl"
<div className="flex items-center space-x-2">
<Switch
id="ssl-toggle"
defaultChecked={resource.ssl}
onCheckedChange={(val) => setSslEnabled(val)}
/> />
<Label htmlFor="ssl-toggle">Enable SSL (https)</Label>
</div>
<SettingsSectionTitle <div className="flex items-center space-x-2">
title="Targets" <Switch
description="Setup targets to route traffic to your services" id="ssl-toggle"
size="1xl" defaultChecked={resource.ssl}
/> onCheckedChange={(val) => setSslEnabled(val)}
/>
<Label htmlFor="ssl-toggle">Enable SSL (https)</Label>
</div>
</section>
<Form {...addTargetForm}> <hr className="lg:max-w-2xl" />
<form
onSubmit={addTargetForm.handleSubmit(addTarget as any)} <section className="space-y-6">
> <SettingsSectionTitle
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> title="Targets"
<FormField description="Setup targets to route traffic to your services"
control={addTargetForm.control} size="1xl"
name="ip" />
render={({ field }) => (
<FormItem> <div className="space-y-6">
<FormLabel>IP Address</FormLabel> <Form {...addTargetForm}>
<FormControl> <form
<Input id="ip" {...field} /> onSubmit={addTargetForm.handleSubmit(
</FormControl> addTarget as any,
<FormDescription>
Enter the IP address of the target
</FormDescription>
<FormMessage />
</FormItem>
)} )}
/> >
<FormField <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
control={addTargetForm.control} <FormField
name="method" control={addTargetForm.control}
render={({ field }) => ( name="ip"
<FormItem> render={({ field }) => (
<FormLabel>Method</FormLabel> <FormItem>
<FormControl> <FormLabel>
<Select IP Address
{...field} </FormLabel>
onValueChange={(value) => { <FormControl>
addTargetForm.setValue( <Input id="ip" {...field} />
"method", </FormControl>
value <FormDescription>
); Enter the IP address of the
}} target.
> </FormDescription>
<SelectTrigger id="method"> <FormMessage />
<SelectValue placeholder="Select method" /> </FormItem>
</SelectTrigger> )}
<SelectContent> />
<SelectItem value="http"> <FormField
HTTP control={addTargetForm.control}
</SelectItem> name="method"
<SelectItem value="https"> render={({ field }) => (
HTTPS <FormItem>
</SelectItem> <FormLabel>Method</FormLabel>
</SelectContent> <FormControl>
</Select> <Select
</FormControl> {...field}
<FormDescription> onValueChange={(
Choose the method for how the target value,
is accessed ) => {
</FormDescription> addTargetForm.setValue(
<FormMessage /> "method",
</FormItem> value,
)} );
/> }}
<FormField >
control={addTargetForm.control} <SelectTrigger id="method">
name="port" <SelectValue placeholder="Select method" />
render={({ field }) => ( </SelectTrigger>
<FormItem> <SelectContent>
<FormLabel>Port</FormLabel> <SelectItem value="http">
<FormControl> HTTP
<Input </SelectItem>
id="port" <SelectItem value="https">
type="number" HTTPS
{...field} </SelectItem>
required </SelectContent>
/> </Select>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Specify the port number for the Choose the method for how
target the target is accessed.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{/* <FormField <FormField
control={addTargetForm.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
id="port"
type="number"
{...field}
required
/>
</FormControl>
<FormDescription>
Specify the port number for
the target.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* <FormField
control={addTargetForm.control} control={addTargetForm.control}
name="protocol" name="protocol"
render={({ field }) => ( render={({ field }) => (
@ -486,64 +500,85 @@ export default function ReverseProxyTargets(props: {
</FormItem> </FormItem>
)} )}
/> */} /> */}
</div> </div>
<Button type="submit" variant="gray"> <Button type="submit" variant="gray">
Add Target Add Target
</Button> </Button>
</form> </form>
</Form> </Form>
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table
<TableRow key={headerGroup.id}> .getHeaderGroups()
{headerGroup.headers.map((header) => ( .map((headerGroup) => (
<TableHead key={header.id}> <TableRow key={headerGroup.id}>
{header.isPlaceholder {headerGroup.headers.map(
? null (header) => (
: flexRender( <TableHead
header.column.columnDef key={header.id}
.header, >
header.getContext() {header.isPlaceholder
)} ? null
</TableHead> : flexRender(
))} header
</TableRow> .column
))} .columnDef
</TableHeader> .header,
<TableBody> header.getContext(),
{table.getRowModel().rows?.length ? ( )}
table.getRowModel().rows.map((row) => ( </TableHead>
<TableRow key={row.id}> ),
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)} )}
</TableCell> </TableRow>
))} ))}
</TableRow> </TableHeader>
)) <TableBody>
) : ( {table.getRowModel().rows?.length ? (
<TableRow> table.getRowModel().rows.map((row) => (
<TableCell <TableRow key={row.id}>
colSpan={columns.length} {row
className="h-24 text-center" .getVisibleCells()
> .map((cell) => (
No targets. Add a target using the form. <TableCell
</TableCell> key={cell.id}
</TableRow> >
)} {flexRender(
</TableBody> cell.column
</Table> .columnDef
</div> .cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No targets. Add a target using
the form.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<Button onClick={saveAll} loading={loading} disabled={loading}> <Button
Save Changes onClick={saveAll}
</Button> loading={loading}
disabled={loading}
>
Save Changes
</Button>
</div>
</section>
</div> </div>
</div> </>
); );
} }

View file

@ -118,69 +118,65 @@ export default function GeneralForm() {
return ( return (
<> <>
<div className="lg:max-w-2xl space-y-6"> <div className="lg:max-w-2xl space-y-12">
<SettingsSectionTitle <section className="space-y-6">
title="General Settings" <SettingsSectionTitle
description="Configure the general settings for this resource" title="General Settings"
size="1xl" description="Configure the general settings for this resource"
/> size="1xl"
/>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6" className="space-y-6"
> >
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel>Name</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
This is the display name of the resource This is the display name of the
</FormDescription> resource.
<FormMessage /> </FormDescription>
</FormItem> <FormMessage />
)} </FormItem>
/> )}
/>
<SettingsSectionTitle <FormField
title="Domain" control={form.control}
description="Define the domain that users will use to access this resource" name="subdomain"
size="1xl" render={({ field }) => (
/> <FormItem>
<FormLabel>Subdomain</FormLabel>
<FormField <FormControl>
control={form.control} <CustomDomainInput
name="subdomain" value={field.value}
render={({ field }) => ( domainSuffix={domainSuffix}
<FormItem> placeholder="Enter subdomain"
<FormLabel>Subdomain</FormLabel> onChange={(value) =>
<FormControl> form.setValue(
<CustomDomainInput "subdomain",
value={field.value} value
domainSuffix={domainSuffix} )
placeholder="Enter subdomain" }
onChange={(value) => />
form.setValue( </FormControl>
"subdomain", <FormDescription>
value This is the subdomain that will be
) used to access the resource.
} </FormDescription>
/> <FormMessage />
</FormControl> </FormItem>
{/* <FormDescription> )}
This is the subdomain that will be used />
to access the resource {/* <FormField
</FormDescription> */}
<FormMessage />
</FormItem>
)}
/>
{/* <FormField
control={form.control} control={form.control}
name="siteId" name="siteId"
render={({ field }) => ( render={({ field }) => (
@ -257,15 +253,16 @@ export default function GeneralForm() {
</FormItem> </FormItem>
)} )}
/> */} /> */}
<Button <Button
type="submit" type="submit"
loading={saveLoading} loading={saveLoading}
disabled={saveLoading} disabled={saveLoading}
> >
Save Changes Save Changes
</Button> </Button>
</form> </form>
</Form> </Form>
</section>
</div> </div>
</> </>
); );

View file

@ -116,7 +116,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
sidebarNavItems={sidebarNavItems} sidebarNavItems={sidebarNavItems}
limitWidth={false} limitWidth={false}
> >
<div className="mb-8"> <div className="mb-8 lg:max-w-2xl">
<ResourceInfoBox /> <ResourceInfoBox />
</div> </div>
{children} {children}

View file

@ -206,6 +206,7 @@ sh get-docker.sh`;
<FormLabel>Name</FormLabel> <FormLabel>Name</FormLabel>
<FormControl> <FormControl>
<Input <Input
autoComplete="off"
placeholder="Site name" placeholder="Site name"
{...field} {...field}
/> />

View file

@ -2,83 +2,65 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root { :root {
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 211.58 18.45% 20.2%; --foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--primary: 24.6 95% 53.1%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--ring: 24.6 95% 53.1%;
--radius: 0.75rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
--primary: 14.59 24.83% 29.22%; .dark {
--primary-foreground: 0 0% 100%; --background: 20 14.3% 4.1%;
--foreground: 60 9.1% 97.8%;
--card: 20 15.79% 96.27%; --card: 20 14.3% 4.1%;
--card-foreground: 0 0% 0%; --card-foreground: 60 9.1% 97.8%;
--popover: 20 14.3% 4.1%;
--popover: 0 0% 100%; --popover-foreground: 60 9.1% 97.8%;
--popover-foreground: 30 28.57% 2.75%; --primary: 20.5 90.2% 48.2%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 25 16.07% 43.92%; --secondary: 12 6.5% 15.1%;
--secondary-foreground: 0 0% 100%; --secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 15.1%;
--muted: 20 15.79% 96.27%; --muted-foreground: 24 5.4% 63.9%;
--muted-foreground: 0 0% 34.12%; --accent: 12 6.5% 15.1%;
--accent-foreground: 60 9.1% 97.8%;
--accent: 0 0% 86.67%; --destructive: 0 72.2% 50.6%;
--accent-foreground: 24 23.81% 4.12%; --destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 15.1%;
--destructive: 0 84.2% 60.2%; --input: 12 6.5% 15.1%;
--destructive-foreground: 210 40% 98%; --ring: 20.5 90.2% 48.2%;
--chart-1: 220 70% 50%;
--border: 12 6.67% 85.29%; --chart-2: 160 60% 45%;
--input: 12 6.67% 85.29%; --chart-3: 30 80% 55%;
--ring: 24.71 31.29% 31.96%; --chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--chart-1: 23.64 23.74% 27.25%; }
--chart-2: 23.57 14.43% 38.04%;
--chart-3: 22.86 8.71% 52.75%;
--chart-4: 23.33 8.82% 60%;
--chart-5: 24 8.98% 67.25%;
--radius: 0.35rem;
}
.dark {
--background: 0 0% 11.76%;
--foreground: 204 6.67% 85.29%;
--primary: 14.21 25.68% 29.02%;
--primary-foreground: 228 13.51% 92.75%;
--card: 0 0% 9.41%;
--card-foreground: 204 6.67% 85.29%;
--popover: 0 0% 11.76%;
--popover-foreground: 24 9.09% 89.22%;
--secondary: 12.63 15.97% 23.33%;
--secondary-foreground: 0 0% 100%;
--muted: 0 0% 9.41%;
--muted-foreground: 212.73 5.31% 59.41%;
--accent: 0 2.17% 18.04%;
--accent-foreground: 24 9.09% 89.22%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 240 2.86% 27.45%;
--input: 240 2.86% 27.45%;
--ring: 23.64 23.74% 27.25%;
--chart-1: 23.64 23.74% 27.25%;
--chart-2: 23.57 23.73% 23.14%;
--chart-3: 24.55 24.44% 17.65%;
--chart-4: 23.33 23.68% 14.9%;
--chart-5: 24 23.81% 12.35%;
--radius: 0.35rem;
}
} }
@layer base { @layer base {
* { * {
@apply border-border; @apply border-border;

View file

@ -1,6 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import { Fira_Sans, Inter, Noto_Sans_Mono, Roboto_Mono } from "next/font/google"; import { IBM_Plex_Sans, Work_Sans } from "next/font/google";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@app/providers/ThemeProvider"; import { ThemeProvider } from "@app/providers/ThemeProvider";
@ -9,8 +9,11 @@ export const metadata: Metadata = {
description: "", description: "",
}; };
const font = Inter({ subsets: ["latin"] }); // const font = Inter({ subsets: ["latin"] });
// const font = Noto_Sans_Mono({ subsets: ["latin"] }); // const font = Noto_Sans_Mono({ subsets: ["latin"] });
const font = Work_Sans({ subsets: ["latin"] });
// const font = Space_Grotesk({subsets: ["latin"]})
// const font = IBM_Plex_Sans({subsets: ["latin"], weight: "400"})
export default async function RootLayout({ export default async function RootLayout({
children, children,

View file

@ -10,7 +10,9 @@ export default function SettingsSectionTitle({
size, size,
}: SettingsSectionTitleProps) { }: SettingsSectionTitleProps) {
return ( return (
<div className="space-y-0.5 select-none mb-6"> <div
className={`space-y-0.5 select-none ${!size || size === "2xl" ? "mb-12" : ""}`}
>
<h2 <h2
className={`text-${ className={`text-${
size ? size : "2xl" size ? size : "2xl"

View file

@ -81,7 +81,7 @@ export function SidebarNav({
</div> </div>
<nav <nav
className={cn( className={cn(
"hidden lg:flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1", "hidden lg:flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-3",
disabled && "opacity-50 pointer-events-none", disabled && "opacity-50 pointer-events-none",
className className
)} )}

View file

@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const alertVariants = cva( const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", "relative w-full rounded-lg p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{ {
variants: { variants: {
variant: { variant: {

View file

@ -23,10 +23,10 @@ const buttonVariants = cva(
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-10 px-4 py-2", default: "h-9 px-4 py-2",
sm: "h-9 rounded-md px-3", sm: "h-8 rounded-md px-3",
lg: "h-11 rounded-md px-8", lg: "h-10 rounded-md px-8",
icon: "h-10 w-10", icon: "h-9 w-9",
}, },
}, },
defaultVariants: { defaultVariants: {

View file

@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
ref={ref} ref={ref}

View file

@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", "flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className className
)} )}
{...props} {...props}