mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-13 00:16:39 +00:00
Merge branch 'dev' into transfer-resource-to-new-site
This commit is contained in:
@@ -17,7 +17,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import Link from "next/link";
|
||||
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 { useUserContext } from "@app/hooks/useUserContext";
|
||||
@@ -75,14 +75,14 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
||||
className="block w-full"
|
||||
>
|
||||
<Link
|
||||
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
||||
className="block w-full"
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
Manage User
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
{userRow.email !== user?.email && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
|
||||
@@ -45,21 +45,65 @@ import {
|
||||
} from "@app/components/ui/command";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
import CustomDomainInput from "./[resourceId]/CustomDomainInput";
|
||||
import { Axios, AxiosResponse } from "axios";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { Resource } from "@server/db/schema";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
||||
import Link from "next/link";
|
||||
import { SquareArrowOutUpRight } from "lucide-react";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
|
||||
const accountFormSchema = z.object({
|
||||
subdomain: subdomainSchema,
|
||||
name: z.string(),
|
||||
siteId: z.number()
|
||||
});
|
||||
const createResourceFormSchema = z
|
||||
.object({
|
||||
subdomain: z.string().optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
siteId: z.number(),
|
||||
http: z.boolean(),
|
||||
protocol: z.string(),
|
||||
proxyPort: z.number().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.http) {
|
||||
return z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(65535)
|
||||
.safeParse(data.proxyPort).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Invalid port number",
|
||||
path: ["proxyPort"]
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.http) {
|
||||
return subdomainSchema.safeParse(data.subdomain).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Invalid subdomain",
|
||||
path: ["subdomain"]
|
||||
}
|
||||
);
|
||||
|
||||
type AccountFormValues = z.infer<typeof accountFormSchema>;
|
||||
type CreateResourceFormValues = z.infer<typeof createResourceFormSchema>;
|
||||
|
||||
type CreateResourceFormProps = {
|
||||
open: boolean;
|
||||
@@ -81,15 +125,22 @@ export default function CreateResourceForm({
|
||||
const router = useRouter();
|
||||
|
||||
const { org } = useOrgContext();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
||||
const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain);
|
||||
|
||||
const form = useForm<AccountFormValues>({
|
||||
resolver: zodResolver(accountFormSchema),
|
||||
const [showSnippets, setShowSnippets] = useState(false);
|
||||
|
||||
const [resourceId, setResourceId] = useState<number | null>(null);
|
||||
|
||||
const form = useForm<CreateResourceFormValues>({
|
||||
resolver: zodResolver(createResourceFormSchema),
|
||||
defaultValues: {
|
||||
subdomain: "",
|
||||
name: "My Resource"
|
||||
name: "My Resource",
|
||||
http: true,
|
||||
protocol: "tcp"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -112,16 +163,17 @@ export default function CreateResourceForm({
|
||||
fetchSites();
|
||||
}, [open]);
|
||||
|
||||
async function onSubmit(data: AccountFormValues) {
|
||||
console.log(data);
|
||||
|
||||
async function onSubmit(data: CreateResourceFormValues) {
|
||||
const res = await api
|
||||
.put<AxiosResponse<Resource>>(
|
||||
`/org/${orgId}/site/${data.siteId}/resource/`,
|
||||
{
|
||||
name: data.name,
|
||||
subdomain: data.subdomain
|
||||
// subdomain: data.subdomain,
|
||||
subdomain: data.http ? data.subdomain : undefined,
|
||||
http: data.http,
|
||||
protocol: data.protocol,
|
||||
proxyPort: data.http ? undefined : data.proxyPort,
|
||||
siteId: data.siteId
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
@@ -137,11 +189,21 @@ export default function CreateResourceForm({
|
||||
|
||||
if (res && res.status === 201) {
|
||||
const id = res.data.data.resourceId;
|
||||
// navigate to the resource page
|
||||
router.push(`/${orgId}/settings/resources/${id}`);
|
||||
setResourceId(id);
|
||||
|
||||
if (data.http) {
|
||||
goToResource(id);
|
||||
} else {
|
||||
setShowSnippets(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function goToResource(id?: number) {
|
||||
// navigate to the resource page
|
||||
router.push(`/${orgId}/settings/resources/${id || resourceId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
@@ -162,153 +224,358 @@ export default function CreateResourceForm({
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="create-resource-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Your name"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is the name that will be
|
||||
displayed for this resource.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subdomain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Subdomain</FormLabel>
|
||||
<FormControl>
|
||||
<CustomDomainInput
|
||||
value={field.value}
|
||||
domainSuffix={domainSuffix}
|
||||
placeholder="Enter subdomain"
|
||||
onChange={(value) =>
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is the fully qualified
|
||||
domain name that will be used to
|
||||
access the resource.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="siteId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Site</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
{!showSnippets && (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="create-resource-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Your name"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is the name that will
|
||||
be displayed for this
|
||||
resource.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!env.flags.allowRawResources || (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="http"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
HTTP Resource
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Toggle if this is an
|
||||
HTTP resource or a
|
||||
raw TCP/UDP resource
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"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>
|
||||
<Switch
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search site..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No site found.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sites.map(
|
||||
(site) => (
|
||||
<CommandItem
|
||||
value={
|
||||
site.name
|
||||
}
|
||||
key={
|
||||
site.siteId
|
||||
}
|
||||
onSelect={() => {
|
||||
form.setValue(
|
||||
"siteId",
|
||||
site.siteId
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
site.siteId ===
|
||||
field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{
|
||||
site.name
|
||||
}
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription>
|
||||
This is the site that will be
|
||||
used in the dashboard.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{form.watch("http") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subdomain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Subdomain
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<CustomDomainInput
|
||||
value={
|
||||
field.value ??
|
||||
""
|
||||
}
|
||||
domainSuffix={
|
||||
domainSuffix
|
||||
}
|
||||
placeholder="Enter subdomain"
|
||||
onChange={(value) =>
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is the fully
|
||||
qualified domain name
|
||||
that will be used to
|
||||
access the resource.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!form.watch("http") && (
|
||||
<Link
|
||||
className="text-sm text-primary flex items-center gap-1"
|
||||
href="https://docs.fossorial.io/Getting%20Started/tcp-udp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>
|
||||
Learn how to configure TCP/UDP
|
||||
resources
|
||||
</span>
|
||||
<SquareArrowOutUpRight size={14} />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!form.watch("http") && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="protocol"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Protocol
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a protocol" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="tcp">
|
||||
TCP
|
||||
</SelectItem>
|
||||
<SelectItem value="udp">
|
||||
UDP
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
The protocol to use
|
||||
for the resource
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="proxyPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Port Number
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter port number"
|
||||
value={
|
||||
field.value ??
|
||||
""
|
||||
}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target
|
||||
.value
|
||||
? parseInt(
|
||||
e
|
||||
.target
|
||||
.value
|
||||
)
|
||||
: null
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The port number to
|
||||
proxy requests to
|
||||
(required for
|
||||
non-HTTP resources)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="siteId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Site</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"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="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search site..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No site
|
||||
found.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sites.map(
|
||||
(
|
||||
site
|
||||
) => (
|
||||
<CommandItem
|
||||
value={
|
||||
site.niceId
|
||||
}
|
||||
key={
|
||||
site.siteId
|
||||
}
|
||||
onSelect={() => {
|
||||
form.setValue(
|
||||
"siteId",
|
||||
site.siteId
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
site.siteId ===
|
||||
field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{
|
||||
site.name
|
||||
}
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription>
|
||||
This is the site that will
|
||||
be used in the dashboard.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{showSnippets && (
|
||||
<div>
|
||||
<div className="flex items-start space-x-4 mb-6 last:mb-0">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-muted text-primary-foreground rounded-full flex items-center justify-center font-bold">
|
||||
1
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<h3 className="text-lg font-semibold mb-3">
|
||||
Traefik: Add Entrypoints
|
||||
</h3>
|
||||
<CopyTextBox
|
||||
text={`entryPoints:
|
||||
${form.getValues("protocol")}-${form.getValues("proxyPort")}:
|
||||
address: ":${form.getValues("proxyPort")}/${form.getValues("protocol")}"`}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-4 mb-6 last:mb-0">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-muted text-primary-foreground rounded-full flex items-center justify-center font-bold">
|
||||
2
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<h3 className="text-lg font-semibold mb-3">
|
||||
Gerbil: Expose Ports in Docker
|
||||
Compose
|
||||
</h3>
|
||||
<CopyTextBox
|
||||
text={`ports:
|
||||
- ${form.getValues("proxyPort")}:${form.getValues("proxyPort")}${form.getValues("protocol") === "tcp" ? "" : "/" + form.getValues("protocol")}`}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
className="text-sm text-primary flex items-center gap-1"
|
||||
href="https://docs.fossorial.io/Getting%20Started/tcp-udp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>
|
||||
Make sure to follow the full guide
|
||||
</span>
|
||||
<SquareArrowOutUpRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<Button
|
||||
{!showSnippets && <Button
|
||||
type="submit"
|
||||
form="create-resource-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Create Resource
|
||||
</Button>
|
||||
</Button>}
|
||||
|
||||
{showSnippets && <Button
|
||||
loading={loading}
|
||||
onClick={() => goToResource()}
|
||||
>
|
||||
Go to Resource
|
||||
</Button>}
|
||||
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</CredenzaClose>
|
||||
|
||||
@@ -25,7 +25,7 @@ import CreateResourceForm from "./CreateResourceForm";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { set } from "zod";
|
||||
import { formatAxiosError } from "@app/lib/api";;
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
@@ -39,6 +39,9 @@ export type ResourceRow = {
|
||||
site: string;
|
||||
siteId: string;
|
||||
hasAuth: boolean;
|
||||
http: boolean;
|
||||
protocol: string;
|
||||
proxyPort: number | null;
|
||||
};
|
||||
|
||||
type ResourcesTableProps = {
|
||||
@@ -91,14 +94,14 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
|
||||
>
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
View settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedResource(resourceRow);
|
||||
@@ -146,24 +149,40 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<Button variant="outline">
|
||||
<Link
|
||||
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteId}`}
|
||||
>
|
||||
<Link
|
||||
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
{resourceRow.site}
|
||||
</Link>
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "protocol",
|
||||
header: "Protocol",
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<span>{resourceRow.protocol.toUpperCase()}</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "domain",
|
||||
header: "Full URL",
|
||||
header: "Access",
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div>
|
||||
{!resourceRow.http ? (
|
||||
<CopyToClipboard text={resourceRow.proxyPort!.toString()} isLink={false} />
|
||||
) : (
|
||||
<CopyToClipboard text={resourceRow.domain} isLink={true} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -186,17 +205,23 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div>
|
||||
{resourceRow.hasAuth ? (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span>Protected</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-yellow-500 flex items-center space-x-2">
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
<span>Not Protected</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
||||
{!resourceRow.http ? (
|
||||
<span>--</span>
|
||||
) :
|
||||
resourceRow.hasAuth ? (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span>Protected</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-yellow-500 flex items-center space-x-2">
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
<span>Not Protected</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function CustomDomainInput({
|
||||
className="rounded-r-none flex-grow"
|
||||
/>
|
||||
<div className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-muted-foreground">
|
||||
<span className="text-sm">{domainSuffix}</span>
|
||||
<span className="text-sm">.{domainSuffix}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
InfoIcon,
|
||||
LinkIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
ShieldCheck,
|
||||
ShieldOff
|
||||
} from "lucide-react";
|
||||
@@ -42,37 +38,65 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<InfoSections>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Authentication</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{authInfo.password ||
|
||||
authInfo.pincode ||
|
||||
authInfo.sso ||
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<Separator orientation="vertical" />
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>URL</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard text={fullUrl} isLink={true} />
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{resource.http ? (
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Authentication
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{authInfo.password ||
|
||||
authInfo.pincode ||
|
||||
authInfo.sso ||
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<Separator orientation="vertical" />
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>URL</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={fullUrl}
|
||||
isLink={true}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Protocol</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span>{resource.protocol.toUpperCase()}</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<Separator orientation="vertical" />
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Port</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={resource.proxyPort!.toString()}
|
||||
isLink={false}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</>
|
||||
)}
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
|
||||
const UsersRolesFormSchema = z.object({
|
||||
roles: z.array(
|
||||
@@ -665,10 +666,12 @@ export default function ResourceAuthenticationPage() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Whitelisted Emails
|
||||
<InfoPopup
|
||||
text="Whitelisted Emails"
|
||||
info="Only users with these email addresses will be able to access this resource. They will be prompted to enter a one-time password sent to their email. Wildcards (*@example.com) can be used to allow any email address from a domain."
|
||||
/>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{/* @ts-ignore */}
|
||||
{/* @ts-ignore */}
|
||||
<TagInput
|
||||
{...field}
|
||||
@@ -681,6 +684,17 @@ export default function ResourceAuthenticationPage() {
|
||||
return z
|
||||
.string()
|
||||
.email()
|
||||
.or(
|
||||
z
|
||||
.string()
|
||||
.regex(
|
||||
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
|
||||
{
|
||||
message:
|
||||
"Invalid email address. Wildcard (*) must be the entire local part."
|
||||
}
|
||||
)
|
||||
)
|
||||
.safeParse(
|
||||
tag
|
||||
).success;
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { useSiteContext } from "@app/hooks/useSiteContext";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
|
||||
// Regular expressions for validation
|
||||
const DOMAIN_REGEX =
|
||||
@@ -94,7 +95,7 @@ const domainSchema = z
|
||||
|
||||
const addTargetSchema = z.object({
|
||||
ip: domainSchema,
|
||||
method: z.string(),
|
||||
method: z.string().nullable(),
|
||||
port: z.coerce.number().int().positive()
|
||||
// protocol: z.string(),
|
||||
});
|
||||
@@ -130,8 +131,8 @@ export default function ReverseProxyTargets(props: {
|
||||
resolver: zodResolver(addTargetSchema),
|
||||
defaultValues: {
|
||||
ip: "",
|
||||
method: "http",
|
||||
port: 80
|
||||
method: resource.http ? "http" : null,
|
||||
port: resource.http ? 80 : resource.proxyPort || 1234
|
||||
// protocol: "TCP",
|
||||
}
|
||||
});
|
||||
@@ -321,7 +322,7 @@ export default function ReverseProxyTargets(props: {
|
||||
});
|
||||
|
||||
setSslEnabled(val);
|
||||
updateResource({ ssl: sslEnabled });
|
||||
updateResource({ ssl: val });
|
||||
|
||||
toast({
|
||||
title: "SSL Configuration",
|
||||
@@ -330,26 +331,6 @@ export default function ReverseProxyTargets(props: {
|
||||
}
|
||||
|
||||
const columns: ColumnDef<LocalTarget>[] = [
|
||||
{
|
||||
accessorKey: "method",
|
||||
header: "Method",
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.method}
|
||||
onValueChange={(value) =>
|
||||
updateTarget(row.original.targetId, { method: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="min-w-[100px]">
|
||||
{row.original.method}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
<SelectItem value="https">https</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: "IP / Hostname",
|
||||
@@ -436,6 +417,32 @@ export default function ReverseProxyTargets(props: {
|
||||
}
|
||||
];
|
||||
|
||||
if (resource.http) {
|
||||
const methodCol: ColumnDef<LocalTarget> = {
|
||||
accessorKey: "method",
|
||||
header: "Method",
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.method ?? ""}
|
||||
onValueChange={(value) =>
|
||||
updateTarget(row.original.targetId, { method: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="min-w-[100px]">
|
||||
{row.original.method}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
<SelectItem value="https">https</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
};
|
||||
|
||||
// add this to the first column
|
||||
columns.unshift(methodCol);
|
||||
}
|
||||
|
||||
const table = useReactTable({
|
||||
data: targets,
|
||||
columns,
|
||||
@@ -451,29 +458,29 @@ export default function ReverseProxyTargets(props: {
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
{/* SSL Section */}
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
SSL Configuration
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Setup SSL to secure your connections with LetsEncrypt
|
||||
certificates
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SwitchInput
|
||||
id="ssl-toggle"
|
||||
label="Enable SSL (https)"
|
||||
defaultChecked={resource.ssl}
|
||||
onCheckedChange={async (val) => {
|
||||
await saveSsl(val);
|
||||
}}
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{resource.http && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
SSL Configuration
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Setup SSL to secure your connections with
|
||||
LetsEncrypt certificates
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SwitchInput
|
||||
id="ssl-toggle"
|
||||
label="Enable SSL (https)"
|
||||
defaultChecked={resource.ssl}
|
||||
onCheckedChange={async (val) => {
|
||||
await saveSsl(val);
|
||||
}}
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
{/* Targets Section */}
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
@@ -491,39 +498,47 @@ export default function ReverseProxyTargets(props: {
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={addTargetForm.control}
|
||||
name="method"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Method</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
addTargetForm.setValue(
|
||||
"method",
|
||||
{resource.http && (
|
||||
<FormField
|
||||
control={addTargetForm.control}
|
||||
name="method"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Method</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={
|
||||
field.value ||
|
||||
undefined
|
||||
}
|
||||
onValueChange={(
|
||||
value
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="method">
|
||||
<SelectValue placeholder="Select method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">
|
||||
http
|
||||
</SelectItem>
|
||||
<SelectItem value="https">
|
||||
https
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) => {
|
||||
addTargetForm.setValue(
|
||||
"method",
|
||||
value
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="method">
|
||||
<SelectValue placeholder="Select method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">
|
||||
http
|
||||
</SelectItem>
|
||||
<SelectItem value="https">
|
||||
https
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={addTargetForm.control}
|
||||
name="ip"
|
||||
@@ -637,6 +652,9 @@ export default function ReverseProxyTargets(props: {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Adding more than one target above will enable load balancing.
|
||||
</p>
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Command,
|
||||
@@ -21,10 +20,8 @@ import {
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@/components/ui/command";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -50,16 +47,47 @@ import {
|
||||
} from "@app/components/Settings";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import CustomDomainInput from "../CustomDomainInput";
|
||||
import ResourceInfoBox from "../ResourceInfoBox";
|
||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string(),
|
||||
subdomain: subdomainSchema
|
||||
// siteId: z.number(),
|
||||
});
|
||||
const GeneralFormSchema = z
|
||||
.object({
|
||||
subdomain: z.string().optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
proxyPort: z.number().optional(),
|
||||
http: z.boolean()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.http) {
|
||||
return z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(65535)
|
||||
.safeParse(data.proxyPort).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Invalid port number",
|
||||
path: ["proxyPort"]
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.http) {
|
||||
return subdomainSchema.safeParse(data.subdomain).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Invalid subdomain",
|
||||
path: ["subdomain"]
|
||||
}
|
||||
);
|
||||
|
||||
const TransferFormSchema = z.object({
|
||||
siteId: z.number()
|
||||
@@ -89,8 +117,9 @@ export default function GeneralForm() {
|
||||
resolver: zodResolver(GeneralFormSchema),
|
||||
defaultValues: {
|
||||
name: resource.name,
|
||||
subdomain: resource.subdomain
|
||||
// siteId: resource.siteId!,
|
||||
subdomain: resource.subdomain ? resource.subdomain : undefined,
|
||||
proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
|
||||
http: resource.http
|
||||
},
|
||||
mode: "onChange"
|
||||
});
|
||||
@@ -211,33 +240,78 @@ export default function GeneralForm() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subdomain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Subdomain</FormLabel>
|
||||
<FormControl>
|
||||
<CustomDomainInput
|
||||
value={field.value}
|
||||
domainSuffix={domainSuffix}
|
||||
placeholder="Enter subdomain"
|
||||
onChange={(value) =>
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is the subdomain that will
|
||||
be used to access the resource.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{resource.http ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subdomain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Subdomain</FormLabel>
|
||||
<FormControl>
|
||||
<CustomDomainInput
|
||||
value={
|
||||
field.value || ""
|
||||
}
|
||||
domainSuffix={
|
||||
domainSuffix
|
||||
}
|
||||
placeholder="Enter subdomain"
|
||||
onChange={(value) =>
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is the subdomain that
|
||||
will be used to access the
|
||||
resource.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="proxyPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Port Number
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter port number"
|
||||
value={
|
||||
field.value ?? ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value
|
||||
? parseInt(
|
||||
e
|
||||
.target
|
||||
.value
|
||||
)
|
||||
: null
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is the port that will
|
||||
be used to access the
|
||||
resource.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
|
||||
@@ -90,13 +90,16 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
title: "Connectivity",
|
||||
href: `/{orgId}/settings/resources/{resourceId}/connectivity`
|
||||
// icon: <Cloud className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
}
|
||||
];
|
||||
|
||||
if (resource.http) {
|
||||
sidebarNavItems.push({
|
||||
title: "Authentication",
|
||||
href: `/{orgId}/settings/resources/{resourceId}/authentication`
|
||||
// icon: <Shield className="w-4 h-4" />,
|
||||
}
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -53,6 +53,9 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
|
||||
site: resource.siteName || "None",
|
||||
siteId: resource.siteId || "Unknown",
|
||||
protocol: resource.protocol,
|
||||
proxyPort: resource.proxyPort,
|
||||
http: resource.http,
|
||||
hasAuth:
|
||||
resource.sso ||
|
||||
resource.pincodeId !== null ||
|
||||
|
||||
@@ -153,7 +153,9 @@ export default function CreateShareLinkForm({
|
||||
|
||||
if (res?.status === 200) {
|
||||
setResources(
|
||||
res.data.data.resources.map((r) => ({
|
||||
res.data.data.resources.filter((r) => {
|
||||
return r.http;
|
||||
}).map((r) => ({
|
||||
resourceId: r.resourceId,
|
||||
name: r.name,
|
||||
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
|
||||
@@ -318,7 +320,7 @@ export default function CreateShareLinkForm({
|
||||
) => (
|
||||
<CommandItem
|
||||
value={
|
||||
r.name
|
||||
r.resourceId.toString()
|
||||
}
|
||||
key={
|
||||
r.resourceId
|
||||
|
||||
@@ -145,14 +145,12 @@ export default function ShareLinksTable({
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Button variant="outline">
|
||||
<Link
|
||||
href={`/${orgId}/settings/resources/${r.resourceId}`}
|
||||
>
|
||||
<Link href={`/${orgId}/settings/resources/${r.resourceId}`}>
|
||||
<Button variant="outline">
|
||||
{r.resourceName}
|
||||
</Link>
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -92,14 +92,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
||||
>
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
View settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedSite(siteRow);
|
||||
|
||||
@@ -5,14 +5,12 @@ import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@app/components/ui/card";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { AuthWithAccessTokenResponse } from "@server/routers/resource";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -32,7 +30,17 @@ export default function AccessToken({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
|
||||
function appendRequestToken(url: string, token: string) {
|
||||
const fullUrl = new URL(url);
|
||||
fullUrl.searchParams.append(
|
||||
env.server.resourceSessionRequestParam,
|
||||
token
|
||||
);
|
||||
return fullUrl.toString();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessTokenId || !accessToken) {
|
||||
@@ -51,7 +59,10 @@ export default function AccessToken({
|
||||
|
||||
if (res.data.data.session) {
|
||||
setIsValid(true);
|
||||
window.location.href = redirectUrl;
|
||||
window.location.href = appendRequestToken(
|
||||
redirectUrl,
|
||||
res.data.data.session
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error checking access token", e);
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function ResourceAccessDenied() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
You're not alowed to access this resource. If this is a mistake,
|
||||
You're not allowed to access this resource. If this is a mistake,
|
||||
please contact the administrator.
|
||||
<div className="text-center mt-4">
|
||||
<Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
@@ -30,9 +29,6 @@ import {
|
||||
Key,
|
||||
User,
|
||||
Send,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Lock,
|
||||
AtSign
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -47,10 +43,8 @@ import { AxiosResponse } from "axios";
|
||||
import LoginForm from "@app/components/LoginForm";
|
||||
import {
|
||||
AuthWithPasswordResponse,
|
||||
AuthWithAccessTokenResponse,
|
||||
AuthWithWhitelistResponse
|
||||
} from "@server/routers/resource";
|
||||
import { redirect } from "next/dist/server/api-utils";
|
||||
import ResourceAccessDenied from "./ResourceAccessDenied";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
@@ -118,7 +112,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
|
||||
const [otpState, setOtpState] = useState<"idle" | "otp_sent">("idle");
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
function getDefaultSelectedMethod() {
|
||||
if (props.methods.sso) {
|
||||
@@ -169,6 +165,15 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
}
|
||||
});
|
||||
|
||||
function appendRequestToken(url: string, token: string) {
|
||||
const fullUrl = new URL(url);
|
||||
fullUrl.searchParams.append(
|
||||
env.server.resourceSessionRequestParam,
|
||||
token
|
||||
);
|
||||
return fullUrl.toString();
|
||||
}
|
||||
|
||||
const onWhitelistSubmit = (values: any) => {
|
||||
setLoadingLogin(true);
|
||||
api.post<AxiosResponse<AuthWithWhitelistResponse>>(
|
||||
@@ -190,7 +195,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
|
||||
const session = res.data.data.session;
|
||||
if (session) {
|
||||
window.location.href = props.redirect;
|
||||
window.location.href = appendRequestToken(props.redirect, session);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -212,7 +217,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
setPincodeError(null);
|
||||
const session = res.data.data.session;
|
||||
if (session) {
|
||||
window.location.href = props.redirect;
|
||||
window.location.href = appendRequestToken(props.redirect, session);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -237,7 +242,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
setPasswordError(null);
|
||||
const session = res.data.data.session;
|
||||
if (session) {
|
||||
window.location.href = props.redirect;
|
||||
window.location.href = appendRequestToken(props.redirect, session);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -619,16 +624,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* {activeTab === "sso" && (
|
||||
<div className="flex justify-center mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
<a href="#" className="underline">
|
||||
Sign up
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
) : (
|
||||
<ResourceAccessDenied />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
AuthWithAccessTokenResponse,
|
||||
GetResourceAuthInfoResponse,
|
||||
GetResourceResponse
|
||||
GetExchangeTokenResponse
|
||||
} from "@server/routers/resource";
|
||||
import ResourceAuthPortal from "./ResourceAuthPortal";
|
||||
import { internal, priv } from "@app/lib/api";
|
||||
@@ -12,9 +11,6 @@ import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { redirect } from "next/navigation";
|
||||
import ResourceNotFound from "./ResourceNotFound";
|
||||
import ResourceAccessDenied from "./ResourceAccessDenied";
|
||||
import { cookies } from "next/headers";
|
||||
import { CheckResourceSessionResponse } from "@server/routers/auth";
|
||||
import AccessTokenInvalid from "./AccessToken";
|
||||
import AccessToken from "./AccessToken";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
|
||||
@@ -48,7 +44,7 @@ export default async function ResourceAuthPage(props: {
|
||||
// TODO: fix this
|
||||
return (
|
||||
<div className="w-full max-w-md">
|
||||
{/* @ts-ignore */}
|
||||
{/* @ts-ignore */}
|
||||
<ResourceNotFound />
|
||||
</div>
|
||||
);
|
||||
@@ -83,49 +79,41 @@ export default async function ResourceAuthPage(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const allCookies = await cookies();
|
||||
const cookieName =
|
||||
env.server.resourceSessionCookieName + `_${params.resourceId}`;
|
||||
const sessionId = allCookies.get(cookieName)?.value ?? null;
|
||||
|
||||
if (sessionId) {
|
||||
let doRedirect = false;
|
||||
try {
|
||||
const res = await priv.get<
|
||||
AxiosResponse<CheckResourceSessionResponse>
|
||||
>(`/resource-session/${params.resourceId}/${sessionId}`);
|
||||
|
||||
if (res && res.data.data.valid) {
|
||||
doRedirect = true;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (doRedirect) {
|
||||
redirect(redirectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAuth) {
|
||||
// no authentication so always go straight to the resource
|
||||
redirect(redirectUrl);
|
||||
}
|
||||
|
||||
|
||||
// convert the dashboard token into a resource session token
|
||||
let userIsUnauthorized = false;
|
||||
if (user && authInfo.sso) {
|
||||
let doRedirect = false;
|
||||
let redirectToUrl: string | undefined;
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
|
||||
`/resource/${params.resourceId}`,
|
||||
const res = await priv.post<
|
||||
AxiosResponse<GetExchangeTokenResponse>
|
||||
>(
|
||||
`/resource/${params.resourceId}/get-exchange-token`,
|
||||
{},
|
||||
await authCookieHeader()
|
||||
);
|
||||
|
||||
doRedirect = true;
|
||||
if (res.data.data.requestToken) {
|
||||
const paramName = env.server.resourceSessionRequestParam;
|
||||
// append the param with the token to the redirect url
|
||||
const fullUrl = new URL(redirectUrl);
|
||||
fullUrl.searchParams.append(
|
||||
paramName,
|
||||
res.data.data.requestToken
|
||||
);
|
||||
redirectToUrl = fullUrl.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
userIsUnauthorized = true;
|
||||
}
|
||||
|
||||
if (doRedirect) {
|
||||
redirect(redirectUrl);
|
||||
if (redirectToUrl) {
|
||||
redirect(redirectToUrl);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
38
src/components/ui/info-popup.tsx
Normal file
38
src/components/ui/info-popup.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Info } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface InfoPopupProps {
|
||||
text: string;
|
||||
info: string;
|
||||
}
|
||||
|
||||
export function InfoPopup({ text, info }: InfoPopupProps) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{text}</span>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-full p-0"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
<span className="sr-only">Show info</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<p className="text-sm text-muted-foreground">{info}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,8 @@ export function pullEnv(): Env {
|
||||
nextPort: process.env.NEXT_PORT as string,
|
||||
externalPort: process.env.SERVER_EXTERNAL_PORT as string,
|
||||
sessionCookieName: process.env.SESSION_COOKIE_NAME as string,
|
||||
resourceSessionCookieName: process.env.RESOURCE_SESSION_COOKIE_NAME as string,
|
||||
resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string
|
||||
resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string,
|
||||
resourceSessionRequestParam: process.env.RESOURCE_SESSION_REQUEST_PARAM as string
|
||||
},
|
||||
app: {
|
||||
environment: process.env.ENVIRONMENT as string,
|
||||
@@ -26,7 +26,9 @@ export function pullEnv(): Env {
|
||||
emailVerificationRequired:
|
||||
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
|
||||
? true
|
||||
: false
|
||||
: false,
|
||||
allowRawResources:
|
||||
process.env.FLAGS_ALLOW_RAW_RESOURCES === "true" ? true : false,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ export type Env = {
|
||||
externalPort: string;
|
||||
nextPort: string;
|
||||
sessionCookieName: string;
|
||||
resourceSessionCookieName: string;
|
||||
resourceAccessTokenParam: string;
|
||||
resourceSessionRequestParam: string;
|
||||
},
|
||||
email: {
|
||||
emailEnabled: boolean;
|
||||
@@ -17,5 +17,6 @@ export type Env = {
|
||||
disableSignupWithoutInvite: boolean;
|
||||
disableUserCreateOrg: boolean;
|
||||
emailVerificationRequired: boolean;
|
||||
allowRawResources: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user