Merge branch 'main' into holepunch

This commit is contained in:
Owen
2025-03-10 21:13:05 -04:00
129 changed files with 21424 additions and 2236 deletions

View File

@@ -7,7 +7,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
@@ -24,11 +24,11 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -40,13 +40,13 @@ type CreateRoleFormProps = {
const formSchema = z.object({
name: z.string({ message: "Name is required" }).max(32),
description: z.string().max(255).optional(),
description: z.string().max(255).optional()
});
export default function CreateRoleForm({
open,
setOpen,
afterCreate,
afterCreate
}: CreateRoleFormProps) {
const { org } = useOrgContext();
@@ -58,8 +58,8 @@ export default function CreateRoleForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
description: "",
},
description: ""
}
});
async function onSubmit(values: z.infer<typeof formSchema>) {
@@ -70,7 +70,7 @@ export default function CreateRoleForm({
`/org/${org?.org.orgId}/role`,
{
name: values.name,
description: values.description,
description: values.description
} as CreateRoleBody
)
.catch((e) => {
@@ -80,7 +80,7 @@ export default function CreateRoleForm({
description: formatAxiosError(
e,
"An error occurred while creating the role."
),
)
});
});
@@ -88,7 +88,7 @@ export default function CreateRoleForm({
toast({
variant: "default",
title: "Role created",
description: "The role has been successfully created.",
description: "The role has been successfully created."
});
if (open) {
@@ -135,10 +135,7 @@ export default function CreateRoleForm({
<FormItem>
<FormLabel>Role Name</FormLabel>
<FormControl>
<Input
placeholder="Enter name for the role"
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -151,10 +148,7 @@ export default function CreateRoleForm({
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder="Describe the role"
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -164,6 +158,9 @@ export default function CreateRoleForm({
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="create-role-form"
@@ -172,9 +169,6 @@ export default function CreateRoleForm({
>
Create Role
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -7,7 +7,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -23,7 +23,7 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { ListRolesResponse } from "@server/routers/role";
@@ -32,10 +32,10 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectValue
} from "@app/components/ui/select";
import { RoleRow } from "./RolesTable";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -47,14 +47,14 @@ type CreateRoleFormProps = {
};
const formSchema = z.object({
newRoleId: z.string({ message: "New role is required" }),
newRoleId: z.string({ message: "New role is required" })
});
export default function DeleteRoleForm({
open,
roleToDelete,
setOpen,
afterDelete,
afterDelete
}: CreateRoleFormProps) {
const { org } = useOrgContext();
@@ -66,9 +66,9 @@ export default function DeleteRoleForm({
useEffect(() => {
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(
`/org/${org?.org.orgId}/roles`
)
.get<
AxiosResponse<ListRolesResponse>
>(`/org/${org?.org.orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
@@ -77,7 +77,7 @@ export default function DeleteRoleForm({
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
),
)
});
});
@@ -96,8 +96,8 @@ export default function DeleteRoleForm({
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
newRoleId: "",
},
newRoleId: ""
}
});
async function onSubmit(values: z.infer<typeof formSchema>) {
@@ -106,8 +106,8 @@ export default function DeleteRoleForm({
const res = await api
.delete(`/role/${roleToDelete.roleId}`, {
data: {
roleId: values.newRoleId,
},
roleId: values.newRoleId
}
})
.catch((e) => {
toast({
@@ -116,7 +116,7 @@ export default function DeleteRoleForm({
description: formatAxiosError(
e,
"An error occurred while removing the role."
),
)
});
});
@@ -124,7 +124,7 @@ export default function DeleteRoleForm({
toast({
variant: "default",
title: "Role removed",
description: "The role has been successfully removed.",
description: "The role has been successfully removed."
});
if (open) {
@@ -214,6 +214,9 @@ export default function DeleteRoleForm({
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="remove-role-form"
@@ -222,9 +225,6 @@ export default function DeleteRoleForm({
>
Remove Role
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -37,7 +37,7 @@ import {
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { ListRolesResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Checkbox } from "@app/components/ui/checkbox";
@@ -194,10 +194,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Enter an email"
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -341,6 +338,9 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="invite-user-form"
@@ -349,9 +349,6 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
>
Create Invitation
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -185,7 +185,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button variant={"outline"} className="ml-2">
<Button variant={"outlinePrimary"} className="ml-2">
Manage
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

View File

@@ -64,7 +64,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
</Breadcrumb>
</div>
<div className="space-y-0.5 select-none mb-6">
<div className="space-y-0.5 mb-6">
<h2 className="text-2xl font-bold tracking-tight">
User {user?.email}
</h2>
@@ -73,7 +73,6 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
<SidebarSettings
sidebarNavItems={sidebarNavItems}
limitWidth={true}
>
{children}
</SidebarSettings>

View File

@@ -210,11 +210,11 @@ export default function GeneralPage() {
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is the display name of the
org
organization.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@@ -238,7 +238,6 @@ export default function GeneralPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
<AlertTriangle className="h-5 w-5" />
Danger Zone
</SettingsSectionTitle>
<SettingsSectionDescription>

View File

@@ -1,6 +1,12 @@
import { Metadata } from "next";
import { TopbarNav } from "@app/components/TopbarNav";
import { Cog, Combine, Laptop, Link, Settings, Users, Waypoints, Workflow } from "lucide-react";
import {
Combine,
LinkIcon,
Settings,
Users,
Waypoints
} from "lucide-react";
import { Header } from "@app/components/Header";
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
@@ -43,7 +49,7 @@ const topNavItems = [
{
title: "Shareable Links",
href: "/{orgId}/settings/share-links",
icon: <Link className="h-4 w-4" />
icon: <LinkIcon className="h-4 w-4" />
},
{
title: "General",
@@ -100,19 +106,23 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return (
<>
<div className="w-full border-b bg-card select-none sm:px-0 px-3 fixed top-0 z-10">
<div className="container mx-auto flex flex-col content-between">
<div className="my-4">
<UserProvider user={user}>
<Header orgId={params.orgId} orgs={orgs} />
</UserProvider>
<div className="w-full bg-card sm:px-0 px-3 fixed top-0 z-10">
<div className="border-b">
<div className="container mx-auto flex flex-col content-between">
<div className="my-4">
<UserProvider user={user}>
<Header orgId={params.orgId} orgs={orgs} />
</UserProvider>
</div>
<TopbarNav items={topNavItems} orgId={params.orgId} />
</div>
<TopbarNav items={topNavItems} orgId={params.orgId} />
</div>
</div>
<div className="container mx-auto sm:px-0 px-3 pt-[165px]">
{children}
<div className="container mx-auto sm:px-0 px-3 pt-[155px]">
<div className="container mx-auto sm:px-0 px-3">
{children}
</div>
</div>
</>
);

File diff suppressed because it is too large Load Diff

View File

@@ -233,7 +233,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
<Link
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<Button variant={"outline"} className="ml-2">
<Button variant={"outlinePrimary"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

View File

@@ -2,27 +2,68 @@
import * as React from "react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
interface DomainOption {
baseDomain: string;
domainId: string;
}
interface CustomDomainInputProps {
domainSuffix: string;
domainOptions: DomainOption[];
selectedDomainId?: string;
placeholder?: string;
value: string;
onChange?: (value: string) => void;
onChange?: (value: string, selectedDomainId: string) => void;
}
export default function CustomDomainInput({
domainSuffix,
placeholder = "Enter subdomain",
domainOptions,
selectedDomainId,
placeholder = "Subdomain",
value: defaultValue,
onChange
}: CustomDomainInputProps) {
const [value, setValue] = React.useState(defaultValue);
const [selectedDomain, setSelectedDomain] = React.useState<DomainOption>();
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
React.useEffect(() => {
if (domainOptions.length) {
if (selectedDomainId) {
const selectedDomainOption = domainOptions.find(
(option) => option.domainId === selectedDomainId
);
setSelectedDomain(selectedDomainOption || domainOptions[0]);
} else {
setSelectedDomain(domainOptions[0]);
}
}
}, [domainOptions]);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!selectedDomain) {
return;
}
const newValue = event.target.value;
setValue(newValue);
if (onChange) {
onChange(newValue);
onChange(newValue, selectedDomain.domainId);
}
};
const handleDomainChange = (domainId: string) => {
const newSelectedDomain =
domainOptions.find((option) => option.domainId === domainId) ||
domainOptions[0];
setSelectedDomain(newSelectedDomain);
if (onChange) {
onChange(value, newSelectedDomain.domainId);
}
};
@@ -33,12 +74,28 @@ export default function CustomDomainInput({
type="text"
placeholder={placeholder}
value={value}
onChange={handleChange}
className="rounded-r-none w-full"
onChange={handleInputChange}
className="w-1/2 mr-1 text-right"
/>
<div className="max-w-1/2 flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-muted-foreground">
<span className="text-sm truncate">.{domainSuffix}</span>
</div>
<Select
onValueChange={handleDomainChange}
value={selectedDomain?.domainId}
defaultValue={selectedDomain?.domainId}
>
<SelectTrigger className="w-1/2 pr-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{domainOptions.map((option) => (
<SelectItem
key={option.domainId}
value={option.domainId}
>
.{option.baseDomain}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);

View File

@@ -1,9 +1,7 @@
"use client";
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { ArrowRight, InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { Separator } from "@app/components/ui/separator";
import CopyToClipboard from "@app/components/CopyToClipboard";
@@ -13,21 +11,14 @@ import {
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import Link from "next/link";
type ResourceInfoBoxType = {};
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
const [copied, setCopied] = useState(false);
const { org } = useOrgContext();
const { resource, authInfo } = useResourceContext();
let fullUrl = `${resource.ssl ? "https" : "http"}://`;
if (resource.isBaseDomain) {
fullUrl = fullUrl + org.org.domain;
} else {
fullUrl = fullUrl + `${resource.subdomain}.${org.org.domain}`;
}
let fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
return (
<Alert>
@@ -36,7 +27,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
Resource Information
</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections>
<InfoSections cols={3}>
{resource.http ? (
<>
<InfoSection>
@@ -50,22 +41,16 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
authInfo.whitelist ? (
<div className="flex items-start space-x-2 text-green-500">
<ShieldCheck className="w-4 h-4 mt-0.5" />
<span>
This resource is protected with
at least one auth method.
</span>
<span>Protected</span>
</div>
) : (
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4" />
<span>
Anyone can access this resource.
</span>
<span>Not Protected</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
<Separator orientation="vertical" />
<InfoSection>
<InfoSectionTitle>URL</InfoSectionTitle>
<InfoSectionContent>
@@ -75,6 +60,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>Site</InfoSectionTitle>
<InfoSectionContent>
{resource.siteName}
</InfoSectionContent>
</InfoSection>
</>
) : (
<>
@@ -86,7 +77,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</span>
</InfoSectionContent>
</InfoSection>
<Separator orientation="vertical" />
<InfoSection>
<InfoSectionTitle>Port</InfoSectionTitle>
<InfoSectionContent>

View File

@@ -8,7 +8,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
@@ -24,22 +24,22 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { Resource } from "@server/db/schema";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
const setPasswordFormSchema = z.object({
password: z.string().min(4).max(100),
password: z.string().min(4).max(100)
});
type SetPasswordFormValues = z.infer<typeof setPasswordFormSchema>;
const defaultValues: Partial<SetPasswordFormValues> = {
password: "",
password: ""
};
type SetPasswordFormProps = {
@@ -53,7 +53,7 @@ export default function SetResourcePasswordForm({
open,
setOpen,
resourceId,
onSetPassword,
onSetPassword
}: SetPasswordFormProps) {
const api = createApiClient(useEnvContext());
@@ -61,7 +61,7 @@ export default function SetResourcePasswordForm({
const form = useForm<SetPasswordFormValues>({
resolver: zodResolver(setPasswordFormSchema),
defaultValues,
defaultValues
});
useEffect(() => {
@@ -76,7 +76,7 @@ export default function SetResourcePasswordForm({
setLoading(true);
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/password`, {
password: data.password,
password: data.password
})
.catch((e) => {
toast({
@@ -85,14 +85,14 @@ export default function SetResourcePasswordForm({
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",
"The resource password has been set successfully"
});
if (onSetPassword) {
@@ -136,17 +136,16 @@ export default function SetResourcePasswordForm({
<Input
autoComplete="off"
type="password"
placeholder="Your secure password"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
Users will be able to access
this resource by entering this
password. It must be at least 4
characters long.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@@ -154,6 +153,9 @@ export default function SetResourcePasswordForm({
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="set-password-form"
@@ -162,9 +164,6 @@ export default function SetResourcePasswordForm({
>
Enable Password Protection
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -8,7 +8,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
@@ -24,27 +24,27 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { Resource } from "@server/db/schema";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSlot
} from "@app/components/ui/input-otp";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
const setPincodeFormSchema = z.object({
pincode: z.string().length(6),
pincode: z.string().length(6)
});
type SetPincodeFormValues = z.infer<typeof setPincodeFormSchema>;
const defaultValues: Partial<SetPincodeFormValues> = {
pincode: "",
pincode: ""
};
type SetPincodeFormProps = {
@@ -58,7 +58,7 @@ export default function SetResourcePincodeForm({
open,
setOpen,
resourceId,
onSetPincode,
onSetPincode
}: SetPincodeFormProps) {
const [loading, setLoading] = useState(false);
@@ -66,7 +66,7 @@ export default function SetResourcePincodeForm({
const form = useForm<SetPincodeFormValues>({
resolver: zodResolver(setPincodeFormSchema),
defaultValues,
defaultValues
});
useEffect(() => {
@@ -81,7 +81,7 @@ export default function SetResourcePincodeForm({
setLoading(true);
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/pincode`, {
pincode: data.pincode,
pincode: data.pincode
})
.catch((e) => {
toast({
@@ -89,15 +89,15 @@ export default function SetResourcePincodeForm({
title: "Error setting resource PIN code",
description: formatAxiosError(
e,
"An error occurred while setting the resource PIN code",
),
"An error occurred while setting the resource PIN code"
)
});
})
.then(() => {
toast({
title: "Resource PIN code set",
description:
"The resource pincode has been set successfully",
"The resource pincode has been set successfully"
});
if (onSetPincode) {
@@ -167,13 +167,13 @@ export default function SetResourcePincodeForm({
</InputOTP>
</div>
</FormControl>
<FormMessage />
<FormDescription>
Users will be able to access
this resource by entering this
PIN code. It must be at least 6
digits long.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@@ -181,6 +181,9 @@ export default function SetResourcePincodeForm({
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="set-pincode-form"
@@ -189,9 +192,6 @@ export default function SetResourcePincodeForm({
>
Enable PIN Code Protection
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -8,14 +8,12 @@ import { useResourceContext } from "@app/hooks/useResourceContext";
import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";
import {
GetResourceAuthInfoResponse,
GetResourceWhitelistResponse,
ListResourceRolesResponse,
ListResourceUsersResponse
} 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 {
@@ -27,12 +25,8 @@ import {
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { TagInput } from "emblor";
// 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 { Binary, Key, ShieldCheck } from "lucide-react";
import { Binary, Key } from "lucide-react";
import SetResourcePasswordForm from "./SetResourcePasswordForm";
import SetResourcePincodeForm from "./SetResourcePincodeForm";
import { createApiClient } from "@app/lib/api";
@@ -44,11 +38,12 @@ import {
SettingsSectionHeader,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
SettingsSectionFooter,
SettingsSectionForm
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { InfoPopup } from "@app/components/ui/info-popup";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { useRouter } from "next/navigation";
const UsersRolesFormSchema = z.object({
@@ -413,7 +408,7 @@ export default function ResourceAuthenticationPage() {
<SwitchInput
id="sso-toggle"
label="Use Platform SSO"
description="Existing users will only have to login once for all resources that have this enabled."
description="Existing users will only have to log in once for all resources that have this enabled."
defaultChecked={resource.sso}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
@@ -435,7 +430,6 @@ export default function ResourceAuthenticationPage() {
<FormItem className="flex flex-col items-start">
<FormLabel>Roles</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
@@ -444,7 +438,8 @@ export default function ResourceAuthenticationPage() {
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder="Enter a role"
placeholder="Select a role"
size="sm"
tags={
usersRolesForm.getValues()
.roles
@@ -473,23 +468,13 @@ export default function ResourceAuthenticationPage() {
true
}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent p-2"
}}
/>
</FormControl>
<FormMessage />
<FormDescription>
These roles will be able
to access this resource.
Admins can always access
this resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@@ -500,7 +485,6 @@ export default function ResourceAuthenticationPage() {
<FormItem className="flex flex-col items-start">
<FormLabel>Users</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
@@ -509,11 +493,12 @@ export default function ResourceAuthenticationPage() {
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder="Enter a user"
placeholder="Select a user"
tags={
usersRolesForm.getValues()
.users
}
size="sm"
setTags={(
newUsers
) => {
@@ -538,25 +523,8 @@ export default function ResourceAuthenticationPage() {
true
}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent p-2"
}}
/>
</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>
)}
@@ -601,7 +569,7 @@ export default function ResourceAuthenticationPage() {
</span>
</div>
<Button
variant="outline"
variant="outlinePrimary"
onClick={
authInfo.password
? removeResourcePassword
@@ -627,7 +595,7 @@ export default function ResourceAuthenticationPage() {
</span>
</div>
<Button
variant="outline"
variant="outlinePrimary"
onClick={
authInfo.pincode
? removeResourcePincode
@@ -683,6 +651,7 @@ export default function ResourceAuthenticationPage() {
activeTagIndex={
activeEmailTagIndex
}
size={"sm"}
validateTag={(
tag
) => {
@@ -727,18 +696,12 @@ export default function ResourceAuthenticationPage() {
false
}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent p-2"
}}
/>
</FormControl>
<FormDescription>
Press enter to add an email after typing it in the input field.
Press enter to add an
email after typing it in
the input field.
</FormDescription>
</FormItem>
)}

View File

@@ -39,6 +39,7 @@ import {
import {
Table,
TableBody,
TableCaption,
TableCell,
TableContainer,
TableHead,
@@ -103,8 +104,8 @@ export default function ReverseProxyTargets(props: {
resolver: zodResolver(addTargetSchema),
defaultValues: {
ip: "",
method: resource.http ? "http" : null
// protocol: "TCP",
method: resource.http ? "http" : null,
port: "" as any as number
} as z.infer<typeof addTargetSchema>
});
@@ -199,7 +200,11 @@ export default function ReverseProxyTargets(props: {
};
setTargets([...targets, newTarget]);
addTargetForm.reset();
addTargetForm.reset({
ip: "",
method: resource.http ? "http" : null,
port: "" as any as number
});
}
const removeTarget = (targetId: number) => {
@@ -241,10 +246,7 @@ export default function ReverseProxyTargets(props: {
>(`/resource/${params.resourceId}/target`, data);
target.targetId = res.data.data.targetId;
} else if (target.updated) {
await api.post(
`/target/${target.targetId}`,
data
);
await api.post(`/target/${target.targetId}`, data);
}
setTargets([
@@ -261,9 +263,7 @@ export default function ReverseProxyTargets(props: {
for (const targetId of targetsToRemove) {
await api.delete(`/target/${targetId}`);
setTargets(
targets.filter((t) => t.targetId !== targetId)
);
setTargets(targets.filter((t) => t.targetId !== targetId));
}
toast({
@@ -421,6 +421,7 @@ export default function ReverseProxyTargets(props: {
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)
@@ -436,7 +437,13 @@ export default function ReverseProxyTargets(props: {
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel()
getFilteredRowModel: getFilteredRowModel(),
state: {
pagination: {
pageIndex: 0,
pageSize: 1000
}
}
});
if (pageLoading) {
@@ -452,8 +459,7 @@ export default function ReverseProxyTargets(props: {
SSL Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Setup SSL to secure your connections with
LetsEncrypt certificates
Set up SSL to secure your connections with certificates
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -475,7 +481,7 @@ export default function ReverseProxyTargets(props: {
Target Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Setup targets to route traffic to your services
Set up targets to route traffic to your services
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -484,7 +490,7 @@ export default function ReverseProxyTargets(props: {
onSubmit={addTargetForm.handleSubmit(addTarget)}
className="space-y-4"
>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-start">
{resource.http && (
<FormField
control={addTargetForm.control}
@@ -517,6 +523,9 @@ export default function ReverseProxyTargets(props: {
<SelectItem value="https">
https
</SelectItem>
<SelectItem value="h2c">
h2c
</SelectItem>
</SelectContent>
</Select>
</FormControl>
@@ -536,18 +545,6 @@ export default function ReverseProxyTargets(props: {
<Input id="ip" {...field} />
</FormControl>
<FormMessage />
{site?.type === "newt" ? (
<FormDescription>
This is the IP or hostname
of the target service on
your network.
</FormDescription>
) : site?.type === "wireguard" ? (
<FormDescription>
This is the IP of the
WireGuard peer.
</FormDescription>
) : null}
</FormItem>
)}
/>
@@ -566,83 +563,68 @@ export default function ReverseProxyTargets(props: {
/>
</FormControl>
<FormMessage />
{site?.type === "newt" ? (
<FormDescription>
This is the port of the
target service on your
network.
</FormDescription>
) : site?.type === "wireguard" ? (
<FormDescription>
This is the port exposed on
an address on the WireGuard
network.
</FormDescription>
) : null}
</FormItem>
)}
/>
<Button
type="submit"
variant="outlinePrimary"
className="mt-8"
>
Add Target
</Button>
</div>
<Button type="submit" variant="outline">
Add Target
</Button>
</form>
</Form>
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column
.columnDef.header,
header.getContext()
)}
</TableHead>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column
.columnDef.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>
</TableContainer>
<p className="text-sm text-muted-foreground">
Adding more than one target above will enable load
balancing.
</p>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No targets. Add a target using the form.
</TableCell>
</TableRow>
)}
</TableBody>
<TableCaption>
Adding more than one target above will enable load
balancing.
</TableCaption>
</Table>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button

View File

@@ -33,7 +33,6 @@ import { useEffect, useState } from "react";
import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
import { toast } from "@app/hooks/useToast";
import {
SettingsContainer,
@@ -53,6 +52,15 @@ import { subdomainSchema } from "@server/lib/schemas";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label";
import { ListDomainsResponse } from "@server/routers/domain";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { UpdateResourceResponse } from "@server/routers/resource";
const GeneralFormSchema = z
.object({
@@ -60,7 +68,8 @@ const GeneralFormSchema = z
name: z.string().min(1).max(255),
proxyPort: z.number().optional(),
http: z.boolean(),
isBaseDomain: z.boolean().optional()
isBaseDomain: z.boolean().optional(),
domainId: z.string().optional()
})
.refine(
(data) => {
@@ -100,6 +109,7 @@ type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
type TransferFormValues = z.infer<typeof TransferFormSchema>;
export default function GeneralForm() {
const [formKey, setFormKey] = useState(0);
const params = useParams();
const { resource, updateResource } = useResourceContext();
const { org } = useOrgContext();
@@ -113,10 +123,13 @@ export default function GeneralForm() {
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [saveLoading, setSaveLoading] = useState(false);
const [domainSuffix, setDomainSuffix] = useState(org.org.domain);
const [transferLoading, setTransferLoading] = useState(false);
const [open, setOpen] = useState(false);
const [baseDomains, setBaseDomains] = useState<
ListDomainsResponse["domains"]
>([]);
const [loadingPage, setLoadingPage] = useState(true);
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
resource.isBaseDomain ? "basedomain" : "subdomain"
);
@@ -128,7 +141,8 @@ export default function GeneralForm() {
subdomain: resource.subdomain ? resource.subdomain : undefined,
proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
http: resource.http,
isBaseDomain: resource.isBaseDomain ? true : false
isBaseDomain: resource.isBaseDomain ? true : false,
domainId: resource.domainId || undefined
},
mode: "onChange"
});
@@ -147,19 +161,54 @@ export default function GeneralForm() {
);
setSites(res.data.data.sites);
};
fetchSites();
const fetchDomains = async () => {
const res = await api
.get<
AxiosResponse<ListDomainsResponse>
>(`/org/${orgId}/domains/`)
.catch((e) => {
toast({
variant: "destructive",
title: "Error fetching domains",
description: formatAxiosError(
e,
"An error occurred when fetching the domains"
)
});
});
if (res?.status === 200) {
const domains = res.data.data.domains;
setBaseDomains(domains);
setFormKey((key) => key + 1);
}
};
const load = async () => {
await fetchDomains();
await fetchSites();
setLoadingPage(false);
};
load();
}, []);
async function onSubmit(data: GeneralFormValues) {
setSaveLoading(true);
const res = await api
.post(`resource/${resource?.resourceId}`, {
name: data.name,
subdomain: data.subdomain,
proxyPort: data.proxyPort,
isBaseDomain: data.isBaseDomain
})
.post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resource?.resourceId}`,
{
name: data.name,
subdomain: data.http ? data.subdomain : undefined,
proxyPort: data.proxyPort,
isBaseDomain: data.http ? data.isBaseDomain : undefined,
domainId: data.http ? data.domainId : undefined
}
)
.catch((e) => {
toast({
variant: "destructive",
@@ -177,12 +226,17 @@ export default function GeneralForm() {
description: "The resource has been updated successfully"
});
const resource = res.data.data;
updateResource({
name: data.name,
subdomain: data.subdomain,
proxyPort: data.proxyPort,
isBaseDomain: data.isBaseDomain
isBaseDomain: data.isBaseDomain,
fullDomain: resource.fullDomain
});
router.refresh();
}
setSaveLoading(false);
}
@@ -211,323 +265,415 @@ export default function GeneralForm() {
description: "The resource has been transferred successfully"
});
router.refresh();
updateResource({
siteName:
sites.find((site) => site.siteId === data.siteId)?.name ||
""
});
}
setTransferLoading(false);
}
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the general settings for this resource
</SettingsSectionDescription>
</SettingsSectionHeader>
!loadingPage && (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the general settings for this resource
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
This is the display name of the
resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{resource.http && (
<>
{env.flags.allowBaseDomainResources && (
<div>
<RadioGroup
className="flex space-x-4"
defaultValue={domainType}
onValueChange={(val) => {
setDomainType(
val as any
);
form.setValue(
"isBaseDomain",
val === "basedomain"
);
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="subdomain"
id="r1"
/>
<Label htmlFor="r1">
Subdomain
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="basedomain"
id="r2"
/>
<Label htmlFor="r2">
Base Domain
</Label>
</div>
</RadioGroup>
</div>
)}
<FormField
control={form.control}
name="subdomain"
render={({ field }) => (
<FormItem>
{!env.flags
.allowBaseDomainResources && (
<FormLabel>
Subdomain
</FormLabel>
)}
{domainType ===
"subdomain" ? (
<FormControl>
<CustomDomainInput
value={
field.value ||
""
}
domainSuffix={
domainSuffix
}
placeholder="Enter subdomain"
onChange={(
value
) =>
form.setValue(
"subdomain",
value
)
}
/>
</FormControl>
) : (
<FormControl>
<Input
value={
domainSuffix
}
readOnly
disabled
/>
</FormControl>
)}
<FormDescription>
This is the subdomain
that will be used to
access the resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{!resource.http && (
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form} key={formKey}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 gap-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="proxyPort"
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
</FormLabel>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Enter port number"
value={
field.value ?? ""
}
onChange={(e) =>
field.onChange(
e.target.value
? parseInt(
e
.target
.value
)
: null
)
}
/>
<Input {...field} />
</FormControl>
<FormDescription>
This is the port that will
be used to access the
resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="general-settings-form"
>
Save Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
{resource.http && (
<>
{env.flags
.allowBaseDomainResources && (
<FormField
control={form.control}
name="isBaseDomain"
render={({ field }) => (
<FormItem>
<FormLabel>
Domain Type
</FormLabel>
<Select
value={
domainType
}
onValueChange={(
val
) => {
setDomainType(
val ===
"basedomain"
? "basedomain"
: "subdomain"
);
form.setValue(
"isBaseDomain",
val ===
"basedomain"
? true
: false
);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="subdomain">
Subdomain
</SelectItem>
<SelectItem value="basedomain">
Base
Domain
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Transfer Resource
</SettingsSectionTitle>
<SettingsSectionDescription>
Transfer this resource to a different site
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...transferForm}>
<form
onSubmit={transferForm.handleSubmit(onTransfer)}
className="space-y-4"
id="transfer-form"
>
<FormField
control={transferForm.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
Destination Site
</FormLabel>
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(site) =>
site.siteId ===
field.value
)?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput
placeholder="Search sites..."
className="h-9"
/>
<CommandEmpty>
No sites found.
</CommandEmpty>
<CommandGroup>
{sites.map(
(site) => (
<CommandItem
value={`${site.name}:${site.siteId}`}
key={
site.siteId
}
onSelect={() => {
transferForm.setValue(
"siteId",
site.siteId
);
setOpen(
false
);
}}
>
{
site.name
}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
site.siteId ===
<div className="col-span-2">
{domainType === "subdomain" ? (
<div className="w-fill space-y-2">
<FormLabel>
Subdomain
</FormLabel>
<div className="flex">
<div className="w-1/2">
<FormField
control={
form.control
}
name="subdomain"
render={({
field
}) => (
<FormItem>
<FormControl>
<Input
{...field}
className="border-r-0 rounded-r-none"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="w-1/2">
<FormField
control={
form.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
)
)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
Select the new site to transfer
this resource to.
</FormDescription>
<FormMessage />
</FormItem>
}
value={
field.value
}
>
<FormControl>
<SelectTrigger className="rounded-l-none">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
.
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
) : (
<FormField
control={form.control}
name="domainId"
render={({ field }) => (
<FormItem>
<FormLabel>
Base Domain
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value ||
baseDomains[0]
?.domainId
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={transferLoading}
disabled={transferLoading}
form="transfer-form"
variant="destructive"
>
Transfer Resource
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
{!resource.http && (
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(e) =>
field.onChange(
e.target
.value
? parseInt(
e
.target
.value
)
: null
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="general-settings-form"
>
Save Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Transfer Resource
</SettingsSectionTitle>
<SettingsSectionDescription>
Transfer this resource to a different site
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...transferForm}>
<form
onSubmit={transferForm.handleSubmit(
onTransfer
)}
className="space-y-4"
id="transfer-form"
>
<FormField
control={transferForm.control}
name="siteId"
render={({ field }) => (
<FormItem>
<FormLabel>
Destination Site
</FormLabel>
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(
site
) =>
site.siteId ===
field.value
)?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="Search sites" />
<CommandEmpty>
No sites found.
</CommandEmpty>
<CommandGroup>
{sites.map(
(site) => (
<CommandItem
value={`${site.name}:${site.siteId}`}
key={
site.siteId
}
onSelect={() => {
transferForm.setValue(
"siteId",
site.siteId
);
setOpen(
false
);
}}
>
{
site.name
}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
)
)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={transferLoading}
disabled={transferLoading}
form="transfer-form"
>
Transfer Resource
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
)
);
}

View File

@@ -130,9 +130,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
<OrgProvider org={org}>
<ResourceProvider resource={resource} authInfo={authInfo}>
<SidebarSettings sidebarNavItems={sidebarNavItems}>
<div className="mb-8">
<ResourceInfoBox />
</div>
<ResourceInfoBox />
{children}
</SidebarSettings>
</ResourceProvider>

View File

@@ -33,6 +33,7 @@ import {
import {
Table,
TableBody,
TableCaption,
TableCell,
TableContainer,
TableHead,
@@ -92,9 +93,9 @@ enum RuleAction {
}
enum RuleMatch {
PATH = "Path",
IP = "IP",
CIDR = "IP Range",
PATH = "Path"
CIDR = "IP Range"
}
export default function ResourceRules(props: {
@@ -469,9 +470,9 @@ export default function ResourceRules(props: {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
</SelectContent>
</Select>
)
@@ -524,7 +525,13 @@ export default function ResourceRules(props: {
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel()
getFilteredRowModel: getFilteredRowModel(),
state: {
pagination: {
pageIndex: 0,
pageSize: 1000
}
}
});
if (pageLoading) {
@@ -617,7 +624,7 @@ export default function ResourceRules(props: {
onSubmit={addRuleForm.handleSubmit(addRule)}
className="space-y-4"
>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-end">
<FormField
control={addRuleForm.control}
name="action"
@@ -665,17 +672,17 @@ export default function ResourceRules(props: {
<SelectValue />
</SelectTrigger>
<SelectContent>
{resource.http && (
<SelectItem value="PATH">
{RuleMatch.PATH}
</SelectItem>
)}
<SelectItem value="IP">
{RuleMatch.IP}
</SelectItem>
<SelectItem value="CIDR">
{RuleMatch.CIDR}
</SelectItem>
{resource.http && (
<SelectItem value="PATH">
{RuleMatch.PATH}
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
@@ -705,68 +712,63 @@ export default function ResourceRules(props: {
</FormItem>
)}
/>
<Button
type="submit"
variant="outlinePrimary"
disabled={!rulesEnabled}
>
Add Rule
</Button>
</div>
<Button
type="submit"
variant="outline"
disabled={!rulesEnabled}
>
Add Rule
</Button>
</form>
</Form>
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column
.columnDef.header,
header.getContext()
)}
</TableHead>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column
.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No rules. Add a rule using the form.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<p className="text-sm text-muted-foreground">
Rules are evaluated by priority in ascending order.
</p>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No rules. Add a rule using the form.
</TableCell>
</TableRow>
)}
</TableBody>
<TableCaption>
Rules are evaluated by priority in ascending order.
</TableCaption>
</Table>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button

View File

@@ -107,7 +107,12 @@ export default function CreateShareLinkForm({
const [isOpen, setIsOpen] = useState(false);
const [resources, setResources] = useState<
{ resourceId: number; name: string; resourceUrl: string }[]
{
resourceId: number;
name: string;
resourceUrl: string;
siteName: string | null;
}[]
>([]);
const timeUnits = [
@@ -152,13 +157,16 @@ export default function CreateShareLinkForm({
if (res?.status === 200) {
setResources(
res.data.data.resources.filter((r) => {
return r.http;
}).map((r) => ({
resourceId: r.resourceId,
name: r.name,
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
}))
res.data.data.resources
.filter((r) => {
return r.http;
})
.map((r) => ({
resourceId: r.resourceId,
name: r.name,
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`,
siteName: r.siteName
}))
);
}
}
@@ -229,19 +237,28 @@ export default function CreateShareLinkForm({
token.accessToken
);
setDirectLink(directLink);
const resource = resources.find((r) => r.resourceId === values.resourceId);
onCreated?.({
accessTokenId: token.accessTokenId,
resourceId: token.resourceId,
resourceName: values.resourceName,
title: token.title,
createdAt: token.createdAt,
expiresAt: token.expiresAt
expiresAt: token.expiresAt,
siteName: resource?.siteName || null,
});
}
setLoading(false);
}
function getSelectedResourceName(id: number) {
const resource = resources.find((r) => r.resourceId === id);
return `${resource?.name} ${resource?.siteName ? `(${resource.siteName})` : ""}`;
}
return (
<>
<Credenza
@@ -274,7 +291,7 @@ export default function CreateShareLinkForm({
name="resourceId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="mb-2">
<FormLabel>
Resource
</FormLabel>
<Popover>
@@ -290,14 +307,9 @@ export default function CreateShareLinkForm({
)}
>
{field.value
? resources.find(
(
r
) =>
r.resourceId ===
field.value
? getSelectedResourceName(
field.value
)
?.name
: "Select resource"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -305,7 +317,7 @@ export default function CreateShareLinkForm({
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search resources..." />
<CommandInput placeholder="Search resources" />
<CommandList>
<CommandEmpty>
No
@@ -318,9 +330,7 @@ export default function CreateShareLinkForm({
r
) => (
<CommandItem
value={
`${r.name}:${r.resourceId}`
}
value={`${r.name}:${r.resourceId}`}
key={
r.resourceId
}
@@ -348,9 +358,7 @@ export default function CreateShareLinkForm({
: "opacity-0"
)}
/>
{
r.name
}
{`${r.name} ${r.siteName ? `(${r.siteName})` : ""}`}
</CommandItem>
)
)}
@@ -369,14 +377,11 @@ export default function CreateShareLinkForm({
name="title"
render={({ field }) => (
<FormItem>
<Label>
<FormLabel>
Title (optional)
</Label>
</FormLabel>
<FormControl>
<Input
placeholder="Enter title"
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -384,67 +389,68 @@ export default function CreateShareLinkForm({
/>
<div className="space-y-4">
<Label>Expire In</Label>
<div className="grid grid-cols-2 gap-4 mt-2">
<FormField
control={form.control}
name="timeUnit"
render={({ field }) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={field.value.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
{timeUnits.map(
(
option
) => (
<SelectItem
key={
option.unit
}
value={
option.unit
}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel>Expire In</FormLabel>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="timeUnit"
render={({ field }) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={field.value.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
{timeUnits.map(
(
option
) => (
<SelectItem
key={
option.unit
}
value={
option.unit
}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeValue"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type="number"
min={1}
placeholder="Enter duration"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeValue"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type="number"
min={1}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="flex items-center space-x-2">
@@ -554,6 +560,9 @@ export default function CreateShareLinkForm({
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="button"
onClick={form.handleSubmit(onSubmit)}
@@ -562,9 +571,6 @@ export default function CreateShareLinkForm({
>
Create Link
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -41,6 +41,7 @@ export type ShareLinkRow = {
title: string | null;
createdAt: number;
expiresAt: number | null;
siteName: string | null;
};
type ShareLinksTableProps = {
@@ -145,7 +146,8 @@ export default function ShareLinksTable({
return (
<Link href={`/${orgId}/settings/resources/${r.resourceId}`}>
<Button variant="outline">
{r.resourceName}
{r.resourceName}{" "}
{r.siteName ? `(${r.siteName})` : ""}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
@@ -273,6 +275,21 @@ export default function ShareLinksTable({
}
return "Never";
}
},
{
id: "delete",
cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2">
<Button
variant="outlinePrimary"
onClick={() =>
deleteSharelink(row.original.accessTokenId)
}
>
Delete
</Button>
</div>
)
}
];

View File

@@ -41,6 +41,7 @@ import Link from "next/link";
import {
ArrowUpRight,
ChevronsUpDown,
Loader2,
SquareArrowOutUpRight
} from "lucide-react";
import {
@@ -48,6 +49,7 @@ import {
CollapsibleContent,
CollapsibleTrigger
} from "@app/components/ui/collapsible";
import LoaderPlaceholder from "@app/components/PlaceHolderLoader";
const createSiteFormSchema = z.object({
name: z
@@ -97,6 +99,8 @@ export default function CreateSiteForm({
const [siteDefaults, setSiteDefaults] =
useState<PickSiteDefaultsResponse | null>(null);
const [loadingPage, setLoadingPage] = useState(true);
const handleCheckboxChange = (checked: boolean) => {
// setChecked?.(checked);
setIsChecked(checked);
@@ -121,27 +125,36 @@ export default function CreateSiteForm({
useEffect(() => {
if (!open) return;
// reset all values
setLoading?.(false);
setIsLoading(false);
form.reset();
setChecked?.(false);
setKeypair(null);
setSiteDefaults(null);
const load = async () => {
setLoadingPage(true);
// reset all values
setLoading?.(false);
setIsLoading(false);
form.reset();
setChecked?.(false);
setKeypair(null);
setSiteDefaults(null);
const generatedKeypair = generateKeypair();
setKeypair(generatedKeypair);
const generatedKeypair = generateKeypair();
setKeypair(generatedKeypair);
api.get(`/org/${orgId}/pick-site-defaults`)
.catch((e) => {
// update the default value of the form to be local method
form.setValue("method", "local");
})
.then((res) => {
if (res && res.status === 200) {
setSiteDefaults(res.data.data);
}
});
await api
.get(`/org/${orgId}/pick-site-defaults`)
.catch((e) => {
// update the default value of the form to be local method
form.setValue("method", "local");
})
.then((res) => {
if (res && res.status === 200) {
setSiteDefaults(res.data.data);
}
});
await new Promise((resolve) => setTimeout(resolve, 200));
setLoadingPage(false);
};
load();
}, [open]);
async function onSubmit(data: CreateSiteFormValues) {
@@ -257,7 +270,9 @@ PersistentKeepalive = 5`
const newtConfigDockerRun = `docker run -it fosrl/newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
return (
return loadingPage ? (
<LoaderPlaceholder height="300px" />
) : (
<div className="space-y-4">
<Form {...form}>
<form
@@ -272,17 +287,12 @@ PersistentKeepalive = 5`
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder="Site name"
{...field}
/>
<Input autoComplete="off" {...field} />
</FormControl>
<FormDescription>
This is the name that will be displayed for
this site.
</FormDescription>
<FormMessage />
<FormDescription>
This is the the display name for the site.
</FormDescription>
</FormItem>
)}
/>
@@ -319,10 +329,10 @@ PersistentKeepalive = 5`
</SelectContent>
</Select>
</FormControl>
<FormMessage />
<FormDescription>
This is how you will expose connections.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@@ -335,7 +345,6 @@ PersistentKeepalive = 5`
rel="noopener noreferrer"
>
<span>
{" "}
Learn how to install Newt on your system
</span>
<SquareArrowOutUpRight size={14} />
@@ -354,7 +363,7 @@ PersistentKeepalive = 5`
) : form.watch("method") === "wireguard" &&
isLoading ? (
<p>Loading WireGuard configuration...</p>
) : form.watch("method") === "newt" ? (
) : form.watch("method") === "newt" && siteDefaults ? (
<>
<div className="mb-2">
<Collapsible
@@ -362,12 +371,16 @@ PersistentKeepalive = 5`
onOpenChange={setIsOpen}
className="space-y-2"
>
<div className="mx-auto">
<div className="mx-auto mb-2">
<CopyTextBox
text={newtConfig}
wrapText={false}
/>
</div>
<span className="text-sm text-muted-foreground">
You will only be able to see the
configuration once.
</span>
<div className="flex items-center justify-between space-x-4">
<CollapsibleTrigger asChild>
<Button
@@ -376,8 +389,8 @@ PersistentKeepalive = 5`
className="p-0 flex items-center justify-between w-full"
>
<h4 className="text-sm font-semibold">
Expand for Docker Deployment
Details
Expand for Docker
Deployment Details
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />
@@ -409,10 +422,6 @@ PersistentKeepalive = 5`
</CollapsibleContent>
</Collapsible>
</div>
<span className="text-sm text-muted-foreground">
You will only be able to see the
configuration once.
</span>
</>
) : null}
</div>

View File

@@ -58,6 +58,9 @@ export default function CreateSiteFormModal({
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="create-site-form"
@@ -69,9 +72,6 @@ export default function CreateSiteFormModal({
>
Create Site
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -268,7 +268,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
<Link
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<Button variant={"outline"} className="ml-2">
<Button variant={"outlinePrimary"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

View File

@@ -3,7 +3,6 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { InfoIcon } from "lucide-react";
import { useSiteContext } from "@app/hooks/useSiteContext";
import { Separator } from "@app/components/ui/separator";
import {
InfoSection,
InfoSectionContent,
@@ -33,7 +32,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">Site Information</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections>
<InfoSections cols={2}>
{(site.type == "newt" || site.type == "wireguard") && (
<>
<InfoSection>
@@ -52,8 +51,6 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
)}
</InfoSectionContent>
</InfoSection>
<Separator orientation="vertical" />
</>
)}
<InfoSection>

View File

@@ -33,7 +33,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useState } from "react";
const GeneralFormSchema = z.object({
name: z.string()
name: z.string().nonempty("Name is required")
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -114,11 +114,11 @@ export default function GeneralPage() {
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is the display name of the
site
site.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

View File

@@ -68,9 +68,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
<SiteProvider site={site}>
<SidebarSettings sidebarNavItems={sidebarNavItems}>
<div className="mb-8">
<SiteInfoCard />
</div>
<SiteInfoCard />
{children}
</SidebarSettings>
</SiteProvider>

View File

@@ -41,7 +41,7 @@ export default async function Page(props: {
Looks like you've been invited!
</h2>
<p className="text-center">
To accept the invite, you must login or create an
To accept the invite, you must log in or create an
account.
</p>
</div>

View File

@@ -38,7 +38,7 @@ import { Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "../../../components/ui/alert";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
@@ -182,7 +182,7 @@ export default function ResetPasswordForm({
return;
}
setSuccessMessage("Password reset successfully! Back to login...");
setSuccessMessage("Password reset successfully! Back to log in...");
setTimeout(() => {
if (redirect) {
@@ -223,16 +223,13 @@ export default function ResetPasswordForm({
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Enter your email"
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
We'll send a password reset
code to this email address.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@@ -255,7 +252,6 @@ export default function ResetPasswordForm({
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Email"
{...field}
disabled
/>
@@ -276,12 +272,15 @@ export default function ResetPasswordForm({
</FormLabel>
<FormControl>
<Input
placeholder="Enter reset code sent to your email"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
Check your email for the
reset code.
</FormDescription>
</FormItem>
)}
/>
@@ -298,7 +297,6 @@ export default function ResetPasswordForm({
<FormControl>
<Input
type="password"
placeholder="Password"
{...field}
/>
</FormControl>
@@ -317,7 +315,6 @@ export default function ResetPasswordForm({
<FormControl>
<Input
type="password"
placeholder="Confirm Password"
{...field}
/>
</FormControl>
@@ -349,7 +346,9 @@ export default function ResetPasswordForm({
<InputOTP
maxLength={6}
{...field}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
pattern={
REGEXP_ONLY_DIGITS_AND_CHARS
}
>
<InputOTPGroup>
<InputOTPSlot

View File

@@ -263,7 +263,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
}
if (isAllowed) {
window.location.href = props.redirect;
// window.location.href = props.redirect;
router.refresh();
}
}
@@ -448,7 +449,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</FormLabel>
<FormControl>
<Input
placeholder="Enter password"
type="password"
{...field}
/>
@@ -517,7 +517,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</FormLabel>
<FormControl>
<Input
placeholder="Enter email"
type="email"
{...field}
/>
@@ -576,7 +575,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</FormLabel>
<FormControl>
<Input
placeholder="Enter OTP"
type="password"
{...field}
/>

View File

@@ -145,7 +145,7 @@ export default function SignupForm({
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="Email" {...field} />
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -160,7 +160,6 @@ export default function SignupForm({
<FormControl>
<Input
type="password"
placeholder="Password"
{...field}
/>
</FormControl>
@@ -177,7 +176,6 @@ export default function SignupForm({
<FormControl>
<Input
type="password"
placeholder="Confirm Password"
{...field}
/>
</FormControl>

View File

@@ -57,7 +57,7 @@ export default async function Page(props: {
Looks like you've been invited!
</h2>
<p className="text-center">
To accept the invite, you must login or create an
To accept the invite, you must log in or create an
account.
</p>
</div>

View File

@@ -145,7 +145,6 @@ export default function VerifyEmailForm({
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Email"
{...field}
disabled
/>
@@ -196,12 +195,11 @@ export default function VerifyEmailForm({
</InputOTP>
</div>
</FormControl>
<FormMessage />
<FormDescription>
We sent a verification code to your
email address. Please enter the code
to verify your email address.
email address.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

View File

@@ -21,8 +21,8 @@
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 85%;
--input: 20 5.9% 85%;
--border: 20 5.9% 80%;
--input: 20 5.9% 75%;
--ring: 24.6 95% 53.1%;
--radius: 0.75rem;
--chart-1: 12 76% 61%;
@@ -49,8 +49,8 @@
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 25.0%;
--input: 12 6.5% 25.0%;
--border: 12 6.5% 30.0%;
--input: 12 6.5% 35.0%;
--ring: 20.5 90.2% 48.2%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;

View File

@@ -37,11 +37,11 @@ export default async function RootLayout({
>
<EnvProvider env={pullEnv()}>
{/* Main content */}
<div className="flex-grow">{children}</div>
<div className="flex-grow pb-3 md:pb-0">{children}</div>
{/* Footer */}
<footer className="w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600 select-none">
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600">
<div className="flex items-center space-x-2 whitespace-nowrap">
<span>Pangolin</span>
</div>

View File

@@ -112,7 +112,7 @@ export default function StepperForm() {
<>
<Card>
<CardHeader>
<CardTitle>Setup New Organization</CardTitle>
<CardTitle>New Organization</CardTitle>
<CardDescription>
Create your organization, site, and resources
</CardDescription>
@@ -200,7 +200,6 @@ export default function StepperForm() {
</FormLabel>
<FormControl>
<Input
placeholder="Name your new organization"
type="text"
{...field}
onChange={(e) => {
@@ -242,7 +241,6 @@ export default function StepperForm() {
<FormControl>
<Input
type="text"
placeholder="Enter unique organization ID"
{...field}
/>
</FormControl>

View File

@@ -7,7 +7,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
@@ -15,14 +15,14 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectValue
} from "@app/components/ui/select";
import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import {
InviteUserBody,
InviteUserResponse,
ListUsersResponse,
ListUsersResponse
} from "@server/routers/user";
import { AxiosResponse } from "axios";
import React, { useState } from "react";
@@ -37,7 +37,7 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { Description } from "@radix-ui/react-toast";
@@ -61,7 +61,7 @@ export default function InviteUserForm({
title,
onConfirm,
buttonText,
dialog,
dialog
}: InviteUserFormProps) {
const [loading, setLoading] = useState(false);
@@ -69,15 +69,15 @@ export default function InviteUserForm({
const formSchema = z.object({
string: z.string().refine((val) => val === string, {
message: "Invalid confirmation",
}),
message: "Invalid confirmation"
})
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
string: "",
},
string: ""
}
});
function reset() {
@@ -128,6 +128,9 @@ export default function InviteUserForm({
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="confirm-delete-form"
@@ -136,9 +139,6 @@ export default function InviteUserForm({
>
{buttonText}
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -78,7 +78,7 @@ const CredenzaClose = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaClose = isDesktop ? DialogClose : DrawerClose;
return (
<CredenzaClose className={cn("mb-3 md:mb-0", className)} {...props}>
<CredenzaClose className={cn("mb-3 mt-3 md:mt-0 md:mb-0", className)} {...props}>
{children}
</CredenzaClose>
);
@@ -155,7 +155,7 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
// );
return (
<div className={cn("px-0 mb-4", className)} {...props}>
<div className={cn("px-0 mb-4 space-y-4", className)} {...props}>
{children}
</div>
);
@@ -168,7 +168,7 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter;
return (
<CredenzaFooter className={className} {...props}>
<CredenzaFooter className={cn("mt-8 md:mt-0", className)} {...props}>
{children}
</CredenzaFooter>
);

View File

@@ -29,10 +29,8 @@ import {
CredenzaTitle
} from "@app/components/Credenza";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { useUserContext } from "@app/hooks/useUserContext";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { CheckCircle2 } from "lucide-react";
const disableSchema = z.object({
@@ -135,7 +133,6 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>
@@ -153,36 +150,7 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
Authenticator Code
</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
{...field}
pattern={
REGEXP_ONLY_DIGITS_AND_CHARS
}
>
<InputOTPGroup>
<InputOTPSlot
index={0}
/>
<InputOTPSlot
index={1}
/>
<InputOTPSlot
index={2}
/>
</InputOTPGroup>
<InputOTPGroup>
<InputOTPSlot
index={3}
/>
<InputOTPSlot
index={4}
/>
<InputOTPSlot
index={5}
/>
</InputOTPGroup>
</InputOTP>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -211,6 +179,9 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{step === "password" && (
<Button
type="submit"
@@ -221,9 +192,6 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
Disable 2FA
</Button>
)}
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -36,7 +36,7 @@ import {
CredenzaTitle
} from "@app/components/Credenza";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import CopyTextBox from "@app/components/CopyTextBox";
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
import { useUserContext } from "@app/hooks/useUserContext";
@@ -200,7 +200,6 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>
@@ -223,7 +222,10 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
<QRCodeCanvas value={secretUri} size={200} />
</div>
<div className="max-w-md mx-auto">
<CopyTextBox text={secretUri} wrapText={false} />
<CopyTextBox
text={secretUri}
wrapText={false}
/>
</div>
<Form {...confirmForm}>
@@ -246,7 +248,6 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
<FormControl>
<Input
type="code"
placeholder="Enter the 6-digit code from your authenticator app"
{...field}
/>
</FormControl>
@@ -281,6 +282,9 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{(step === 1 || step === 2) && (
<Button
type="button"
@@ -297,9 +301,6 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
Submit
</Button>
)}
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -1,8 +1,16 @@
"use client";
export function InfoSections({ children }: { children: React.ReactNode }) {
export function InfoSections({
children,
cols
}: {
children: React.ReactNode;
cols?: number;
}) {
return (
<div className="grid grid-cols-1 md:gap-4 gap-2 md:grid-cols-[1fr_auto_1fr] md:items-start">
<div
className={`grid md:grid-cols-${cols || 1} md:gap-4 gap-2 md:items-start grid-cols-1`}
>
{children}
</div>
);
@@ -23,9 +31,3 @@ export function InfoSectionContent({
}) {
return <div className="break-words">{children}</div>;
}
export function Divider() {
return (
<div className="hidden md:block border-l border-gray-300 h-auto mx-4"></div>
);
}

View File

@@ -147,7 +147,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Enter your email"
{...field}
/>
</FormControl>
@@ -166,7 +165,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>

View File

@@ -0,0 +1,21 @@
"use client";
import React from "react";
import { Loader2 } from "lucide-react"; // Ensure you have lucide-react installed
interface LoaderProps {
height?: string;
}
const LoaderPlaceholder: React.FC<LoaderProps> = ({ height = "100px" }) => {
return (
<div
className="flex items-center justify-center w-full"
style={{ height }}
>
<Loader2 className="animate-spin" />
</div>
);
};
export default LoaderPlaceholder;

View File

@@ -1,5 +1,5 @@
export function SettingsContainer({ children }: { children: React.ReactNode }) {
return <div className="space-y-4">{children}</div>
return <div className="space-y-6">{children}</div>
}
export function SettingsSection({ children }: { children: React.ReactNode }) {
@@ -7,7 +7,7 @@ export function SettingsSection({ children }: { children: React.ReactNode }) {
}
export function SettingsSectionHeader({ children }: { children: React.ReactNode }) {
return <div className="space-y-0.5 pb-8">{children}</div>
return <div className="space-y-0.5 pb-6">{children}</div>
}
export function SettingsSectionForm({ children }: { children: React.ReactNode }) {
@@ -19,7 +19,7 @@ export function SettingsSectionTitle({ children }: { children: React.ReactNode }
}
export function SettingsSectionDescription({ children }: { children: React.ReactNode }) {
return <p className="text-muted-foreground">{children}</p>
return <p className="text-muted-foreground text-sm">{children}</p>
}
export function SettingsSectionBody({ children }: { children: React.ReactNode }) {

View File

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

View File

@@ -26,7 +26,7 @@ export function SidebarSettings({
<aside className="lg:w-1/5">
<SidebarNav items={sidebarNavItems} disabled={disabled} />
</aside>
<div className={`flex-1 ${limitWidth ? "lg:max-w-2xl" : ""}`}>
<div className={`flex-1 ${limitWidth ? "lg:max-w-2xl" : ""} space-y-6`}>
{children}
</div>
</div>

View File

@@ -0,0 +1,53 @@
"use client";
import { cn } from "@app/lib/cn";
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
interface StrategyOption {
id: string;
title: string;
description: string;
}
interface StrategySelectProps {
options: StrategyOption[];
defaultValue?: string;
onChange?: (value: string) => void;
}
export function StrategySelect({
options,
defaultValue,
onChange
}: StrategySelectProps) {
return (
<RadioGroup
defaultValue={defaultValue}
onValueChange={onChange}
className="grid gap-4"
>
{options.map((option) => (
<label
key={option.id}
htmlFor={option.id}
className={cn(
"relative flex cursor-pointer rounded-lg border-2 p-4",
"data-[state=checked]:border-primary data-[state=checked]:bg-primary/10 data-[state=checked]:text-primary"
)}
>
<RadioGroupItem
value={option.id}
id={option.id}
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
/>
<div className="pl-7">
<div className="font-medium">{option.title}</div>
<div className="text-sm text-muted-foreground">
{option.description}
</div>
</div>
</label>
))}
</RadioGroup>
);
}

View File

@@ -0,0 +1,353 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
// import { Command, CommandList, CommandItem, CommandGroup, CommandEmpty } from '../ui/command';
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Button } from "../ui/button";
import { cn } from "@app/lib/cn";
type AutocompleteProps = {
tags: TagType[];
setTags: React.Dispatch<React.SetStateAction<TagType[]>>;
setInputValue: React.Dispatch<React.SetStateAction<string>>;
setTagCount: React.Dispatch<React.SetStateAction<number>>;
autocompleteOptions: TagType[];
maxTags?: number;
onTagAdd?: (tag: string) => void;
onTagRemove?: (tag: string) => void;
allowDuplicates: boolean;
children: React.ReactNode;
inlineTags?: boolean;
classStyleProps: TagInputStyleClassesProps["autoComplete"];
usePortal?: boolean;
};
export const Autocomplete: React.FC<AutocompleteProps> = ({
tags,
setTags,
setInputValue,
setTagCount,
autocompleteOptions,
maxTags,
onTagAdd,
onTagRemove,
allowDuplicates,
inlineTags,
children,
classStyleProps,
usePortal
}) => {
const triggerContainerRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const popoverContentRef = useRef<HTMLDivElement | null>(null);
const [popoverWidth, setPopoverWidth] = useState<number>(0);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [inputFocused, setInputFocused] = useState(false);
const [popooverContentTop, setPopoverContentTop] = useState<number>(0);
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
// Dynamically calculate the top position for the popover content
useEffect(() => {
if (!triggerContainerRef.current || !triggerRef.current) return;
setPopoverContentTop(
triggerContainerRef.current?.getBoundingClientRect().bottom -
triggerRef.current?.getBoundingClientRect().bottom
);
}, [tags]);
// Close the popover when clicking outside of it
useEffect(() => {
const handleOutsideClick = (
event: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent
) => {
if (
isPopoverOpen &&
triggerContainerRef.current &&
popoverContentRef.current &&
!triggerContainerRef.current.contains(event.target as Node) &&
!popoverContentRef.current.contains(event.target as Node)
) {
setIsPopoverOpen(false);
}
};
document.addEventListener("mousedown", handleOutsideClick);
return () => {
document.removeEventListener("mousedown", handleOutsideClick);
};
}, [isPopoverOpen]);
const handleOpenChange = useCallback(
(open: boolean) => {
if (open && triggerContainerRef.current) {
const { width } =
triggerContainerRef.current.getBoundingClientRect();
setPopoverWidth(width);
}
if (open) {
inputRef.current?.focus();
setIsPopoverOpen(open);
}
},
[inputFocused]
);
const handleInputFocus = (
event:
| React.FocusEvent<HTMLInputElement>
| React.FocusEvent<HTMLTextAreaElement>
) => {
if (triggerContainerRef.current) {
const { width } =
triggerContainerRef.current.getBoundingClientRect();
setPopoverWidth(width);
setIsPopoverOpen(true);
}
// Only set inputFocused to true if the popover is already open.
// This will prevent the popover from opening due to an input focus if it was initially closed.
if (isPopoverOpen) {
setInputFocused(true);
}
const userOnFocus = (children as React.ReactElement<any>).props.onFocus;
if (userOnFocus) userOnFocus(event);
};
const handleInputBlur = (
event:
| React.FocusEvent<HTMLInputElement>
| React.FocusEvent<HTMLTextAreaElement>
) => {
setInputFocused(false);
// Allow the popover to close if no other interactions keep it open
if (!isPopoverOpen) {
setIsPopoverOpen(false);
}
const userOnBlur = (children as React.ReactElement<any>).props.onBlur;
if (userOnBlur) userOnBlur(event);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (!isPopoverOpen) return;
switch (event.key) {
case "ArrowUp":
event.preventDefault();
setSelectedIndex((prevIndex) =>
prevIndex <= 0
? autocompleteOptions.length - 1
: prevIndex - 1
);
break;
case "ArrowDown":
event.preventDefault();
setSelectedIndex((prevIndex) =>
prevIndex === autocompleteOptions.length - 1
? 0
: prevIndex + 1
);
break;
case "Enter":
event.preventDefault();
if (selectedIndex !== -1) {
toggleTag(autocompleteOptions[selectedIndex]);
setSelectedIndex(-1);
}
break;
}
};
const toggleTag = (option: TagType) => {
// Check if the tag already exists in the array
const index = tags.findIndex((tag) => tag.text === option.text);
if (index >= 0) {
// Tag exists, remove it
const newTags = tags.filter((_, i) => i !== index);
setTags(newTags);
setTagCount((prevCount) => prevCount - 1);
if (onTagRemove) {
onTagRemove(option.text);
}
} else {
// Tag doesn't exist, add it if allowed
if (
!allowDuplicates &&
tags.some((tag) => tag.text === option.text)
) {
// If duplicates aren't allowed and a tag with the same text exists, do nothing
return;
}
// Add the tag if it doesn't exceed max tags, if applicable
if (!maxTags || tags.length < maxTags) {
setTags([...tags, option]);
setTagCount((prevCount) => prevCount + 1);
setInputValue("");
if (onTagAdd) {
onTagAdd(option.text);
}
}
}
setSelectedIndex(-1);
};
const childrenWithProps = React.cloneElement(
children as React.ReactElement<any>,
{
onKeyDown: handleKeyDown,
onFocus: handleInputFocus,
onBlur: handleInputBlur,
ref: inputRef
}
);
return (
<div
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
classStyleProps?.command
)}
>
<Popover
open={isPopoverOpen}
onOpenChange={handleOpenChange}
modal={usePortal}
>
<div
className="relative h-full flex items-center rounded-md border-2 bg-transparent pr-3"
ref={triggerContainerRef}
>
{childrenWithProps}
<PopoverTrigger asChild ref={triggerRef}>
<Button
variant="ghost"
size="icon"
role="combobox"
className={cn(
`hover:bg-transparent ${!inlineTags ? "ml-auto" : ""}`,
classStyleProps?.popoverTrigger
)}
onClick={() => {
setIsPopoverOpen(!isPopoverOpen);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`lucide lucide-chevron-down h-4 w-4 shrink-0 opacity-50 ${isPopoverOpen ? "rotate-180" : "rotate-0"}`}
>
<path d="m6 9 6 6 6-6"></path>
</svg>
</Button>
</PopoverTrigger>
</div>
<PopoverContent
ref={popoverContentRef}
side="bottom"
align="start"
forceMount
className={cn(
`p-0 relative`,
classStyleProps?.popoverContent
)}
style={{
top: `${popooverContentTop}px`,
marginLeft: `calc(-${popoverWidth}px + 36px)`,
width: `${popoverWidth}px`,
minWidth: `${popoverWidth}px`,
zIndex: 9999
}}
>
<div
className={cn(
"max-h-[300px] overflow-y-auto overflow-x-hidden",
classStyleProps?.commandList
)}
style={{
minHeight: "68px"
}}
key={autocompleteOptions.length}
>
{autocompleteOptions.length > 0 ? (
<div
key={autocompleteOptions.length}
role="group"
className={cn(
"overflow-y-auto overflow-hidden p-1 text-foreground",
classStyleProps?.commandGroup
)}
style={{
minHeight: "68px"
}}
>
<span className="text-muted-foreground font-medium text-sm py-1.5 px-2 pb-2">
Suggestions
</span>
<div role="separator" className="py-0.5" />
{autocompleteOptions.map((option, index) => {
const isSelected = index === selectedIndex;
return (
<div
key={option.id}
role="option"
aria-selected={isSelected}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent",
isSelected &&
"bg-accent text-accent-foreground",
classStyleProps?.commandItem
)}
data-value={option.text}
onClick={() => toggleTag(option)}
>
<div className="w-full flex items-center gap-2">
{option.text}
{tags.some(
(tag) =>
tag.text === option.text
) && (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-check"
>
<path d="M20 6 9 17l-5-5"></path>
</svg>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="py-6 text-center text-sm">
No results found.
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
);
};

View File

@@ -0,0 +1,949 @@
"use client";
import React, { useMemo } from "react";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
import { type VariantProps } from "class-variance-authority";
// import { CommandInput } from '../ui/command';
import { TagPopover } from "./tag-popover";
import { TagList } from "./tag-list";
import { tagVariants } from "./tag";
import { Autocomplete } from "./autocomplete";
import { cn } from "@app/lib/cn";
export enum Delimiter {
Comma = ",",
Enter = "Enter"
}
type OmittedInputProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"size" | "value"
>;
export type Tag = {
id: string;
text: string;
};
export interface TagInputStyleClassesProps {
inlineTagsContainer?: string;
tagPopover?: {
popoverTrigger?: string;
popoverContent?: string;
};
tagList?: {
container?: string;
sortableList?: string;
};
autoComplete?: {
command?: string;
popoverTrigger?: string;
popoverContent?: string;
commandList?: string;
commandGroup?: string;
commandItem?: string;
};
tag?: {
body?: string;
closeButton?: string;
};
input?: string;
clearAllButton?: string;
}
export interface TagInputProps
extends OmittedInputProps,
VariantProps<typeof tagVariants> {
placeholder?: string;
tags: Tag[];
setTags: React.Dispatch<React.SetStateAction<Tag[]>>;
enableAutocomplete?: boolean;
autocompleteOptions?: Tag[];
maxTags?: number;
minTags?: number;
readOnly?: boolean;
disabled?: boolean;
onTagAdd?: (tag: string) => void;
onTagRemove?: (tag: string) => void;
allowDuplicates?: boolean;
validateTag?: (tag: string) => boolean;
delimiter?: Delimiter;
showCount?: boolean;
placeholderWhenFull?: string;
sortTags?: boolean;
delimiterList?: string[];
truncate?: number;
minLength?: number;
maxLength?: number;
usePopoverForTags?: boolean;
value?:
| string
| number
| readonly string[]
| { id: string; text: string }[];
autocompleteFilter?: (option: string) => boolean;
direction?: "row" | "column";
onInputChange?: (value: string) => void;
customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode;
onFocus?: React.FocusEventHandler<HTMLInputElement>;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
onTagClick?: (tag: Tag) => void;
draggable?: boolean;
inputFieldPosition?: "bottom" | "top";
clearAll?: boolean;
onClearAll?: () => void;
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
restrictTagsToAutocompleteOptions?: boolean;
inlineTags?: boolean;
activeTagIndex: number | null;
setActiveTagIndex: React.Dispatch<React.SetStateAction<number | null>>;
styleClasses?: TagInputStyleClassesProps;
usePortal?: boolean;
addOnPaste?: boolean;
addTagsOnBlur?: boolean;
generateTagId?: () => string;
}
const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
(props, ref) => {
const {
id,
placeholder,
tags,
setTags,
variant,
size,
shape,
enableAutocomplete,
autocompleteOptions,
maxTags,
delimiter = Delimiter.Comma,
onTagAdd,
onTagRemove,
allowDuplicates,
showCount,
validateTag,
placeholderWhenFull = "Max tags reached",
sortTags,
delimiterList,
truncate,
autocompleteFilter,
borderStyle,
textCase,
interaction,
animation,
textStyle,
minLength,
maxLength,
direction = "row",
onInputChange,
customTagRenderer,
onFocus,
onBlur,
onTagClick,
draggable = false,
inputFieldPosition = "bottom",
clearAll = false,
onClearAll,
usePopoverForTags = false,
inputProps = {},
restrictTagsToAutocompleteOptions,
inlineTags = true,
addTagsOnBlur = false,
activeTagIndex,
setActiveTagIndex,
styleClasses = {},
disabled = false,
usePortal = false,
addOnPaste = false,
generateTagId = uuid
} = props;
const [inputValue, setInputValue] = React.useState("");
const [tagCount, setTagCount] = React.useState(
Math.max(0, tags.length)
);
const inputRef = React.useRef<HTMLInputElement>(null);
if (
(maxTags !== undefined && maxTags < 0) ||
(props.minTags !== undefined && props.minTags < 0)
) {
console.warn("maxTags and minTags cannot be less than 0");
// error
return null;
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
if (addOnPaste && newValue.includes(delimiter)) {
const splitValues = newValue
.split(delimiter)
.map((v) => v.trim())
.filter((v) => v);
splitValues.forEach((value) => {
if (!value) return; // Skip empty strings from split
const newTagText = value.trim();
// Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true
if (
restrictTagsToAutocompleteOptions &&
!autocompleteOptions?.some(
(option) => option.text === newTagText
)
) {
console.warn(
"Tag not allowed as per autocomplete options"
);
return;
}
if (validateTag && !validateTag(newTagText)) {
console.warn("Invalid tag as per validateTag");
return;
}
if (minLength && newTagText.length < minLength) {
console.warn(`Tag "${newTagText}" is too short`);
return;
}
if (maxLength && newTagText.length > maxLength) {
console.warn(`Tag "${newTagText}" is too long`);
return;
}
const newTagId = generateTagId();
// Add tag if duplicates are allowed or tag does not already exist
if (
allowDuplicates ||
!tags.some((tag) => tag.text === newTagText)
) {
if (maxTags === undefined || tags.length < maxTags) {
// Check for maxTags limit
const newTag = { id: newTagId, text: newTagText };
setTags((prevTags) => [...prevTags, newTag]);
onTagAdd?.(newTagText);
} else {
console.warn(
"Reached the maximum number of tags allowed"
);
}
} else {
console.warn(`Duplicate tag "${newTagText}" not added`);
}
});
setInputValue("");
} else {
setInputValue(newValue);
}
onInputChange?.(newValue);
};
const handleInputFocus = (
event: React.FocusEvent<HTMLInputElement>
) => {
setActiveTagIndex(null); // Reset active tag index when the input field gains focus
onFocus?.(event);
};
const handleInputBlur = (event: React.FocusEvent<HTMLInputElement>) => {
if (addTagsOnBlur && inputValue.trim()) {
const newTagText = inputValue.trim();
if (validateTag && !validateTag(newTagText)) {
return;
}
if (minLength && newTagText.length < minLength) {
console.warn("Tag is too short");
return;
}
if (maxLength && newTagText.length > maxLength) {
console.warn("Tag is too long");
return;
}
if (
(allowDuplicates ||
!tags.some((tag) => tag.text === newTagText)) &&
(maxTags === undefined || tags.length < maxTags)
) {
const newTagId = generateTagId();
setTags([...tags, { id: newTagId, text: newTagText }]);
onTagAdd?.(newTagText);
setTagCount((prevTagCount) => prevTagCount + 1);
setInputValue("");
}
}
onBlur?.(event);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (
delimiterList
? delimiterList.includes(e.key)
: e.key === delimiter || e.key === Delimiter.Enter
) {
e.preventDefault();
const newTagText = inputValue.trim();
// Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true
if (
restrictTagsToAutocompleteOptions &&
!autocompleteOptions?.some(
(option) => option.text === newTagText
)
) {
// error
return;
}
if (validateTag && !validateTag(newTagText)) {
return;
}
if (minLength && newTagText.length < minLength) {
console.warn("Tag is too short");
// error
return;
}
// Validate maxLength
if (maxLength && newTagText.length > maxLength) {
// error
console.warn("Tag is too long");
return;
}
const newTagId = generateTagId();
if (
newTagText &&
(allowDuplicates ||
!tags.some((tag) => tag.text === newTagText)) &&
(maxTags === undefined || tags.length < maxTags)
) {
setTags([...tags, { id: newTagId, text: newTagText }]);
onTagAdd?.(newTagText);
setTagCount((prevTagCount) => prevTagCount + 1);
}
setInputValue("");
} else {
switch (e.key) {
case "Delete":
if (activeTagIndex !== null) {
e.preventDefault();
const newTags = [...tags];
newTags.splice(activeTagIndex, 1);
setTags(newTags);
setActiveTagIndex((prev) =>
newTags.length === 0
? null
: prev! >= newTags.length
? newTags.length - 1
: prev
);
setTagCount((prevTagCount) => prevTagCount - 1);
onTagRemove?.(tags[activeTagIndex].text);
}
break;
case "Backspace":
if (activeTagIndex !== null) {
e.preventDefault();
const newTags = [...tags];
newTags.splice(activeTagIndex, 1);
setTags(newTags);
setActiveTagIndex((prev) =>
prev! === 0 ? null : prev! - 1
);
setTagCount((prevTagCount) => prevTagCount - 1);
onTagRemove?.(tags[activeTagIndex].text);
}
break;
case "ArrowRight":
e.preventDefault();
if (activeTagIndex === null) {
setActiveTagIndex(0);
} else {
setActiveTagIndex((prev) =>
prev! + 1 >= tags.length ? 0 : prev! + 1
);
}
break;
case "ArrowLeft":
e.preventDefault();
if (activeTagIndex === null) {
setActiveTagIndex(tags.length - 1);
} else {
setActiveTagIndex((prev) =>
prev! === 0 ? tags.length - 1 : prev! - 1
);
}
break;
case "Home":
e.preventDefault();
setActiveTagIndex(0);
break;
case "End":
e.preventDefault();
setActiveTagIndex(tags.length - 1);
break;
}
}
};
const removeTag = (idToRemove: string) => {
setTags(tags.filter((tag) => tag.id !== idToRemove));
onTagRemove?.(
tags.find((tag) => tag.id === idToRemove)?.text || ""
);
setTagCount((prevTagCount) => prevTagCount - 1);
};
const onSortEnd = (oldIndex: number, newIndex: number) => {
setTags((currentTags) => {
const newTags = [...currentTags];
const [removedTag] = newTags.splice(oldIndex, 1);
newTags.splice(newIndex, 0, removedTag);
return newTags;
});
};
const handleClearAll = () => {
if (!onClearAll) {
setActiveTagIndex(-1);
setTags([]);
return;
}
onClearAll?.();
};
// const filteredAutocompleteOptions = autocompleteFilter
// ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text))
// : autocompleteOptions;
const filteredAutocompleteOptions = useMemo(() => {
return (autocompleteOptions || []).filter((option) =>
option.text
.toLowerCase()
.includes(inputValue ? inputValue.toLowerCase() : "")
);
}, [inputValue, autocompleteOptions]);
const displayedTags = sortTags ? [...tags].sort() : tags;
const truncatedTags = truncate
? tags.map((tag) => ({
id: tag.id,
text:
tag.text?.length > truncate
? `${tag.text.substring(0, truncate)}...`
: tag.text
}))
: displayedTags;
return (
<div
className={`w-full flex ${!inlineTags && tags.length > 0 ? "gap-3" : ""} ${
inputFieldPosition === "bottom"
? "flex-col"
: inputFieldPosition === "top"
? "flex-col-reverse"
: "flex-row"
}`}
>
{!usePopoverForTags &&
(!inlineTags ? (
<TagList
tags={truncatedTags}
customTagRenderer={customTagRenderer}
variant={variant}
size={size}
shape={shape}
borderStyle={borderStyle}
textCase={textCase}
interaction={interaction}
animation={animation}
textStyle={textStyle}
onTagClick={onTagClick}
draggable={draggable}
onSortEnd={onSortEnd}
onRemoveTag={removeTag}
direction={direction}
inlineTags={inlineTags}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
classStyleProps={{
tagListClasses: styleClasses?.tagList,
tagClasses: styleClasses?.tag
}}
disabled={disabled}
/>
) : (
!enableAutocomplete && (
<div className="w-full">
<div
className={cn(
`flex flex-row flex-wrap items-center gap-1.5 p-1.5 w-full rounded-md border-2 border-input bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium 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 bg-transparent`,
styleClasses?.inlineTagsContainer
)}
>
<TagList
tags={truncatedTags}
customTagRenderer={customTagRenderer}
variant={variant}
size={size}
shape={shape}
borderStyle={borderStyle}
textCase={textCase}
interaction={interaction}
animation={animation}
textStyle={textStyle}
onTagClick={onTagClick}
draggable={draggable}
onSortEnd={onSortEnd}
onRemoveTag={removeTag}
direction={direction}
inlineTags={inlineTags}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
classStyleProps={{
tagListClasses:
styleClasses?.tagList,
tagClasses: styleClasses?.tag
}}
disabled={disabled}
/>
<Input
ref={inputRef}
id={id}
type="text"
placeholder={
maxTags !== undefined &&
tags.length >= maxTags
? placeholderWhenFull
: placeholder
}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
{...inputProps}
className={cn(
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit",
// className,
styleClasses?.input
)}
autoComplete={
enableAutocomplete ? "on" : "off"
}
list={
enableAutocomplete
? "autocomplete-options"
: undefined
}
disabled={
disabled ||
(maxTags !== undefined &&
tags.length >= maxTags)
}
/>
</div>
</div>
)
))}
{enableAutocomplete ? (
<div className="w-full">
<Autocomplete
tags={tags}
setTags={setTags}
setInputValue={setInputValue}
autocompleteOptions={
filteredAutocompleteOptions as Tag[]
}
setTagCount={setTagCount}
maxTags={maxTags}
onTagAdd={onTagAdd}
onTagRemove={onTagRemove}
allowDuplicates={allowDuplicates ?? false}
inlineTags={inlineTags}
usePortal={usePortal}
classStyleProps={{
command: styleClasses?.autoComplete?.command,
popoverTrigger:
styleClasses?.autoComplete?.popoverTrigger,
popoverContent:
styleClasses?.autoComplete?.popoverContent,
commandList:
styleClasses?.autoComplete?.commandList,
commandGroup:
styleClasses?.autoComplete?.commandGroup,
commandItem:
styleClasses?.autoComplete?.commandItem
}}
>
{!usePopoverForTags ? (
!inlineTags ? (
// <CommandInput
// placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
// ref={inputRef}
// value={inputValue}
// disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
// onChangeCapture={handleInputChange}
// onKeyDown={handleKeyDown}
// onFocus={handleInputFocus}
// onBlur={handleInputBlur}
// className={cn(
// 'w-full',
// // className,
// styleClasses?.input,
// )}
// />
<Input
ref={inputRef}
id={id}
type="text"
placeholder={
maxTags !== undefined &&
tags.length >= maxTags
? placeholderWhenFull
: placeholder
}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
{...inputProps}
className={cn(
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit",
// className,
styleClasses?.input
)}
autoComplete={
enableAutocomplete ? "on" : "off"
}
list={
enableAutocomplete
? "autocomplete-options"
: undefined
}
disabled={
disabled ||
(maxTags !== undefined &&
tags.length >= maxTags)
}
/>
) : (
<div
className={cn(
`flex flex-row flex-wrap items-center p-1.5 gap-1.5 h-fit w-full bg-transparent text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium 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`,
styleClasses?.inlineTagsContainer
)}
>
<TagList
tags={truncatedTags}
customTagRenderer={
customTagRenderer
}
variant={variant}
size={size}
shape={shape}
borderStyle={borderStyle}
textCase={textCase}
interaction={interaction}
animation={animation}
textStyle={textStyle}
onTagClick={onTagClick}
draggable={draggable}
onSortEnd={onSortEnd}
onRemoveTag={removeTag}
direction={direction}
inlineTags={inlineTags}
activeTagIndex={activeTagIndex}
setActiveTagIndex={
setActiveTagIndex
}
classStyleProps={{
tagListClasses:
styleClasses?.tagList,
tagClasses: styleClasses?.tag
}}
disabled={disabled}
/>
{/* <CommandInput
placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
ref={inputRef}
value={inputValue}
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
onChangeCapture={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
inlineTags={inlineTags}
className={cn(
'border-0 flex-1 w-fit h-5',
// className,
styleClasses?.input,
)}
/> */}
<Input
ref={inputRef}
id={id}
type="text"
placeholder={
maxTags !== undefined &&
tags.length >= maxTags
? placeholderWhenFull
: placeholder
}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
{...inputProps}
className={cn(
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit",
// className,
styleClasses?.input
)}
autoComplete={
enableAutocomplete
? "on"
: "off"
}
list={
enableAutocomplete
? "autocomplete-options"
: undefined
}
disabled={
disabled ||
(maxTags !== undefined &&
tags.length >= maxTags)
}
/>
</div>
)
) : (
<TagPopover
tags={truncatedTags}
customTagRenderer={customTagRenderer}
variant={variant}
size={size}
shape={shape}
borderStyle={borderStyle}
textCase={textCase}
interaction={interaction}
animation={animation}
textStyle={textStyle}
onTagClick={onTagClick}
draggable={draggable}
onSortEnd={onSortEnd}
onRemoveTag={removeTag}
direction={direction}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
classStyleProps={{
popoverClasses:
styleClasses?.tagPopover,
tagListClasses: styleClasses?.tagList,
tagClasses: styleClasses?.tag
}}
disabled={disabled}
>
{/* <CommandInput
placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
ref={inputRef}
value={inputValue}
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
onChangeCapture={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
className={cn(
'w-full',
// className,
styleClasses?.input,
)}
/> */}
<Input
ref={inputRef}
id={id}
type="text"
placeholder={
maxTags !== undefined &&
tags.length >= maxTags
? placeholderWhenFull
: placeholder
}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
{...inputProps}
className={cn(
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit",
// className,
styleClasses?.input
)}
autoComplete={
enableAutocomplete ? "on" : "off"
}
list={
enableAutocomplete
? "autocomplete-options"
: undefined
}
disabled={
disabled ||
(maxTags !== undefined &&
tags.length >= maxTags)
}
/>
</TagPopover>
)}
</Autocomplete>
</div>
) : (
<div className="w-full">
{!usePopoverForTags ? (
!inlineTags ? (
<Input
ref={inputRef}
id={id}
type="text"
placeholder={
maxTags !== undefined &&
tags.length >= maxTags
? placeholderWhenFull
: placeholder
}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
{...inputProps}
className={cn(
styleClasses?.input
// className
)}
autoComplete={
enableAutocomplete ? "on" : "off"
}
list={
enableAutocomplete
? "autocomplete-options"
: undefined
}
disabled={
disabled ||
(maxTags !== undefined &&
tags.length >= maxTags)
}
/>
) : null
) : (
<TagPopover
tags={truncatedTags}
customTagRenderer={customTagRenderer}
variant={variant}
size={size}
shape={shape}
borderStyle={borderStyle}
textCase={textCase}
interaction={interaction}
animation={animation}
textStyle={textStyle}
onTagClick={onTagClick}
draggable={draggable}
onSortEnd={onSortEnd}
onRemoveTag={removeTag}
direction={direction}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
classStyleProps={{
popoverClasses: styleClasses?.tagPopover,
tagListClasses: styleClasses?.tagList,
tagClasses: styleClasses?.tag
}}
disabled={disabled}
>
<Input
ref={inputRef}
id={id}
type="text"
placeholder={
maxTags !== undefined &&
tags.length >= maxTags
? placeholderWhenFull
: placeholder
}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
{...inputProps}
autoComplete={
enableAutocomplete ? "on" : "off"
}
list={
enableAutocomplete
? "autocomplete-options"
: undefined
}
disabled={
disabled ||
(maxTags !== undefined &&
tags.length >= maxTags)
}
className={cn(
"border-0 w-full",
styleClasses?.input
// className
)}
/>
</TagPopover>
)}
</div>
)}
{showCount && maxTags && (
<div className="flex">
<span className="text-muted-foreground text-sm mt-1 ml-auto">
{`${tagCount}`}/{`${maxTags}`}
</span>
</div>
)}
{clearAll && (
<Button
type="button"
onClick={handleClearAll}
className={cn("mt-2", styleClasses?.clearAllButton)}
>
Clear All
</Button>
)}
</div>
);
}
);
TagInput.displayName = "TagInput";
export function uuid() {
return crypto.getRandomValues(new Uint32Array(1))[0].toString();
}
export { TagInput };

View File

@@ -0,0 +1,205 @@
import React from "react";
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
import { Tag, TagProps } from "./tag";
import SortableList, { SortableItem } from "react-easy-sort";
import { cn } from "@app/lib/cn";
export type TagListProps = {
tags: TagType[];
customTagRenderer?: (tag: TagType, isActiveTag: boolean) => React.ReactNode;
direction?: TagProps["direction"];
onSortEnd: (oldIndex: number, newIndex: number) => void;
className?: string;
inlineTags?: boolean;
activeTagIndex?: number | null;
setActiveTagIndex?: (index: number | null) => void;
classStyleProps: {
tagListClasses: TagInputStyleClassesProps["tagList"];
tagClasses: TagInputStyleClassesProps["tag"];
};
disabled?: boolean;
} & Omit<TagProps, "tagObj">;
const DropTarget: React.FC = () => {
return <div className={cn("h-full rounded-md bg-secondary/50")} />;
};
export const TagList: React.FC<TagListProps> = ({
tags,
customTagRenderer,
direction,
draggable,
onSortEnd,
className,
inlineTags,
activeTagIndex,
setActiveTagIndex,
classStyleProps,
disabled,
...tagListProps
}) => {
const [draggedTagId, setDraggedTagId] = React.useState<string | null>(null);
const handleMouseDown = (id: string) => {
setDraggedTagId(id);
};
const handleMouseUp = () => {
setDraggedTagId(null);
};
return (
<>
{!inlineTags ? (
<div
className={cn(
"rounded-md w-full",
// className,
{
"flex flex-wrap gap-2": direction === "row",
"flex flex-col gap-2": direction === "column"
},
classStyleProps?.tagListClasses?.container
)}
>
{draggable ? (
<SortableList
onSortEnd={onSortEnd}
// className="flex flex-wrap gap-2 list"
className={`flex flex-wrap gap-2 list ${classStyleProps?.tagListClasses?.sortableList}`}
dropTarget={<DropTarget />}
>
{tags.map((tagObj, index) => (
<SortableItem key={tagObj.id}>
<div
onMouseDown={() =>
handleMouseDown(tagObj.id)
}
onMouseLeave={handleMouseUp}
className={cn(
{
"border border-solid border-primary rounded-md":
draggedTagId === tagObj.id
},
"transition-all duration-200 ease-in-out"
)}
>
{customTagRenderer ? (
customTagRenderer(
tagObj,
index === activeTagIndex
)
) : (
<Tag
tagObj={tagObj}
isActiveTag={
index === activeTagIndex
}
direction={direction}
draggable={draggable}
tagClasses={
classStyleProps?.tagClasses
}
{...tagListProps}
disabled={disabled}
/>
)}
</div>
</SortableItem>
))}
</SortableList>
) : (
tags.map((tagObj, index) =>
customTagRenderer ? (
customTagRenderer(
tagObj,
index === activeTagIndex
)
) : (
<Tag
key={tagObj.id}
tagObj={tagObj}
isActiveTag={index === activeTagIndex}
direction={direction}
draggable={draggable}
tagClasses={classStyleProps?.tagClasses}
{...tagListProps}
disabled={disabled}
/>
)
)
)}
</div>
) : (
<>
{draggable ? (
<SortableList
onSortEnd={onSortEnd}
className="flex flex-wrap gap-2 list"
dropTarget={<DropTarget />}
>
{tags.map((tagObj, index) => (
<SortableItem key={tagObj.id}>
<div
onMouseDown={() =>
handleMouseDown(tagObj.id)
}
onMouseLeave={handleMouseUp}
className={cn(
{
"border border-solid border-primary rounded-md":
draggedTagId === tagObj.id
},
"transition-all duration-200 ease-in-out"
)}
>
{customTagRenderer ? (
customTagRenderer(
tagObj,
index === activeTagIndex
)
) : (
<Tag
tagObj={tagObj}
isActiveTag={
index === activeTagIndex
}
direction={direction}
draggable={draggable}
tagClasses={
classStyleProps?.tagClasses
}
{...tagListProps}
disabled={disabled}
/>
)}
</div>
</SortableItem>
))}
</SortableList>
) : (
tags.map((tagObj, index) =>
customTagRenderer ? (
customTagRenderer(
tagObj,
index === activeTagIndex
)
) : (
<Tag
key={tagObj.id}
tagObj={tagObj}
isActiveTag={index === activeTagIndex}
direction={direction}
draggable={draggable}
tagClasses={classStyleProps?.tagClasses}
{...tagListProps}
disabled={disabled}
/>
)
)
)}
</>
)}
</>
);
};

View File

@@ -0,0 +1,207 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
import { TagList, TagListProps } from "./tag-list";
import { Button } from "../ui/button";
import { cn } from "@app/lib/cn";
type TagPopoverProps = {
children: React.ReactNode;
tags: TagType[];
customTagRenderer?: (tag: TagType, isActiveTag: boolean) => React.ReactNode;
activeTagIndex?: number | null;
setActiveTagIndex?: (index: number | null) => void;
classStyleProps: {
popoverClasses: TagInputStyleClassesProps["tagPopover"];
tagListClasses: TagInputStyleClassesProps["tagList"];
tagClasses: TagInputStyleClassesProps["tag"];
};
disabled?: boolean;
usePortal?: boolean;
} & TagListProps;
export const TagPopover: React.FC<TagPopoverProps> = ({
children,
tags,
customTagRenderer,
activeTagIndex,
setActiveTagIndex,
classStyleProps,
disabled,
usePortal,
...tagProps
}) => {
const triggerContainerRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const popoverContentRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [popoverWidth, setPopoverWidth] = useState<number>(0);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [inputFocused, setInputFocused] = useState(false);
const [sideOffset, setSideOffset] = useState<number>(0);
useEffect(() => {
const handleResize = () => {
if (triggerContainerRef.current && triggerRef.current) {
setPopoverWidth(triggerContainerRef.current.offsetWidth);
setSideOffset(
triggerContainerRef.current.offsetWidth -
triggerRef?.current?.offsetWidth
);
}
};
handleResize(); // Call on mount and layout changes
window.addEventListener("resize", handleResize); // Adjust on window resize
return () => window.removeEventListener("resize", handleResize);
}, [triggerContainerRef, triggerRef]);
// Close the popover when clicking outside of it
useEffect(() => {
const handleOutsideClick = (
event: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent
) => {
if (
isPopoverOpen &&
triggerContainerRef.current &&
popoverContentRef.current &&
!triggerContainerRef.current.contains(event.target as Node) &&
!popoverContentRef.current.contains(event.target as Node)
) {
setIsPopoverOpen(false);
}
};
document.addEventListener("mousedown", handleOutsideClick);
return () => {
document.removeEventListener("mousedown", handleOutsideClick);
};
}, [isPopoverOpen]);
const handleOpenChange = useCallback(
(open: boolean) => {
if (open && triggerContainerRef.current) {
setPopoverWidth(triggerContainerRef.current.offsetWidth);
}
if (open) {
inputRef.current?.focus();
setIsPopoverOpen(open);
}
},
[inputFocused]
);
const handleInputFocus = (
event:
| React.FocusEvent<HTMLInputElement>
| React.FocusEvent<HTMLTextAreaElement>
) => {
// Only set inputFocused to true if the popover is already open.
// This will prevent the popover from opening due to an input focus if it was initially closed.
if (isPopoverOpen) {
setInputFocused(true);
}
const userOnFocus = (children as React.ReactElement<any>).props.onFocus;
if (userOnFocus) userOnFocus(event);
};
const handleInputBlur = (
event:
| React.FocusEvent<HTMLInputElement>
| React.FocusEvent<HTMLTextAreaElement>
) => {
setInputFocused(false);
// Allow the popover to close if no other interactions keep it open
if (!isPopoverOpen) {
setIsPopoverOpen(false);
}
const userOnBlur = (children as React.ReactElement<any>).props.onBlur;
if (userOnBlur) userOnBlur(event);
};
return (
<Popover
open={isPopoverOpen}
onOpenChange={handleOpenChange}
modal={usePortal}
>
<div
className="relative flex items-center rounded-md border border-input bg-transparent pr-3"
ref={triggerContainerRef}
>
{React.cloneElement(children as React.ReactElement<any>, {
onFocus: handleInputFocus,
onBlur: handleInputBlur,
ref: inputRef
})}
<PopoverTrigger asChild>
<Button
ref={triggerRef}
variant="ghost"
size="icon"
role="combobox"
className={cn(
`hover:bg-transparent`,
classStyleProps?.popoverClasses?.popoverTrigger
)}
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`lucide lucide-chevron-down h-4 w-4 shrink-0 opacity-50 ${isPopoverOpen ? "rotate-180" : "rotate-0"}`}
>
<path d="m6 9 6 6 6-6"></path>
</svg>
</Button>
</PopoverTrigger>
</div>
<PopoverContent
ref={popoverContentRef}
className={cn(
`w-full space-y-3`,
classStyleProps?.popoverClasses?.popoverContent
)}
style={{
marginLeft: `-${sideOffset}px`,
width: `${popoverWidth}px`
}}
>
<div className="space-y-1">
<h4 className="text-sm font-medium leading-none">
Entered Tags
</h4>
<p className="text-sm text-muted-foregrounsd text-left">
These are the tags you&apos;ve entered.
</p>
</div>
<TagList
tags={tags}
customTagRenderer={customTagRenderer}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
classStyleProps={{
tagListClasses: classStyleProps?.tagListClasses,
tagClasses: classStyleProps?.tagClasses
}}
{...tagProps}
disabled={disabled}
/>
</PopoverContent>
</Popover>
);
};

169
src/components/tags/tag.tsx Normal file
View File

@@ -0,0 +1,169 @@
import React from "react";
import { Button } from "../ui/button";
import {
TagInputProps,
TagInputStyleClassesProps,
type Tag as TagType
} from "./tag-input";
import { cva } from "class-variance-authority";
import { cn } from "@app/lib/cn";
export const tagVariants = cva(
"transition-all border inline-flex items-center text-sm pl-2 rounded-md",
{
variants: {
variant: {
default:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50",
primary:
"bg-primary border-primary text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50",
destructive:
"bg-destructive border-destructive text-destructive-foreground hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-50"
},
size: {
sm: "text-xs h-6",
md: "text-sm h-8",
lg: "text-base h-9",
xl: "text-lg h-10"
},
shape: {
default: "rounded-sm",
rounded: "rounded-lg",
square: "rounded-none",
pill: "rounded-full"
},
borderStyle: {
default: "border-solid",
none: "border-none",
dashed: "border-dashed",
dotted: "border-dotted",
double: "border-double"
},
textCase: {
uppercase: "uppercase",
lowercase: "lowercase",
capitalize: "capitalize"
},
interaction: {
clickable: "cursor-pointer hover:shadow-md",
nonClickable: "cursor-default"
},
animation: {
none: "",
fadeIn: "animate-fadeIn",
slideIn: "animate-slideIn",
bounce: "animate-bounce"
},
textStyle: {
normal: "font-normal",
bold: "font-bold",
italic: "italic",
underline: "underline",
lineThrough: "line-through"
}
},
defaultVariants: {
variant: "default",
size: "md",
shape: "default",
borderStyle: "none",
interaction: "nonClickable",
animation: "fadeIn",
textStyle: "normal"
}
}
);
export type TagProps = {
tagObj: TagType;
variant: TagInputProps["variant"];
size: TagInputProps["size"];
shape: TagInputProps["shape"];
borderStyle: TagInputProps["borderStyle"];
textCase: TagInputProps["textCase"];
interaction: TagInputProps["interaction"];
animation: TagInputProps["animation"];
textStyle: TagInputProps["textStyle"];
onRemoveTag: (id: string) => void;
isActiveTag?: boolean;
tagClasses?: TagInputStyleClassesProps["tag"];
disabled?: boolean;
} & Pick<TagInputProps, "direction" | "onTagClick" | "draggable">;
export const Tag: React.FC<TagProps> = ({
tagObj,
direction,
draggable,
onTagClick,
onRemoveTag,
variant,
size,
shape,
borderStyle,
textCase,
interaction,
animation,
textStyle,
isActiveTag,
tagClasses,
disabled
}) => {
return (
<span
key={tagObj.id}
draggable={draggable}
className={cn(
tagVariants({
variant,
size,
shape,
borderStyle,
textCase,
interaction,
animation,
textStyle
}),
{
"justify-between w-full": direction === "column",
"cursor-pointer": draggable,
"ring-ring ring-offset-2 ring-2 ring-offset-background":
isActiveTag
},
tagClasses?.body
)}
onClick={() => onTagClick?.(tagObj)}
>
{tagObj.text}
<Button
type="button"
variant="ghost"
onClick={(e) => {
e.stopPropagation(); // Prevent event from bubbling up to the tag span
onRemoveTag(tagObj.id);
}}
disabled={disabled}
className={cn(
`p-1 h-full hover:bg-transparent`,
tagClasses?.closeButton
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-x"
>
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</Button>
</span>
);
};

View File

@@ -15,9 +15,11 @@ const buttonVariants = cva(
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-card hover:bg-accent hover:text-accent-foreground",
"border-2 border-input bg-card hover:bg-accent hover:text-accent-foreground",
outlinePrimary:
"border-2 border-primary bg-card hover:bg-primary/10 text-primary",
secondary:
"bg-secondary border border-input text-secondary-foreground hover:bg-secondary/80",
"bg-secondary border border-input border-2 text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
text: "",
link: "text-primary underline-offset-4 hover:underline",

View File

@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
"peer h-4 w-4 shrink-0 rounded-sm border-2 border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}

View File

@@ -41,7 +41,7 @@ const InputOTPSlot = React.forwardRef<
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-base md:text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
"relative flex h-10 w-10 items-center justify-center border-y-2 border-r-2 border-input text-base md:text-sm transition-all first:rounded-l-md first:border-l-2 last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}

View File

@@ -15,7 +15,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={showPassword ? "text" : "password"}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md: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-2 border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md: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
)}
ref={ref}
@@ -39,7 +39,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md: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-2 border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md: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
)}
ref={ref}

View File

@@ -28,7 +28,7 @@ const RadioGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"aspect-square h-4 w-4 rounded-full border-2 border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}

View File

@@ -19,9 +19,9 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between border border-input bg-card px-3 py-2 text-base md: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,
"rounded-md"
"flex h-9 w-full items-center justify-between border-2 border-input bg-card px-3 py-2 text-base md: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",
"rounded-md",
className
)}
{...props}
>

View File

@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-4 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
@@ -19,7 +19,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-3 w-3 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>

View File

@@ -77,7 +77,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
"h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}

View File

@@ -25,7 +25,7 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-3 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-3 pr-8 transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=open]:fade-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[swipe=end]:animate-out",
{
variants: {
variant: {