mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-15 01:16:38 +00:00
Pull up downstream changes
This commit is contained in:
430
src/app/[orgId]/settings/domains/CreateDomainForm.tsx
Normal file
430
src/app/[orgId]/settings/domains/CreateDomainForm.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { CreateDomainResponse } from "@server/routers/domain/createOrgDomain";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon, AlertTriangle } from "lucide-react";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
|
||||
const formSchema = z.object({
|
||||
baseDomain: z.string().min(1, "Domain is required"),
|
||||
type: z.enum(["ns", "cname"])
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
type CreateDomainFormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onCreated?: (domain: CreateDomainResponse) => void;
|
||||
};
|
||||
|
||||
export default function CreateDomainForm({
|
||||
open,
|
||||
setOpen,
|
||||
onCreated
|
||||
}: CreateDomainFormProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createdDomain, setCreatedDomain] =
|
||||
useState<CreateDomainResponse | null>(null);
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
const { toast } = useToast();
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
baseDomain: "",
|
||||
type: "ns"
|
||||
}
|
||||
});
|
||||
|
||||
function reset() {
|
||||
form.reset();
|
||||
setLoading(false);
|
||||
setCreatedDomain(null);
|
||||
}
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.put<AxiosResponse<CreateDomainResponse>>(
|
||||
`/org/${org.org.orgId}/domain`,
|
||||
values
|
||||
);
|
||||
const domainData = response.data.data;
|
||||
setCreatedDomain(domainData);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("domainCreatedDescription")
|
||||
});
|
||||
onCreated?.(domainData);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const domainType = form.watch("type");
|
||||
const baseDomain = form.watch("baseDomain");
|
||||
|
||||
return (
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("domainAdd")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("domainAddDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
{!createdDomain ? (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="create-domain-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<StrategySelect
|
||||
options={[
|
||||
{
|
||||
id: "ns",
|
||||
title: t(
|
||||
"selectDomainTypeNsName"
|
||||
),
|
||||
description: t(
|
||||
"selectDomainTypeNsDescription"
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "cname",
|
||||
title: t(
|
||||
"selectDomainTypeCnameName"
|
||||
),
|
||||
description: t(
|
||||
"selectDomainTypeCnameDescription"
|
||||
)
|
||||
}
|
||||
]}
|
||||
defaultValue={field.value}
|
||||
onChange={field.onChange}
|
||||
cols={1}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="baseDomain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("domain")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Alert variant="default">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Add DNS Records
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Add the following DNS records to your domain
|
||||
provider to complete the setup.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4">
|
||||
{domainType === "ns" &&
|
||||
createdDomain.nsRecords && (
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">
|
||||
NS Records
|
||||
</h3>
|
||||
<InfoSections cols={1}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Record
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
Type:
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
NS
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
Name:
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
{baseDomain}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
Value:
|
||||
</span>
|
||||
{createdDomain.nsRecords.map(
|
||||
(
|
||||
nsRecord,
|
||||
index
|
||||
) => (
|
||||
<div
|
||||
className="flex justify-between items-center"
|
||||
key={
|
||||
index
|
||||
}
|
||||
>
|
||||
<CopyToClipboard
|
||||
text={
|
||||
nsRecord
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{domainType === "cname" && (
|
||||
<>
|
||||
{createdDomain.cnameRecords &&
|
||||
createdDomain.cnameRecords.length >
|
||||
0 && (
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">
|
||||
CNAME Records
|
||||
</h3>
|
||||
<InfoSections cols={1}>
|
||||
{createdDomain.cnameRecords.map(
|
||||
(
|
||||
cnameRecord,
|
||||
index
|
||||
) => (
|
||||
<InfoSection
|
||||
key={index}
|
||||
>
|
||||
<InfoSectionTitle>
|
||||
Record{" "}
|
||||
{index +
|
||||
1}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
Type:
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
CNAME
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
Name:
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
{
|
||||
cnameRecord.baseDomain
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
Value:
|
||||
</span>
|
||||
<CopyToClipboard
|
||||
text={
|
||||
cnameRecord.value
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)
|
||||
)}
|
||||
</InfoSections>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdDomain.txtRecords &&
|
||||
createdDomain.txtRecords.length >
|
||||
0 && (
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">
|
||||
TXT Records
|
||||
</h3>
|
||||
<InfoSections cols={1}>
|
||||
{createdDomain.txtRecords.map(
|
||||
(
|
||||
txtRecord,
|
||||
index
|
||||
) => (
|
||||
<InfoSection
|
||||
key={index}
|
||||
>
|
||||
<InfoSectionTitle>
|
||||
Record{" "}
|
||||
{index +
|
||||
1}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
Type:
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
TXT
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
Name:
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
{
|
||||
txtRecord.baseDomain
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
Value:
|
||||
</span>
|
||||
<CopyToClipboard
|
||||
text={
|
||||
txtRecord.value
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)
|
||||
)}
|
||||
</InfoSections>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Save These Records
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Make sure to save these DNS records as you
|
||||
will not see them again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert variant="info">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
DNS Propagation
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
DNS changes may take some time to propagate
|
||||
across the internet. This can take anywhere
|
||||
from a few minutes to 48 hours, depending on
|
||||
your DNS provider and TTL settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
{!createdDomain && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-domain-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("domainCreate")}
|
||||
</Button>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
37
src/app/[orgId]/settings/domains/DomainsDataTable.tsx
Normal file
37
src/app/[orgId]/settings/domains/DomainsDataTable.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
onAdd?: () => void;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
}
|
||||
|
||||
export function DomainsDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
onAdd,
|
||||
onRefresh,
|
||||
isRefreshing
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title={t("domains")}
|
||||
searchPlaceholder={t("domainsSearch")}
|
||||
searchColumn="baseDomain"
|
||||
addButtonText={t("domainAdd")}
|
||||
onAdd={onAdd}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
261
src/app/[orgId]/settings/domains/DomainsTable.tsx
Normal file
261
src/app/[orgId]/settings/domains/DomainsTable.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DomainsDataTable } from "./DomainsDataTable";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowUpDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import CreateDomainForm from "./CreateDomainForm";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
|
||||
export type DomainRow = {
|
||||
domainId: string;
|
||||
baseDomain: string;
|
||||
type: string;
|
||||
verified: boolean;
|
||||
failed: boolean;
|
||||
tries: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
domains: DomainRow[];
|
||||
};
|
||||
|
||||
export default function DomainsTable({ domains }: Props) {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [selectedDomain, setSelectedDomain] = useState<DomainRow | null>(
|
||||
null
|
||||
);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [restartingDomains, setRestartingDomains] = useState<Set<string>>(new Set());
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { toast } = useToast();
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const refreshData = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteDomain = async (domainId: string) => {
|
||||
try {
|
||||
await api.delete(`/org/${org.org.orgId}/domain/${domainId}`);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("domainDeletedDescription")
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
refreshData();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const restartDomain = async (domainId: string) => {
|
||||
setRestartingDomains(prev => new Set(prev).add(domainId));
|
||||
try {
|
||||
await api.post(`/org/${org.org.orgId}/domain/${domainId}/restart`);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("domainRestartedDescription", { fallback: "Domain verification restarted successfully" })
|
||||
});
|
||||
refreshData();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setRestartingDomains(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(domainId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeDisplay = (type: string) => {
|
||||
switch (type) {
|
||||
case "ns":
|
||||
return t("selectDomainTypeNsName");
|
||||
case "cname":
|
||||
return t("selectDomainTypeCnameName");
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<DomainRow>[] = [
|
||||
{
|
||||
accessorKey: "baseDomain",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("domain")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("type")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const type = row.original.type;
|
||||
return (
|
||||
<Badge variant="secondary">{getTypeDisplay(type)}</Badge>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "verified",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("status")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const { verified, failed } = row.original;
|
||||
if (verified) {
|
||||
return <Badge variant="green">{t("verified")}</Badge>;
|
||||
} else if (failed) {
|
||||
return <Badge variant="destructive">{t("failed", { fallback: "Failed" })}</Badge>;
|
||||
} else {
|
||||
return <Badge variant="yellow">{t("pending")}</Badge>;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const domain = row.original;
|
||||
const isRestarting = restartingDomains.has(domain.domainId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{domain.failed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => restartDomain(domain.domainId)}
|
||||
disabled={isRestarting}
|
||||
>
|
||||
{isRestarting ? t("restarting", { fallback: "Restarting..." }) : t("restart", { fallback: "Restart" })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedDomain(domain);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedDomain && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedDomain(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
{t("domainQuestionRemove", {
|
||||
domain: selectedDomain.baseDomain
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
<b>{t("domainMessageRemove")}</b>
|
||||
</p>
|
||||
<p>{t("domainMessageConfirm")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("domainConfirmDelete")}
|
||||
onConfirm={async () =>
|
||||
deleteDomain(selectedDomain.domainId)
|
||||
}
|
||||
string={selectedDomain.baseDomain}
|
||||
title={t("domainDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateDomainForm
|
||||
open={isCreateModalOpen}
|
||||
setOpen={setIsCreateModalOpen}
|
||||
onCreated={(domain) => {
|
||||
refreshData();
|
||||
}}
|
||||
/>
|
||||
|
||||
<DomainsDataTable
|
||||
columns={columns}
|
||||
data={domains}
|
||||
onAdd={() => setIsCreateModalOpen(true)}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
60
src/app/[orgId]/settings/domains/page.tsx
Normal file
60
src/app/[orgId]/settings/domains/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import DomainsTable, { DomainRow } from "./DomainsTable";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { cache } from "react";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import { redirect } from "next/navigation";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { ListDomainsResponse } from "@server/routers/domain";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export default async function DomainsPage(props: Props) {
|
||||
const params = await props.params;
|
||||
|
||||
let domains: DomainRow[] = [];
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<ListDomainsResponse>
|
||||
>(`/org/${params.orgId}/domains`, await authCookieHeader());
|
||||
domains = res.data.data.domains as DomainRow[];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
let org = null;
|
||||
try {
|
||||
const getOrg = cache(async () =>
|
||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
||||
`/org/${params.orgId}`,
|
||||
await authCookieHeader()
|
||||
)
|
||||
);
|
||||
const res = await getOrg();
|
||||
org = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}`);
|
||||
}
|
||||
|
||||
if (!org) {
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<OrgProvider org={org}>
|
||||
<SettingsSectionTitle
|
||||
title={t("domains")}
|
||||
description={t("domainsDescription")}
|
||||
/>
|
||||
<DomainsTable domains={domains} />
|
||||
</OrgProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -202,25 +202,25 @@ export default function GeneralPage() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subnet"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Subnet</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
The subnet for this organization's network configuration.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* <FormField */}
|
||||
{/* control={form.control} */}
|
||||
{/* name="subnet" */}
|
||||
{/* render={({ field }) => ( */}
|
||||
{/* <FormItem> */}
|
||||
{/* <FormLabel>Subnet</FormLabel> */}
|
||||
{/* <FormControl> */}
|
||||
{/* <Input */}
|
||||
{/* {...field} */}
|
||||
{/* disabled={true} */}
|
||||
{/* /> */}
|
||||
{/* </FormControl> */}
|
||||
{/* <FormMessage /> */}
|
||||
{/* <FormDescription> */}
|
||||
{/* The subnet for this organization's network configuration. */}
|
||||
{/* </FormDescription> */}
|
||||
{/* </FormItem> */}
|
||||
{/* )} */}
|
||||
{/* /> */}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Metadata } from "next";
|
||||
import {
|
||||
Combine,
|
||||
KeyRound,
|
||||
LinkIcon,
|
||||
Settings,
|
||||
Users,
|
||||
@@ -11,6 +12,7 @@ import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { redirect } from "next/navigation";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ListOrgsResponse } from "@server/routers/org";
|
||||
import { GetOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { cache } from "react";
|
||||
|
||||
@@ -32,6 +32,8 @@ import { Switch } from "@app/components/ui/switch";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
|
||||
export type ResourceRow = {
|
||||
id: number;
|
||||
@@ -45,6 +47,7 @@ export type ResourceRow = {
|
||||
protocol: string;
|
||||
proxyPort: number | null;
|
||||
enabled: boolean;
|
||||
domainId?: string;
|
||||
};
|
||||
|
||||
type ResourcesTableProps = {
|
||||
@@ -158,17 +161,26 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div>
|
||||
{!resourceRow.http ? (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.proxyPort!.toString()}
|
||||
isLink={false}
|
||||
<div className="flex items-center space-x-2">
|
||||
{!resourceRow.domainId ? (
|
||||
<InfoPopup
|
||||
info={t("domainNotFoundDescription")}
|
||||
text={t("domainNotFound")}
|
||||
/>
|
||||
) : (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.domain}
|
||||
isLink={true}
|
||||
/>
|
||||
<div>
|
||||
{!resourceRow.http ? (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.proxyPort!.toString()}
|
||||
isLink={false}
|
||||
/>
|
||||
) : (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.domain}
|
||||
isLink={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -215,7 +227,10 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
header: t("enabled"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.original.enabled}
|
||||
defaultChecked={
|
||||
!row.original.domainId ? false : row.original.enabled
|
||||
}
|
||||
disabled={!row.original.domainId}
|
||||
onCheckedChange={(val) =>
|
||||
toggleResourceEnabled(val, row.original.id)
|
||||
}
|
||||
@@ -261,7 +276,11 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
<Link
|
||||
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
|
||||
>
|
||||
<Button variant={"secondary"} className="ml-2" size="sm">
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
className="ml-2"
|
||||
size="sm"
|
||||
>
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
@@ -10,10 +10,14 @@ import {
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useDockerSocket } from "@app/hooks/useDockerSocket";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RotateCw } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
|
||||
type ResourceInfoBoxType = {};
|
||||
|
||||
|
||||
@@ -205,10 +205,10 @@ export default function ResourceAuthenticationPage() {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('resourceErrorAuthFetch'),
|
||||
title: t("resourceErrorAuthFetch"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('resourceErrorAuthFetchDescription')
|
||||
t("resourceErrorAuthFetchDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -235,18 +235,18 @@ export default function ResourceAuthenticationPage() {
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t('resourceWhitelistSave'),
|
||||
description: t('resourceWhitelistSaveDescription')
|
||||
title: t("resourceWhitelistSave"),
|
||||
description: t("resourceWhitelistSaveDescription")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('resourceErrorWhitelistSave'),
|
||||
title: t("resourceErrorWhitelistSave"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('resourceErrorWhitelistSaveDescription')
|
||||
t("resourceErrorWhitelistSaveDescription")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
@@ -283,18 +283,18 @@ export default function ResourceAuthenticationPage() {
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t('resourceAuthSettingsSave'),
|
||||
description: t('resourceAuthSettingsSaveDescription')
|
||||
title: t("resourceAuthSettingsSave"),
|
||||
description: t("resourceAuthSettingsSaveDescription")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('resourceErrorUsersRolesSave'),
|
||||
title: t("resourceErrorUsersRolesSave"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('resourceErrorUsersRolesSaveDescription')
|
||||
t("resourceErrorUsersRolesSaveDescription")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
@@ -310,8 +310,8 @@ export default function ResourceAuthenticationPage() {
|
||||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: t('resourcePasswordRemove'),
|
||||
description: t('resourcePasswordRemoveDescription')
|
||||
title: t("resourcePasswordRemove"),
|
||||
description: t("resourcePasswordRemoveDescription")
|
||||
});
|
||||
|
||||
updateAuthInfo({
|
||||
@@ -322,10 +322,10 @@ export default function ResourceAuthenticationPage() {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('resourceErrorPasswordRemove'),
|
||||
title: t("resourceErrorPasswordRemove"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('resourceErrorPasswordRemoveDescription')
|
||||
t("resourceErrorPasswordRemoveDescription")
|
||||
)
|
||||
});
|
||||
})
|
||||
@@ -340,8 +340,8 @@ export default function ResourceAuthenticationPage() {
|
||||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: t('resourcePincodeRemove'),
|
||||
description: t('resourcePincodeRemoveDescription')
|
||||
title: t("resourcePincodeRemove"),
|
||||
description: t("resourcePincodeRemoveDescription")
|
||||
});
|
||||
|
||||
updateAuthInfo({
|
||||
@@ -352,10 +352,10 @@ export default function ResourceAuthenticationPage() {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('resourceErrorPincodeRemove'),
|
||||
title: t("resourceErrorPincodeRemove"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('resourceErrorPincodeRemoveDescription')
|
||||
t("resourceErrorPincodeRemoveDescription")
|
||||
)
|
||||
});
|
||||
})
|
||||
@@ -400,140 +400,151 @@ export default function ResourceAuthenticationPage() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t('resourceUsersRoles')}
|
||||
{t("resourceUsersRoles")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t('resourceUsersRolesDescription')}
|
||||
{t("resourceUsersRolesDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SwitchInput
|
||||
id="sso-toggle"
|
||||
label={t('ssoUse')}
|
||||
description={t('ssoUseDescription')}
|
||||
defaultChecked={resource.sso}
|
||||
onCheckedChange={(val) => setSsoEnabled(val)}
|
||||
/>
|
||||
<SettingsSectionForm>
|
||||
<SwitchInput
|
||||
id="sso-toggle"
|
||||
label={t("ssoUse")}
|
||||
defaultChecked={resource.sso}
|
||||
onCheckedChange={(val) => setSsoEnabled(val)}
|
||||
/>
|
||||
|
||||
<Form {...usersRolesForm}>
|
||||
<form
|
||||
onSubmit={usersRolesForm.handleSubmit(
|
||||
onSubmitUsersRoles
|
||||
)}
|
||||
id="users-roles-form"
|
||||
className="space-y-4"
|
||||
>
|
||||
{ssoEnabled && (
|
||||
<>
|
||||
<FormField
|
||||
control={usersRolesForm.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t('roles')}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeRolesTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveRolesTagIndex
|
||||
}
|
||||
placeholder={t('accessRoleSelect2')}
|
||||
size="sm"
|
||||
tags={
|
||||
usersRolesForm.getValues()
|
||||
.roles
|
||||
}
|
||||
setTags={(
|
||||
newRoles
|
||||
) => {
|
||||
usersRolesForm.setValue(
|
||||
"roles",
|
||||
newRoles as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={
|
||||
true
|
||||
}
|
||||
autocompleteOptions={
|
||||
allRoles
|
||||
}
|
||||
allowDuplicates={
|
||||
false
|
||||
}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t('resourceRoleDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={usersRolesForm.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t('users')}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeUsersTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveUsersTagIndex
|
||||
}
|
||||
placeholder={t('accessUserSelect')}
|
||||
tags={
|
||||
usersRolesForm.getValues()
|
||||
.users
|
||||
}
|
||||
size="sm"
|
||||
setTags={(
|
||||
newUsers
|
||||
) => {
|
||||
usersRolesForm.setValue(
|
||||
"users",
|
||||
newUsers as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={
|
||||
true
|
||||
}
|
||||
autocompleteOptions={
|
||||
allUsers
|
||||
}
|
||||
allowDuplicates={
|
||||
false
|
||||
}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
<Form {...usersRolesForm}>
|
||||
<form
|
||||
onSubmit={usersRolesForm.handleSubmit(
|
||||
onSubmitUsersRoles
|
||||
)}
|
||||
id="users-roles-form"
|
||||
className="space-y-4"
|
||||
>
|
||||
{ssoEnabled && (
|
||||
<>
|
||||
<FormField
|
||||
control={usersRolesForm.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>
|
||||
{t("roles")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeRolesTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveRolesTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"accessRoleSelect2"
|
||||
)}
|
||||
size="sm"
|
||||
tags={
|
||||
usersRolesForm.getValues()
|
||||
.roles
|
||||
}
|
||||
setTags={(
|
||||
newRoles
|
||||
) => {
|
||||
usersRolesForm.setValue(
|
||||
"roles",
|
||||
newRoles as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={
|
||||
true
|
||||
}
|
||||
autocompleteOptions={
|
||||
allRoles
|
||||
}
|
||||
allowDuplicates={
|
||||
false
|
||||
}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"resourceRoleDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={usersRolesForm.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>
|
||||
{t("users")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeUsersTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveUsersTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"accessUserSelect"
|
||||
)}
|
||||
tags={
|
||||
usersRolesForm.getValues()
|
||||
.users
|
||||
}
|
||||
size="sm"
|
||||
setTags={(
|
||||
newUsers
|
||||
) => {
|
||||
usersRolesForm.setValue(
|
||||
"users",
|
||||
newUsers as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={
|
||||
true
|
||||
}
|
||||
autocompleteOptions={
|
||||
allUsers
|
||||
}
|
||||
allowDuplicates={
|
||||
false
|
||||
}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
@@ -542,7 +553,7 @@ export default function ResourceAuthenticationPage() {
|
||||
disabled={loadingSaveUsersRoles}
|
||||
form="users-roles-form"
|
||||
>
|
||||
{t('resourceUsersRolesSubmit')}
|
||||
{t("resourceUsersRolesSubmit")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
@@ -550,170 +561,195 @@ export default function ResourceAuthenticationPage() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t('resourceAuthMethods')}
|
||||
{t("resourceAuthMethods")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t('resourceAuthMethodsDescriptions')}
|
||||
{t("resourceAuthMethodsDescriptions")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{/* Password Protection */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className={`flex items-center text-${!authInfo.password ? "neutral" : "green"}-500 space-x-2`}
|
||||
>
|
||||
<Key />
|
||||
<span>
|
||||
{t('resourcePasswordProtection', {status: authInfo.password? t('enabled') : t('disabled')})}
|
||||
</span>
|
||||
<SettingsSectionForm>
|
||||
{/* Password Protection */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
|
||||
<div
|
||||
className={`flex items-center ${!authInfo.password ? "text-muted-foreground" : "text-green-500"} text-sm space-x-2`}
|
||||
>
|
||||
<Key size="14" />
|
||||
<span>
|
||||
{t("resourcePasswordProtection", {
|
||||
status: authInfo.password
|
||||
? t("enabled")
|
||||
: t("disabled")
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={
|
||||
authInfo.password
|
||||
? removeResourcePassword
|
||||
: () => setIsSetPasswordOpen(true)
|
||||
}
|
||||
loading={loadingRemoveResourcePassword}
|
||||
>
|
||||
{authInfo.password
|
||||
? t("passwordRemove")
|
||||
: t("passwordAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={
|
||||
authInfo.password
|
||||
? removeResourcePassword
|
||||
: () => setIsSetPasswordOpen(true)
|
||||
}
|
||||
loading={loadingRemoveResourcePassword}
|
||||
>
|
||||
{authInfo.password
|
||||
? t('passwordRemove')
|
||||
: t('passwordAdd')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* PIN Code Protection */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className={`flex items-center text-${!authInfo.pincode ? "neutral" : "green"}-500 space-x-2`}
|
||||
>
|
||||
<Binary />
|
||||
<span>
|
||||
{t('resourcePincodeProtection', {status: authInfo.pincode ? t('enabled') : t('disabled')})}
|
||||
</span>
|
||||
{/* PIN Code Protection */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2">
|
||||
<div
|
||||
className={`flex items-center ${!authInfo.pincode ? "text-muted-foreground" : "text-green-500"} space-x-2 text-sm`}
|
||||
>
|
||||
<Binary size="14" />
|
||||
<span>
|
||||
{t("resourcePincodeProtection", {
|
||||
status: authInfo.pincode
|
||||
? t("enabled")
|
||||
: t("disabled")
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={
|
||||
authInfo.pincode
|
||||
? removeResourcePincode
|
||||
: () => setIsSetPincodeOpen(true)
|
||||
}
|
||||
loading={loadingRemoveResourcePincode}
|
||||
>
|
||||
{authInfo.pincode
|
||||
? t("pincodeRemove")
|
||||
: t("pincodeAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={
|
||||
authInfo.pincode
|
||||
? removeResourcePincode
|
||||
: () => setIsSetPincodeOpen(true)
|
||||
}
|
||||
loading={loadingRemoveResourcePincode}
|
||||
>
|
||||
{authInfo.pincode
|
||||
? t('pincodeRemove')
|
||||
: t('pincodeAdd')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t('otpEmailTitle')}
|
||||
{t("otpEmailTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t('otpEmailTitleDescription')}
|
||||
{t("otpEmailTitleDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{!env.email.emailEnabled && (
|
||||
<Alert variant="neutral" className="mb-4">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t('otpEmailSmtpRequired')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('otpEmailSmtpRequiredDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<SwitchInput
|
||||
id="whitelist-toggle"
|
||||
label={t('otpEmailWhitelist')}
|
||||
defaultChecked={resource.emailWhitelistEnabled}
|
||||
onCheckedChange={setWhitelistEnabled}
|
||||
disabled={!env.email.emailEnabled}
|
||||
/>
|
||||
<SettingsSectionForm>
|
||||
{!env.email.emailEnabled && (
|
||||
<Alert variant="neutral" className="mb-4">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("otpEmailSmtpRequired")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("otpEmailSmtpRequiredDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<SwitchInput
|
||||
id="whitelist-toggle"
|
||||
label={t("otpEmailWhitelist")}
|
||||
defaultChecked={resource.emailWhitelistEnabled}
|
||||
onCheckedChange={setWhitelistEnabled}
|
||||
disabled={!env.email.emailEnabled}
|
||||
/>
|
||||
|
||||
{whitelistEnabled && env.email.emailEnabled && (
|
||||
<Form {...whitelistForm}>
|
||||
<form id="whitelist-form">
|
||||
<FormField
|
||||
control={whitelistForm.control}
|
||||
name="emails"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<InfoPopup
|
||||
text={t('otpEmailWhitelistList')}
|
||||
info={t('otpEmailWhitelistListDescription')}
|
||||
/>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{/* @ts-ignore */}
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeEmailTagIndex
|
||||
}
|
||||
size={"sm"}
|
||||
validateTag={(
|
||||
tag
|
||||
) => {
|
||||
return z
|
||||
.string()
|
||||
.email()
|
||||
.or(
|
||||
z
|
||||
.string()
|
||||
.regex(
|
||||
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
|
||||
{
|
||||
message: t('otpEmailErrorInvalid')
|
||||
}
|
||||
)
|
||||
)
|
||||
.safeParse(
|
||||
tag
|
||||
).success;
|
||||
}}
|
||||
setActiveTagIndex={
|
||||
setActiveEmailTagIndex
|
||||
}
|
||||
placeholder={t('otpEmailEnter')}
|
||||
tags={
|
||||
whitelistForm.getValues()
|
||||
.emails
|
||||
}
|
||||
setTags={(
|
||||
newRoles
|
||||
) => {
|
||||
whitelistForm.setValue(
|
||||
"emails",
|
||||
newRoles as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
allowDuplicates={
|
||||
false
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('otpEmailEnterDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
{whitelistEnabled && env.email.emailEnabled && (
|
||||
<Form {...whitelistForm}>
|
||||
<form id="whitelist-form">
|
||||
<FormField
|
||||
control={whitelistForm.control}
|
||||
name="emails"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<InfoPopup
|
||||
text={t(
|
||||
"otpEmailWhitelistList"
|
||||
)}
|
||||
info={t(
|
||||
"otpEmailWhitelistListDescription"
|
||||
)}
|
||||
/>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{/* @ts-ignore */}
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeEmailTagIndex
|
||||
}
|
||||
size={"sm"}
|
||||
validateTag={(
|
||||
tag
|
||||
) => {
|
||||
return z
|
||||
.string()
|
||||
.email()
|
||||
.or(
|
||||
z
|
||||
.string()
|
||||
.regex(
|
||||
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
|
||||
{
|
||||
message:
|
||||
t(
|
||||
"otpEmailErrorInvalid"
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
.safeParse(
|
||||
tag
|
||||
).success;
|
||||
}}
|
||||
setActiveTagIndex={
|
||||
setActiveEmailTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"otpEmailEnter"
|
||||
)}
|
||||
tags={
|
||||
whitelistForm.getValues()
|
||||
.emails
|
||||
}
|
||||
setTags={(
|
||||
newRoles
|
||||
) => {
|
||||
whitelistForm.setValue(
|
||||
"emails",
|
||||
newRoles as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
allowDuplicates={
|
||||
false
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"otpEmailEnterDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
@@ -722,7 +758,7 @@ export default function ResourceAuthenticationPage() {
|
||||
loading={loadingSaveWhitelist}
|
||||
disabled={loadingSaveWhitelist}
|
||||
>
|
||||
{t('otpEmailWhitelistSave')}
|
||||
{t("otpEmailWhitelistSave")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -66,6 +66,18 @@ import {
|
||||
} from "@server/routers/resource";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { Globe } from "lucide-react";
|
||||
|
||||
const TransferFormSchema = z.object({
|
||||
siteId: z.number()
|
||||
@@ -80,6 +92,7 @@ export default function GeneralForm() {
|
||||
const { org } = useOrgContext();
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const [editDomainOpen, setEditDomainOpen] = useState(false);
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
@@ -99,46 +112,22 @@ export default function GeneralForm() {
|
||||
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
|
||||
resource.isBaseDomain ? "basedomain" : "subdomain"
|
||||
);
|
||||
const [resourceFullDomain, setResourceFullDomain] = useState(
|
||||
`${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
|
||||
);
|
||||
const [selectedDomain, setSelectedDomain] = useState<{
|
||||
domainId: string;
|
||||
subdomain?: string;
|
||||
fullDomain: string;
|
||||
} | null>(null);
|
||||
|
||||
const GeneralFormSchema = z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
subdomain: z.string().optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
proxyPort: z.number().optional(),
|
||||
http: z.boolean(),
|
||||
isBaseDomain: z.boolean().optional(),
|
||||
domainId: z.string().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.http) {
|
||||
return z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(65535)
|
||||
.safeParse(data.proxyPort).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t("proxyErrorInvalidPort"),
|
||||
path: ["proxyPort"]
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.http && !data.isBaseDomain) {
|
||||
return subdomainSchema.safeParse(data.subdomain).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t("subdomainErrorInvalid"),
|
||||
path: ["subdomain"]
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
|
||||
@@ -148,9 +137,6 @@ export default function GeneralForm() {
|
||||
enabled: resource.enabled,
|
||||
name: resource.name,
|
||||
subdomain: resource.subdomain ? resource.subdomain : undefined,
|
||||
proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
|
||||
http: resource.http,
|
||||
isBaseDomain: resource.isBaseDomain ? true : false,
|
||||
domainId: resource.domainId || undefined
|
||||
},
|
||||
mode: "onChange"
|
||||
@@ -213,10 +199,8 @@ export default function GeneralForm() {
|
||||
{
|
||||
enabled: data.enabled,
|
||||
name: data.name,
|
||||
subdomain: data.http ? data.subdomain : undefined,
|
||||
proxyPort: data.proxyPort,
|
||||
isBaseDomain: data.http ? data.isBaseDomain : undefined,
|
||||
domainId: data.http ? data.domainId : undefined
|
||||
subdomain: data.subdomain,
|
||||
domainId: data.domainId,
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
@@ -242,8 +226,6 @@ export default function GeneralForm() {
|
||||
enabled: data.enabled,
|
||||
name: data.name,
|
||||
subdomain: data.subdomain,
|
||||
proxyPort: data.proxyPort,
|
||||
isBaseDomain: data.isBaseDomain,
|
||||
fullDomain: resource.fullDomain
|
||||
});
|
||||
|
||||
@@ -288,449 +270,290 @@ export default function GeneralForm() {
|
||||
|
||||
return (
|
||||
!loadingPage && (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceGeneral")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourceGeneralDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<>
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceGeneral")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourceGeneralDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<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="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="enable-resource"
|
||||
defaultChecked={resource.enabled}
|
||||
onCheckedChange={(val) => form.setValue("enabled", val)}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1">
|
||||
<FormLabel className="text-base">
|
||||
{t("resourceEnable")}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t("resourceVisibilityTitleDescription")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{resource.http && (
|
||||
<>
|
||||
{env.flags
|
||||
.allowBaseDomainResources && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isBaseDomain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"domainType"
|
||||
)}
|
||||
</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">
|
||||
{t(
|
||||
"subdomain"
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="basedomain">
|
||||
{t(
|
||||
"baseDomain"
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="col-span-2">
|
||||
{domainType === "subdomain" ? (
|
||||
<div className="w-fill space-y-2">
|
||||
<FormLabel>
|
||||
{t("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
|
||||
}
|
||||
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>
|
||||
{t(
|
||||
"baseDomain"
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!resource.http && (
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form} key={formKey}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="general-settings-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="proxyPort"
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="enable-resource"
|
||||
defaultChecked={
|
||||
resource.enabled
|
||||
}
|
||||
label={
|
||||
t(
|
||||
"resourceEnable"
|
||||
)
|
||||
}
|
||||
onCheckedChange={(
|
||||
val
|
||||
) =>
|
||||
form.setValue(
|
||||
"enabled",
|
||||
val
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"resourcePortNumber"
|
||||
)}
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
value={
|
||||
field.value ??
|
||||
""
|
||||
}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target
|
||||
.value
|
||||
? parseInt(
|
||||
e
|
||||
.target
|
||||
.value
|
||||
)
|
||||
: null
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={saveLoading}
|
||||
disabled={saveLoading}
|
||||
form="general-settings-form"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceTransfer")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourceTransferDescription")}
|
||||
</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>
|
||||
{t("siteDestination")}
|
||||
</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
|
||||
: t(
|
||||
"siteSelect"
|
||||
)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"searchSites"
|
||||
)}
|
||||
/>
|
||||
<CommandEmpty>
|
||||
{t(
|
||||
"sitesNotFound"
|
||||
)}
|
||||
</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>
|
||||
{resource.http && (
|
||||
<div className="space-y-2">
|
||||
<Label>Domain</Label>
|
||||
<div className="border p-2 rounded-md flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Globe size="14"/>
|
||||
{resourceFullDomain}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setEditDomainOpen(
|
||||
true
|
||||
)
|
||||
}
|
||||
>
|
||||
Edit Domain
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={transferLoading}
|
||||
disabled={transferLoading}
|
||||
form="transfer-form"
|
||||
>
|
||||
{t("resourceTransferSubmit")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
console.log(form.getValues());
|
||||
}}
|
||||
loading={saveLoading}
|
||||
disabled={saveLoading}
|
||||
form="general-settings-form"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceTransfer")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourceTransferDescription")}
|
||||
</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>
|
||||
{t("siteDestination")}
|
||||
</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
|
||||
: t(
|
||||
"siteSelect"
|
||||
)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"searchSites"
|
||||
)}
|
||||
/>
|
||||
<CommandEmpty>
|
||||
{t(
|
||||
"sitesNotFound"
|
||||
)}
|
||||
</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"
|
||||
>
|
||||
{t("resourceTransferSubmit")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
|
||||
<Credenza
|
||||
open={editDomainOpen}
|
||||
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Edit Domain</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Select a domain for your resource
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<DomainPicker
|
||||
orgId={orgId as string}
|
||||
onDomainChange={(res) => {
|
||||
const selected = {
|
||||
domainId: res.domainId,
|
||||
subdomain: res.subdomain,
|
||||
fullDomain: res.fullDomain
|
||||
};
|
||||
setSelectedDomain(selected);
|
||||
}}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button onClick={() => {
|
||||
if (selectedDomain) {
|
||||
setResourceFullDomain(selectedDomain.fullDomain);
|
||||
form.setValue("domainId", selectedDomain.domainId);
|
||||
form.setValue("subdomain", selectedDomain.subdomain);
|
||||
setEditDomainOpen(false);
|
||||
}
|
||||
}}>Select Domain</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -772,81 +772,50 @@ export default function ReverseProxyTargets(props: {
|
||||
className="space-y-4"
|
||||
id="tls-settings-form"
|
||||
>
|
||||
<FormField
|
||||
control={tlsSettingsForm.control}
|
||||
name="ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="ssl-toggle"
|
||||
label={t(
|
||||
"proxyEnableSSL"
|
||||
)}
|
||||
defaultChecked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={(
|
||||
val
|
||||
) => {
|
||||
field.onChange(val);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={tlsSettingsForm.control}
|
||||
name="ssl"
|
||||
name="tlsServerName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("targetTlsSni")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="ssl-toggle"
|
||||
label={t(
|
||||
"proxyEnableSSL"
|
||||
)}
|
||||
defaultChecked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={(
|
||||
val
|
||||
) => {
|
||||
field.onChange(val);
|
||||
}}
|
||||
/>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"targetTlsSniDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Collapsible
|
||||
open={isAdvancedOpen}
|
||||
onOpenChange={setIsAdvancedOpen}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="p-0 flex items-center justify-start gap-2 w-full"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"targetTlsSettingsAdvanced"
|
||||
)}
|
||||
</p>
|
||||
<div>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
Toggle
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent className="space-y-2">
|
||||
<FormField
|
||||
control={
|
||||
tlsSettingsForm.control
|
||||
}
|
||||
name="tlsServerName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("targetTlsSni")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"targetTlsSniDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
|
||||
@@ -247,7 +247,7 @@ export default function ResourceRules(props: {
|
||||
async function saveAllSettings() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
// Save rules enabled state
|
||||
const res = await api
|
||||
.post(`/resource/${params.resourceId}`, {
|
||||
@@ -594,17 +594,10 @@ export default function ResourceRules(props: {
|
||||
<div className="flex items-center space-x-2">
|
||||
<SwitchInput
|
||||
id="rules-toggle"
|
||||
label={t('rulesEnable')}
|
||||
defaultChecked={rulesEnabled}
|
||||
onCheckedChange={(val) => setRulesEnabled(val)}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label className="text-base font-medium">
|
||||
{t('rulesEnable')}
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('rulesEnableDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...addRuleForm}>
|
||||
|
||||
@@ -63,6 +63,7 @@ import { SquareArrowOutUpRight } from "lucide-react";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
|
||||
const baseResourceFormSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
@@ -70,17 +71,10 @@ const baseResourceFormSchema = z.object({
|
||||
http: z.boolean()
|
||||
});
|
||||
|
||||
const httpResourceFormSchema = z.discriminatedUnion("isBaseDomain", [
|
||||
z.object({
|
||||
isBaseDomain: z.literal(true),
|
||||
domainId: z.string().min(1)
|
||||
}),
|
||||
z.object({
|
||||
isBaseDomain: z.literal(false),
|
||||
domainId: z.string().min(1),
|
||||
subdomain: z.string().pipe(subdomainSchema)
|
||||
})
|
||||
]);
|
||||
const httpResourceFormSchema = z.object({
|
||||
domainId: z.string().optional(),
|
||||
subdomain: z.string().optional()
|
||||
});
|
||||
|
||||
const tcpUdpResourceFormSchema = z.object({
|
||||
protocol: z.string(),
|
||||
@@ -143,11 +137,7 @@ export default function Page() {
|
||||
|
||||
const httpForm = useForm<HttpResourceFormValues>({
|
||||
resolver: zodResolver(httpResourceFormSchema),
|
||||
defaultValues: {
|
||||
subdomain: "",
|
||||
domainId: "",
|
||||
isBaseDomain: false
|
||||
}
|
||||
defaultValues: {}
|
||||
});
|
||||
|
||||
const tcpUdpForm = useForm<TcpUdpResourceFormValues>({
|
||||
@@ -173,20 +163,10 @@ export default function Page() {
|
||||
|
||||
if (isHttp) {
|
||||
const httpData = httpForm.getValues();
|
||||
if (httpData.isBaseDomain) {
|
||||
Object.assign(payload, {
|
||||
domainId: httpData.domainId,
|
||||
isBaseDomain: true,
|
||||
protocol: "tcp"
|
||||
});
|
||||
} else {
|
||||
Object.assign(payload, {
|
||||
subdomain: httpData.subdomain,
|
||||
domainId: httpData.domainId,
|
||||
isBaseDomain: false,
|
||||
protocol: "tcp"
|
||||
domainId: httpData.domainId
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const tcpUdpData = tcpUdpForm.getValues();
|
||||
Object.assign(payload, {
|
||||
@@ -498,218 +478,23 @@ export default function Page() {
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...httpForm}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="http-settings-form"
|
||||
>
|
||||
{env.flags
|
||||
.allowBaseDomainResources && (
|
||||
<FormField
|
||||
control={
|
||||
httpForm.control
|
||||
}
|
||||
name="isBaseDomain"
|
||||
render={({
|
||||
field
|
||||
}) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"domainType"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={
|
||||
field.value
|
||||
? "basedomain"
|
||||
: "subdomain"
|
||||
}
|
||||
onValueChange={(
|
||||
value
|
||||
) => {
|
||||
field.onChange(
|
||||
value ===
|
||||
"basedomain"
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="subdomain">
|
||||
{t(
|
||||
"subdomain"
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="basedomain">
|
||||
{t(
|
||||
"baseDomain"
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!httpForm.watch(
|
||||
"isBaseDomain"
|
||||
) && (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("subdomain")}
|
||||
</FormLabel>
|
||||
<div className="flex space-x-0">
|
||||
<div className="w-1/2">
|
||||
<FormField
|
||||
control={
|
||||
httpForm.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={
|
||||
httpForm.control
|
||||
}
|
||||
name="domainId"
|
||||
render={({
|
||||
field
|
||||
}) => (
|
||||
<FormItem>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
value={
|
||||
field.value
|
||||
}
|
||||
defaultValue={
|
||||
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>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"subdomnainDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
{httpForm.watch(
|
||||
"isBaseDomain"
|
||||
) && (
|
||||
<FormField
|
||||
control={
|
||||
httpForm.control
|
||||
}
|
||||
name="domainId"
|
||||
render={({
|
||||
field
|
||||
}) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"baseDomain"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
defaultValue={
|
||||
field.value
|
||||
}
|
||||
{...field}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{baseDomains.map(
|
||||
(
|
||||
option
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
option.domainId
|
||||
}
|
||||
value={
|
||||
option.domainId
|
||||
}
|
||||
>
|
||||
{
|
||||
option.baseDomain
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
<DomainPicker
|
||||
orgId={orgId as string}
|
||||
onDomainChange={(res) => {
|
||||
httpForm.setValue(
|
||||
"subdomain",
|
||||
res.subdomain
|
||||
);
|
||||
httpForm.setValue(
|
||||
"domainId",
|
||||
res.domainId
|
||||
);
|
||||
console.log(
|
||||
"Domain changed:",
|
||||
res
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
) : (
|
||||
@@ -921,7 +706,7 @@ export default function Page() {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/${orgId}/settings/resources/${resourceId}`
|
||||
`/${orgId}/settings/resources/${resourceId}/proxy`
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -67,7 +67,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
resource.whitelist
|
||||
? "protected"
|
||||
: "not_protected",
|
||||
enabled: resource.enabled
|
||||
enabled: resource.enabled,
|
||||
domainId: resource.domainId || undefined
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -5,10 +5,12 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowRight, DockIcon as Docker, Globe, Server, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export const SitesSplashCard = () => {
|
||||
const [isDismissed, setIsDismissed] = useState(true);
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const key = "sites-splash-card-dismissed";
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -375,7 +375,10 @@ WantedBy=default.target`
|
||||
async function onSubmit(data: CreateSiteFormValues) {
|
||||
setCreateLoading(true);
|
||||
|
||||
let payload: CreateSiteBody = { name: data.name, type: data.method };
|
||||
let payload: CreateSiteBody = {
|
||||
name: data.name,
|
||||
type: data.method as "newt" | "wireguard" | "local"
|
||||
};
|
||||
|
||||
if (data.method == "wireguard") {
|
||||
if (!siteDefaults || !wgConfig) {
|
||||
@@ -412,7 +415,7 @@ WantedBy=default.target`
|
||||
exitNodeId: siteDefaults.exitNodeId,
|
||||
secret: siteDefaults.newtSecret,
|
||||
newtId: siteDefaults.newtId,
|
||||
address: clientAddress
|
||||
// address: clientAddress
|
||||
};
|
||||
}
|
||||
|
||||
@@ -573,42 +576,42 @@ WantedBy=default.target`
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Site Address
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
value={
|
||||
clientAddress
|
||||
}
|
||||
onChange={(
|
||||
e
|
||||
) => {
|
||||
setClientAddress(
|
||||
e.target
|
||||
.value
|
||||
);
|
||||
field.onChange(
|
||||
e.target
|
||||
.value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
Specify the IP
|
||||
address of the host.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* <FormField */}
|
||||
{/* control={form.control} */}
|
||||
{/* name="clientAddress" */}
|
||||
{/* render={({ field }) => ( */}
|
||||
{/* <FormItem> */}
|
||||
{/* <FormLabel> */}
|
||||
{/* Site Address */}
|
||||
{/* </FormLabel> */}
|
||||
{/* <FormControl> */}
|
||||
{/* <Input */}
|
||||
{/* autoComplete="off" */}
|
||||
{/* value={ */}
|
||||
{/* clientAddress */}
|
||||
{/* } */}
|
||||
{/* onChange={( */}
|
||||
{/* e */}
|
||||
{/* ) => { */}
|
||||
{/* setClientAddress( */}
|
||||
{/* e.target */}
|
||||
{/* .value */}
|
||||
{/* ); */}
|
||||
{/* field.onChange( */}
|
||||
{/* e.target */}
|
||||
{/* .value */}
|
||||
{/* ); */}
|
||||
{/* }} */}
|
||||
{/* /> */}
|
||||
{/* </FormControl> */}
|
||||
{/* <FormMessage /> */}
|
||||
{/* <FormDescription> */}
|
||||
{/* Specify the IP */}
|
||||
{/* address of the host. */}
|
||||
{/* </FormDescription> */}
|
||||
{/* </FormItem> */}
|
||||
{/* )} */}
|
||||
{/* /> */}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
@@ -760,7 +763,7 @@ WantedBy=default.target`
|
||||
? "squareOutlinePrimary"
|
||||
: "squareOutline"
|
||||
}
|
||||
className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""}`}
|
||||
className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""} shadow-none`}
|
||||
onClick={() => {
|
||||
setPlatform(os);
|
||||
}}
|
||||
@@ -791,7 +794,7 @@ WantedBy=default.target`
|
||||
? "squareOutlinePrimary"
|
||||
: "squareOutline"
|
||||
}
|
||||
className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""}`}
|
||||
className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""} shadow-none`}
|
||||
onClick={() =>
|
||||
setArchitecture(
|
||||
arch
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Metadata } from "next";
|
||||
import { Users } from "lucide-react";
|
||||
import { TopbarNav } from "@app/components/TopbarNav";
|
||||
import { KeyRound, Users } from "lucide-react";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ProfileIcon from "@app/components/ProfileIcon";
|
||||
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import { priv } from "@app/lib/api";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
@@ -11,7 +12,7 @@ import { cache } from "react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Auth - Pangolin`,
|
||||
title: `Auth - "Pangolin`,
|
||||
description: ""
|
||||
};
|
||||
|
||||
@@ -23,6 +24,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
const t = await getTranslations();
|
||||
const hideFooter = true;
|
||||
|
||||
const licenseStatusRes = await cache(
|
||||
async () =>
|
||||
@@ -34,20 +36,18 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{user && (
|
||||
<UserProvider user={user}>
|
||||
<div className="p-3 ml-auto">
|
||||
<ProfileIcon />
|
||||
</div>
|
||||
</UserProvider>
|
||||
)}
|
||||
<div className="flex justify-end items-center p-3 space-x-2">
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="w-full max-w-md p-3">{children}</div>
|
||||
</div>
|
||||
|
||||
{!(
|
||||
licenseStatus.isHostLicensed && licenseStatus.isLicenseValid
|
||||
hideFooter || (
|
||||
licenseStatus.isHostLicensed &&
|
||||
licenseStatus.isLicenseValid)
|
||||
) && (
|
||||
<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">
|
||||
@@ -73,7 +73,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
||||
aria-label="GitHub"
|
||||
className="flex items-center space-x-2 whitespace-nowrap"
|
||||
>
|
||||
<span>{t('communityEdition')}</span>
|
||||
<span>{t("communityEdition")}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -54,10 +54,10 @@ export default async function Page(props: {
|
||||
<div className="flex flex-col items-center">
|
||||
<Mail className="w-12 h-12 mb-4 text-primary" />
|
||||
<h2 className="text-2xl font-bold mb-2 text-center">
|
||||
{t('inviteAlready')}
|
||||
{t("inviteAlready")}
|
||||
</h2>
|
||||
<p className="text-center">
|
||||
{t('inviteAlreadyDescription')}
|
||||
{t("inviteAlreadyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,7 +67,7 @@ export default async function Page(props: {
|
||||
|
||||
{(!signUpDisabled || isInvite) && (
|
||||
<p className="text-center text-muted-foreground mt-4">
|
||||
{t('authNoAccount')}{" "}
|
||||
{t("authNoAccount")}{" "}
|
||||
<Link
|
||||
href={
|
||||
!redirectUrl
|
||||
@@ -76,7 +76,7 @@ export default async function Page(props: {
|
||||
}
|
||||
className="underline"
|
||||
>
|
||||
{t('signup')}
|
||||
{t("signup")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -54,12 +54,14 @@ export type ResetPasswordFormProps = {
|
||||
emailParam?: string;
|
||||
tokenParam?: string;
|
||||
redirect?: string;
|
||||
quickstart?: boolean;
|
||||
};
|
||||
|
||||
export default function ResetPasswordForm({
|
||||
emailParam,
|
||||
tokenParam,
|
||||
redirect
|
||||
redirect,
|
||||
quickstart
|
||||
}: ResetPasswordFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -184,17 +186,63 @@ export default function ResetPasswordForm({
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccessMessage(t('passwordResetSuccess'));
|
||||
setSuccessMessage(quickstart ? t('accountSetupSuccess') : t('passwordResetSuccess'));
|
||||
|
||||
setTimeout(() => {
|
||||
if (redirect) {
|
||||
const safe = cleanRedirect(redirect);
|
||||
router.push(safe);
|
||||
} else {
|
||||
router.push("/login");
|
||||
// Auto-login after successful password reset
|
||||
try {
|
||||
const loginRes = await api.post("/auth/login", {
|
||||
email: form.getValues("email"),
|
||||
password: form.getValues("password")
|
||||
});
|
||||
|
||||
if (loginRes.data.data?.codeRequested) {
|
||||
if (redirect) {
|
||||
router.push(`/auth/login?redirect=${redirect}`);
|
||||
} else {
|
||||
router.push("/auth/login");
|
||||
}
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
}, 1500);
|
||||
|
||||
if (loginRes.data.data?.emailVerificationRequired) {
|
||||
try {
|
||||
await api.post("/auth/verify-email/request");
|
||||
} catch (verificationError) {
|
||||
console.error("Failed to send verification code:", verificationError);
|
||||
}
|
||||
|
||||
if (redirect) {
|
||||
router.push(`/auth/verify-email?redirect=${redirect}`);
|
||||
} else {
|
||||
router.push("/auth/verify-email");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Login successful, redirect
|
||||
setTimeout(() => {
|
||||
if (redirect) {
|
||||
const safe = cleanRedirect(redirect);
|
||||
router.push(safe);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
}, 1500);
|
||||
|
||||
} catch (loginError) {
|
||||
// Auto-login failed, but password reset was successful
|
||||
console.error("Auto-login failed:", loginError);
|
||||
setTimeout(() => {
|
||||
if (redirect) {
|
||||
const safe = cleanRedirect(redirect);
|
||||
router.push(safe);
|
||||
} else {
|
||||
router.push("/login");
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,9 +250,14 @@ export default function ResetPasswordForm({
|
||||
<div>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('passwordReset')}</CardTitle>
|
||||
<CardTitle>
|
||||
{quickstart ? t('completeAccountSetup') : t('passwordReset')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('passwordResetDescription')}
|
||||
{quickstart
|
||||
? t('completeAccountSetupDescription')
|
||||
: t('passwordResetDescription')
|
||||
}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -229,7 +282,10 @@ export default function ResetPasswordForm({
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t('passwordResetSent')}
|
||||
{quickstart
|
||||
? t('accountSetupSent')
|
||||
: t('passwordResetSent')
|
||||
}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -269,7 +325,10 @@ export default function ResetPasswordForm({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('passwordResetCode')}
|
||||
{quickstart
|
||||
? t('accountSetupCode')
|
||||
: t('passwordResetCode')
|
||||
}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -279,7 +338,10 @@ export default function ResetPasswordForm({
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t('passwordResetCodeDescription')}
|
||||
{quickstart
|
||||
? t('accountSetupCodeDescription')
|
||||
: t('passwordResetCodeDescription')
|
||||
}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -292,7 +354,10 @@ export default function ResetPasswordForm({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('passwordNew')}
|
||||
{quickstart
|
||||
? t('passwordCreate')
|
||||
: t('passwordNew')
|
||||
}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -310,7 +375,10 @@ export default function ResetPasswordForm({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('passwordNewConfirm')}
|
||||
{quickstart
|
||||
? t('passwordCreateConfirm')
|
||||
: t('passwordNewConfirm')
|
||||
}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -407,7 +475,7 @@ export default function ResetPasswordForm({
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{state === "reset"
|
||||
? t('passwordReset')
|
||||
? (quickstart ? t('completeSetup') : t('passwordReset'))
|
||||
: t('pincodeSubmit2')}
|
||||
</Button>
|
||||
)}
|
||||
@@ -422,7 +490,10 @@ export default function ResetPasswordForm({
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{t('passwordResetSubmit')}
|
||||
{quickstart
|
||||
? t('accountSetupSubmit')
|
||||
: t('passwordResetSubmit')
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export default async function Page(props: {
|
||||
redirect: string | undefined;
|
||||
email: string | undefined;
|
||||
token: string | undefined;
|
||||
quickstart?: string | undefined;
|
||||
}>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
@@ -35,6 +36,9 @@ export default async function Page(props: {
|
||||
redirect={searchParams.redirect}
|
||||
tokenParam={searchParams.token}
|
||||
emailParam={searchParams.email}
|
||||
quickstart={
|
||||
searchParams.quickstart === "true" ? true : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="text-center text-muted-foreground mt-4">
|
||||
@@ -46,7 +50,7 @@ export default async function Page(props: {
|
||||
}
|
||||
className="underline"
|
||||
>
|
||||
{t('loginBack')}
|
||||
{t("loginBack")}
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
|
||||
@@ -43,6 +43,7 @@ import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -185,8 +186,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
setOtpState("otp_sent");
|
||||
submitOtpForm.setValue("email", values.email);
|
||||
toast({
|
||||
title: t('otpEmailSent'),
|
||||
description: t('otpEmailSentDescription')
|
||||
title: t("otpEmailSent"),
|
||||
description: t("otpEmailSentDescription")
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -202,7 +203,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setWhitelistError(
|
||||
formatAxiosError(e, t('otpEmailErrorAuthenticate'))
|
||||
formatAxiosError(e, t("otpEmailErrorAuthenticate"))
|
||||
);
|
||||
})
|
||||
.then(() => setLoadingLogin(false));
|
||||
@@ -227,7 +228,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setPincodeError(
|
||||
formatAxiosError(e, t('pincodeErrorAuthenticate'))
|
||||
formatAxiosError(e, t("pincodeErrorAuthenticate"))
|
||||
);
|
||||
})
|
||||
.then(() => setLoadingLogin(false));
|
||||
@@ -255,7 +256,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setPasswordError(
|
||||
formatAxiosError(e, t('passwordErrorAuthenticate'))
|
||||
formatAxiosError(e, t("passwordErrorAuthenticate"))
|
||||
);
|
||||
})
|
||||
.finally(() => setLoadingLogin(false));
|
||||
@@ -276,30 +277,25 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function getTitle() {
|
||||
return t("authenticationRequired");
|
||||
}
|
||||
|
||||
function getSubtitle(resourceName: string) {
|
||||
return numMethods > 1
|
||||
? t("authenticationMethodChoose", { name: props.resource.name })
|
||||
: t("authenticationRequest", { name: props.resource.name });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!accessDenied ? (
|
||||
<div>
|
||||
<div className="text-center mb-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('poweredBy')}{" "}
|
||||
<Link
|
||||
href="https://github.com/fosrl/pangolin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Pangolin
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('authenticationRequired')}</CardTitle>
|
||||
<CardTitle>{getTitle()}</CardTitle>
|
||||
<CardDescription>
|
||||
{numMethods > 1
|
||||
? t('authenticationMethodChoose', {name: props.resource.name})
|
||||
: t('authenticationRequest', {name: props.resource.name})}
|
||||
{getSubtitle(props.resource.name)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -329,19 +325,19 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
{props.methods.password && (
|
||||
<TabsTrigger value="password">
|
||||
<Key className="w-4 h-4 mr-1" />{" "}
|
||||
{t('password')}
|
||||
{t("password")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{props.methods.sso && (
|
||||
<TabsTrigger value="sso">
|
||||
<User className="w-4 h-4 mr-1" />{" "}
|
||||
{t('user')}
|
||||
{t("user")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{props.methods.whitelist && (
|
||||
<TabsTrigger value="whitelist">
|
||||
<AtSign className="w-4 h-4 mr-1" />{" "}
|
||||
{t('email')}
|
||||
{t("email")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
@@ -364,7 +360,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('pincodeInput')}
|
||||
{t(
|
||||
"pincodeInput"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
@@ -433,7 +431,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
disabled={loadingLogin}
|
||||
>
|
||||
<LockIcon className="w-4 h-4 mr-2" />
|
||||
{t('pincodeSubmit')}
|
||||
{t("pincodeSubmit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -459,7 +457,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('password')}
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -487,7 +485,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
disabled={loadingLogin}
|
||||
>
|
||||
<LockIcon className="w-4 h-4 mr-2" />
|
||||
{t('passwordSubmit')}
|
||||
{t("passwordSubmit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -528,7 +526,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('email')}
|
||||
{t("email")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -537,7 +535,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('otpEmailDescription')}
|
||||
{t(
|
||||
"otpEmailDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -559,7 +559,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
disabled={loadingLogin}
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{t('otpEmailSend')}
|
||||
{t("otpEmailSend")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -581,7 +581,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('otpEmail')}
|
||||
{t(
|
||||
"otpEmail"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -609,7 +611,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
disabled={loadingLogin}
|
||||
>
|
||||
<LockIcon className="w-4 h-4 mr-2" />
|
||||
{t('otpEmailSubmit')}
|
||||
{t("otpEmailSubmit")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -621,7 +623,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
submitOtpForm.reset();
|
||||
}}
|
||||
>
|
||||
{t('backToEmail')}
|
||||
{t("backToEmail")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -634,7 +636,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
{supporterStatus?.visible && (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm text-muted-foreground opacity-50">
|
||||
{t('noSupportKey')}
|
||||
{t("noSupportKey")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -57,7 +57,9 @@ export default function SignupForm({
|
||||
}: SignupFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -116,8 +118,8 @@ export default function SignupForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<Card className="w-full max-w-md shadow-md">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<Image
|
||||
src={`/logo/pangolin_orange.svg`}
|
||||
@@ -135,7 +137,7 @@ export default function SignupForm({
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="pt-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
@@ -161,10 +163,7 @@ export default function SignupForm({
|
||||
<FormItem>
|
||||
<FormLabel>{t('password')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -177,10 +176,7 @@ export default function SignupForm({
|
||||
<FormItem>
|
||||
<FormLabel>{t('confirmPassword')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -111,6 +111,9 @@
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
--shadow-2xs: 0 1px 1px rgba(0, 0, 0, 0.03);
|
||||
--inset-shadow-2xs: inset 0 1px 1px rgba(0, 0, 1, 0.03);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Inter } from "next/font/google";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
||||
import EnvProvider from "@app/providers/EnvProvider";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
@@ -15,10 +14,11 @@ import LicenseViolation from "@app/components/LicenseViolation";
|
||||
import { cache } from "react";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { Toaster } from "@app/components/ui/toaster";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Dashboard - Pangolin`,
|
||||
description: ""
|
||||
description: "",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -83,4 +83,4 @@ export default async function RootLayout({
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
Workflow,
|
||||
KeyRound,
|
||||
TicketCheck,
|
||||
User
|
||||
User,
|
||||
Globe
|
||||
} from "lucide-react";
|
||||
|
||||
export type SidebarNavSection = {
|
||||
@@ -31,6 +32,11 @@ export const orgNavSections: SidebarNavSection[] = [
|
||||
title: "sidebarResources",
|
||||
href: "/{orgId}/settings/resources",
|
||||
icon: <Waypoints className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: "sidebarDomains",
|
||||
href: "/{orgId}/settings/domains",
|
||||
icon: <Globe className="h-4 w-4" />
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -75,35 +75,33 @@ export default async function Page(props: {
|
||||
const allCookies = await cookies();
|
||||
const lastOrgCookie = allCookies.get("pangolin-last-org")?.value;
|
||||
|
||||
if (lastOrgCookie && orgs.length > 0) {
|
||||
const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie);
|
||||
if (lastOrgExists) {
|
||||
redirect(`/${lastOrgCookie}`);
|
||||
const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie);
|
||||
if (lastOrgExists) {
|
||||
redirect(`/${lastOrgCookie}`);
|
||||
} else {
|
||||
const ownedOrg = orgs.find((org) => org.isOwner);
|
||||
if (ownedOrg) {
|
||||
redirect(`/${ownedOrg.orgId}`);
|
||||
} else {
|
||||
const ownedOrg = orgs.find((org) => org.isOwner);
|
||||
if (ownedOrg) {
|
||||
redirect(`/${ownedOrg.orgId}`);
|
||||
} else {
|
||||
redirect("/setup");
|
||||
}
|
||||
redirect("/setup");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<Layout orgs={orgs} navItems={[]}>
|
||||
<div className="w-full max-w-md mx-auto md:mt-32 mt-4">
|
||||
<OrganizationLanding
|
||||
disableCreateOrg={
|
||||
env.flags.disableUserCreateOrg && !user.serverAdmin
|
||||
}
|
||||
organizations={orgs.map((org) => ({
|
||||
name: org.name,
|
||||
id: org.orgId
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
</UserProvider>
|
||||
);
|
||||
// return (
|
||||
// <UserProvider user={user}>
|
||||
// <Layout orgs={orgs} navItems={[]}>
|
||||
// <div className="w-full max-w-md mx-auto md:mt-32 mt-4">
|
||||
// <OrganizationLanding
|
||||
// disableCreateOrg={
|
||||
// env.flags.disableUserCreateOrg && !user.serverAdmin
|
||||
// }
|
||||
// organizations={orgs.map((org) => ({
|
||||
// name: org.name,
|
||||
// id: org.orgId
|
||||
// }))}
|
||||
// />
|
||||
// </div>
|
||||
// </Layout>
|
||||
// </UserProvider>
|
||||
// );
|
||||
}
|
||||
|
||||
499
src/components/DomainPicker.tsx
Normal file
499
src/components/DomainPicker.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Building2,
|
||||
Zap,
|
||||
ArrowUpDown
|
||||
} from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { createApiClient, formatAxiosError } from "@/lib/api";
|
||||
import { useEnvContext } from "@/hooks/useEnvContext";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { ListDomainsResponse } from "@server/routers/domain/listDomains";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type OrganizationDomain = {
|
||||
domainId: string;
|
||||
baseDomain: string;
|
||||
verified: boolean;
|
||||
type: "ns" | "cname";
|
||||
};
|
||||
|
||||
type AvailableOption = {
|
||||
domainNamespaceId: string;
|
||||
fullDomain: string;
|
||||
domainId: string;
|
||||
};
|
||||
|
||||
type DomainOption = {
|
||||
id: string;
|
||||
domain: string;
|
||||
type: "organization" | "provided";
|
||||
verified?: boolean;
|
||||
domainType?: "ns" | "cname";
|
||||
domainId?: string;
|
||||
domainNamespaceId?: string;
|
||||
subdomain?: string;
|
||||
};
|
||||
|
||||
interface DomainPickerProps {
|
||||
orgId: string;
|
||||
onDomainChange?: (domainInfo: {
|
||||
domainId: string;
|
||||
domainNamespaceId?: string;
|
||||
type: "organization" | "provided";
|
||||
subdomain?: string;
|
||||
fullDomain: string;
|
||||
baseDomain: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export default function DomainPicker({
|
||||
orgId,
|
||||
onDomainChange
|
||||
}: DomainPickerProps) {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const t = useTranslations();
|
||||
|
||||
const [userInput, setUserInput] = useState<string>("");
|
||||
const [selectedOption, setSelectedOption] = useState<DomainOption | null>(
|
||||
null
|
||||
);
|
||||
const [availableOptions, setAvailableOptions] = useState<AvailableOption[]>(
|
||||
[]
|
||||
);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [organizationDomains, setOrganizationDomains] = useState<
|
||||
OrganizationDomain[]
|
||||
>([]);
|
||||
const [loadingDomains, setLoadingDomains] = useState(false);
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"all" | "organization" | "provided"
|
||||
>("all");
|
||||
const [providedDomainsShown, setProvidedDomainsShown] = useState(3);
|
||||
|
||||
useEffect(() => {
|
||||
const loadOrganizationDomains = async () => {
|
||||
setLoadingDomains(true);
|
||||
try {
|
||||
const response = await api.get<
|
||||
AxiosResponse<ListDomainsResponse>
|
||||
>(`/org/${orgId}/domains`);
|
||||
if (response.status === 200) {
|
||||
const domains = response.data.data.domains
|
||||
.filter(
|
||||
(domain) =>
|
||||
domain.type === "ns" || domain.type === "cname"
|
||||
)
|
||||
.map((domain) => ({
|
||||
...domain,
|
||||
type: domain.type as "ns" | "cname"
|
||||
}));
|
||||
setOrganizationDomains(domains);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load organization domains:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to load organization domains"
|
||||
});
|
||||
} finally {
|
||||
setLoadingDomains(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadOrganizationDomains();
|
||||
}, [orgId, api]);
|
||||
|
||||
// Generate domain options based on user input
|
||||
const generateDomainOptions = (): DomainOption[] => {
|
||||
const options: DomainOption[] = [];
|
||||
|
||||
if (!userInput.trim()) return options;
|
||||
|
||||
// Check if input is more than one level deep (contains multiple dots)
|
||||
const isMultiLevel = (userInput.match(/\./g) || []).length > 1;
|
||||
|
||||
// Add organization domain options
|
||||
organizationDomains.forEach((orgDomain) => {
|
||||
if (orgDomain.type === "cname") {
|
||||
// For CNAME domains, check if the user input matches exactly
|
||||
if (
|
||||
orgDomain.baseDomain.toLowerCase() ===
|
||||
userInput.toLowerCase()
|
||||
) {
|
||||
options.push({
|
||||
id: `org-${orgDomain.domainId}`,
|
||||
domain: orgDomain.baseDomain,
|
||||
type: "organization",
|
||||
verified: orgDomain.verified,
|
||||
domainType: "cname",
|
||||
domainId: orgDomain.domainId
|
||||
});
|
||||
}
|
||||
} else if (orgDomain.type === "ns") {
|
||||
// For NS domains, check if the user input could be a subdomain
|
||||
const userInputLower = userInput.toLowerCase();
|
||||
const baseDomainLower = orgDomain.baseDomain.toLowerCase();
|
||||
|
||||
// Check if user input ends with the base domain
|
||||
if (userInputLower.endsWith(`.${baseDomainLower}`)) {
|
||||
const subdomain = userInputLower.slice(
|
||||
0,
|
||||
-(baseDomainLower.length + 1)
|
||||
);
|
||||
options.push({
|
||||
id: `org-${orgDomain.domainId}`,
|
||||
domain: userInput,
|
||||
type: "organization",
|
||||
verified: orgDomain.verified,
|
||||
domainType: "ns",
|
||||
domainId: orgDomain.domainId,
|
||||
subdomain: subdomain
|
||||
});
|
||||
} else if (userInputLower === baseDomainLower) {
|
||||
// Exact match for base domain
|
||||
options.push({
|
||||
id: `org-${orgDomain.domainId}`,
|
||||
domain: orgDomain.baseDomain,
|
||||
type: "organization",
|
||||
verified: orgDomain.verified,
|
||||
domainType: "ns",
|
||||
domainId: orgDomain.domainId
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add provided domain options (always try to match provided domains)
|
||||
availableOptions.forEach((option) => {
|
||||
options.push({
|
||||
id: `provided-${option.domainNamespaceId}`,
|
||||
domain: option.fullDomain,
|
||||
type: "provided",
|
||||
domainNamespaceId: option.domainNamespaceId,
|
||||
domainId: option.domainId,
|
||||
});
|
||||
});
|
||||
|
||||
// Sort options
|
||||
return options.sort((a, b) => {
|
||||
const comparison = a.domain.localeCompare(b.domain);
|
||||
return sortOrder === "asc" ? comparison : -comparison;
|
||||
});
|
||||
};
|
||||
|
||||
const domainOptions = generateDomainOptions();
|
||||
|
||||
// Filter options based on active tab
|
||||
const filteredOptions = domainOptions.filter((option) => {
|
||||
if (activeTab === "all") return true;
|
||||
return option.type === activeTab;
|
||||
});
|
||||
|
||||
// Separate organization and provided options for pagination
|
||||
const organizationOptions = filteredOptions.filter(
|
||||
(opt) => opt.type === "organization"
|
||||
);
|
||||
const allProvidedOptions = filteredOptions.filter(
|
||||
(opt) => opt.type === "provided"
|
||||
);
|
||||
const providedOptions = allProvidedOptions.slice(0, providedDomainsShown);
|
||||
const hasMoreProvided = allProvidedOptions.length > providedDomainsShown;
|
||||
|
||||
// Handle option selection
|
||||
const handleOptionSelect = (option: DomainOption) => {
|
||||
setSelectedOption(option);
|
||||
|
||||
if (option.type === "organization") {
|
||||
if (option.domainType === "cname") {
|
||||
onDomainChange?.({
|
||||
domainId: option.domainId!,
|
||||
type: "organization",
|
||||
subdomain: undefined,
|
||||
fullDomain: option.domain,
|
||||
baseDomain: option.domain
|
||||
});
|
||||
} else if (option.domainType === "ns") {
|
||||
const subdomain = option.subdomain || "";
|
||||
onDomainChange?.({
|
||||
domainId: option.domainId!,
|
||||
type: "organization",
|
||||
subdomain: subdomain || undefined,
|
||||
fullDomain: option.domain,
|
||||
baseDomain: option.domain
|
||||
});
|
||||
}
|
||||
} else if (option.type === "provided") {
|
||||
// Extract subdomain from full domain
|
||||
const parts = option.domain.split(".");
|
||||
const subdomain = parts[0];
|
||||
const baseDomain = parts.slice(1).join(".");
|
||||
onDomainChange?.({
|
||||
domainId: option.domainId!,
|
||||
domainNamespaceId: option.domainNamespaceId,
|
||||
type: "provided",
|
||||
subdomain: subdomain,
|
||||
fullDomain: option.domain,
|
||||
baseDomain: baseDomain
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Domain Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="domain-input">
|
||||
{t("domainPickerEnterDomain")}
|
||||
</Label>
|
||||
<Input
|
||||
id="domain-input"
|
||||
value={userInput}
|
||||
onChange={(e) => {
|
||||
// Only allow letters, numbers, hyphens, and periods
|
||||
const validInput = e.target.value.replace(
|
||||
/[^a-zA-Z0-9.-]/g,
|
||||
""
|
||||
);
|
||||
setUserInput(validInput);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("domainPickerDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs and Sort Toggle */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) =>
|
||||
setActiveTab(
|
||||
value as "all" | "organization" | "provided"
|
||||
)
|
||||
}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">
|
||||
{t("domainPickerTabAll")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="organization">
|
||||
{t("domainPickerTabOrganization")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="provided">
|
||||
{t("domainPickerTabProvided")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
|
||||
}
|
||||
>
|
||||
<ArrowUpDown className="h-4 w-4 mr-2" />
|
||||
{sortOrder === "asc"
|
||||
? t("domainPickerSortAsc")
|
||||
: t("domainPickerSortDesc")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isChecking && (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||
<span>{t("domainPickerCheckingAvailability")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Options */}
|
||||
{!isChecking &&
|
||||
filteredOptions.length === 0 &&
|
||||
userInput.trim() && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t("domainPickerNoMatchingDomains", { userInput })}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Domain Options */}
|
||||
{!isChecking && filteredOptions.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{/* Organization Domains */}
|
||||
{organizationOptions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("domainPickerOrganizationDomains")}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{organizationOptions.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"transition-all p-3 rounded-lg border",
|
||||
selectedOption?.id === option.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-input",
|
||||
option.verified
|
||||
? "cursor-pointer hover:bg-accent"
|
||||
: "cursor-not-allowed opacity-60"
|
||||
)}
|
||||
onClick={() =>
|
||||
option.verified && handleOptionSelect(option)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="font-mono text-sm">
|
||||
{option.domain}
|
||||
</p>
|
||||
{/* <Badge */}
|
||||
{/* variant={ */}
|
||||
{/* option.domainType === */}
|
||||
{/* "ns" */}
|
||||
{/* ? "default" */}
|
||||
{/* : "secondary" */}
|
||||
{/* } */}
|
||||
{/* > */}
|
||||
{/* {option.domainType} */}
|
||||
{/* </Badge> */}
|
||||
{option.verified ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="h-3 w-3 text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
{option.subdomain && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t(
|
||||
"domainPickerSubdomain",
|
||||
{
|
||||
subdomain:
|
||||
option.subdomain
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{!option.verified && (
|
||||
<p className="text-xs text-yellow-600 mt-1">
|
||||
Domain is unverified
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedOption?.id ===
|
||||
option.id && (
|
||||
<CheckCircle2 className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provided Domains */}
|
||||
{providedOptions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Zap className="h-4 w-4" />
|
||||
<div className="text-sm font-medium">
|
||||
{t("domainPickerProvidedDomains")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{providedOptions.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"transition-all p-3 rounded-lg border",
|
||||
selectedOption?.id === option.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-input",
|
||||
"cursor-pointer hover:bg-accent"
|
||||
)}
|
||||
onClick={() =>
|
||||
handleOptionSelect(option)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-sm">
|
||||
{option.domain}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"domainPickerNamespace",
|
||||
{
|
||||
namespace:
|
||||
option.domainNamespaceId as string
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{selectedOption?.id ===
|
||||
option.id && (
|
||||
<CheckCircle2 className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hasMoreProvided && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setProvidedDomainsShown(
|
||||
(prev) => prev + 3
|
||||
)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{t("domainPickerShowMore")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
func(...args);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
@@ -30,8 +30,9 @@ export async function Layout({
|
||||
}: LayoutProps) {
|
||||
const allCookies = await cookies();
|
||||
const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
|
||||
|
||||
const initialSidebarCollapsed = sidebarStateCookie === "collapsed" ||
|
||||
|
||||
const initialSidebarCollapsed =
|
||||
sidebarStateCookie === "collapsed" ||
|
||||
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
|
||||
|
||||
return (
|
||||
@@ -49,7 +50,7 @@ export async function Layout({
|
||||
{/* Main content area */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 flex flex-col h-full min-w-0",
|
||||
"flex-1 flex flex-col h-full min-w-0 relative",
|
||||
!showSidebar && "w-full"
|
||||
)}
|
||||
>
|
||||
@@ -69,7 +70,10 @@ export async function Layout({
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
|
||||
<div className="container mx-auto max-w-12xl mb-12">
|
||||
<div className={cn(
|
||||
"container mx-auto max-w-12xl mb-12",
|
||||
showHeader && "md:pt-20" // Add top padding only on desktop to account for fixed header
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -7,6 +7,8 @@ import Link from "next/link";
|
||||
import ProfileIcon from "@app/components/ProfileIcon";
|
||||
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { Badge } from "./ui/badge";
|
||||
|
||||
interface LayoutHeaderProps {
|
||||
showTopBar: boolean;
|
||||
@@ -15,6 +17,7 @@ interface LayoutHeaderProps {
|
||||
export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
|
||||
const { theme } = useTheme();
|
||||
const [path, setPath] = useState<string>("");
|
||||
const { env } = useEnvContext();
|
||||
|
||||
useEffect(() => {
|
||||
function getPath() {
|
||||
@@ -56,7 +59,6 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Profile controls on the right */}
|
||||
{showTopBar && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<ThemeSwitcher />
|
||||
|
||||
@@ -152,7 +152,7 @@ export function LayoutSidebar({
|
||||
onClick={() =>
|
||||
setIsSidebarCollapsed(!isSidebarCollapsed)
|
||||
}
|
||||
className="cursor-pointer absolute -right-2.5 top-1/2 transform -translate-y-1/2 w-2 h-8 rounded-full flex items-center justify-center transition-all duration-200 ease-in-out hover:scale-110 group"
|
||||
className="cursor-pointer absolute -right-2.5 top-1/2 transform -translate-y-1/2 w-2 h-8 rounded-full flex items-center justify-center transition-all duration-200 ease-in-out hover:scale-110 group z-[60]"
|
||||
aria-label={
|
||||
isSidebarCollapsed
|
||||
? "Expand sidebar"
|
||||
|
||||
@@ -80,8 +80,8 @@ export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorPro
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command className="rounded-lg">
|
||||
<CommandInput
|
||||
placeholder={t('searchProgress')}
|
||||
<CommandInput
|
||||
placeholder={t('searchProgress')}
|
||||
className="border-0 focus:ring-0"
|
||||
/>
|
||||
<CommandEmpty className="py-6 text-center">
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Plus } from "lucide-react";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface Organization {
|
||||
@@ -29,6 +30,8 @@ export default function OrganizationLanding({
|
||||
}: OrganizationLandingProps) {
|
||||
const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const handleOrgClick = (orgId: string) => {
|
||||
setSelectedOrg(orgId);
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ export function SettingsSectionForm({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="max-w-xl">{children}</div>;
|
||||
return <div className="max-w-xl space-y-4">{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSectionTitle({
|
||||
|
||||
@@ -48,6 +48,7 @@ export function SidebarNav({
|
||||
const niceId = params.niceId as string;
|
||||
const resourceId = params.resourceId as string;
|
||||
const userId = params.userId as string;
|
||||
const apiKeyId = params.apiKeyId as string;
|
||||
const clientId = params.clientId as string;
|
||||
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
||||
const { user } = useUserContext();
|
||||
@@ -59,6 +60,7 @@ export function SidebarNav({
|
||||
.replace("{niceId}", niceId)
|
||||
.replace("{resourceId}", resourceId)
|
||||
.replace("{userId}", userId)
|
||||
.replace("{apiKeyId}", apiKeyId)
|
||||
.replace("{clientId}", clientId);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,8 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
const [keyOpen, setKeyOpen] = useState(false);
|
||||
const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const t = useTranslations();
|
||||
|
||||
const formSchema = z.object({
|
||||
|
||||
@@ -497,7 +497,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
<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 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-0 disabled:cursor-not-allowed disabled:opacity-50`,
|
||||
`flex flex-row flex-wrap items-center gap-1.5 p-1.5 w-full rounded-md border border-input 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-0 disabled:cursor-not-allowed disabled:opacity-50`,
|
||||
styleClasses?.inlineTagsContainer
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -16,9 +16,9 @@ const badgeVariants = cva(
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground",
|
||||
outline: "text-foreground",
|
||||
green: "border-transparent bg-green-500",
|
||||
yellow: "border-transparent bg-yellow-500",
|
||||
red: "border-transparent bg-red-300",
|
||||
green: "border-green-600 bg-green-500/20 text-green-700 dark:text-green-300",
|
||||
yellow: "border-yellow-600 bg-yellow-500/20 text-yellow-700 dark:text-yellow-300",
|
||||
red: "border-red-400 bg-red-300/20 text-red-600 dark:text-red-300",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -71,7 +71,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
disabled={loading || props.disabled} // Disable button when loading
|
||||
{...props}
|
||||
>
|
||||
{/* {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} */}
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{props.children}
|
||||
</Comp>
|
||||
);
|
||||
|
||||
@@ -107,8 +107,8 @@ export function DataTable<TData, TValue>({
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:justify-end">
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
loading={isRefreshing}
|
||||
@@ -182,4 +182,4 @@ export function DataTable<TData, TValue>({
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,24 +3,60 @@
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
const progressVariants = cva(
|
||||
"border relative h-2 w-full overflow-hidden rounded-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
success: "bg-muted",
|
||||
warning: "bg-muted",
|
||||
danger: "bg-muted"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const indicatorVariants = cva(
|
||||
"h-full w-full flex-1 transition-all",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary",
|
||||
success: "bg-green-500",
|
||||
warning: "bg-yellow-500",
|
||||
danger: "bg-red-500"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
type ProgressProps = React.ComponentProps<typeof ProgressPrimitive.Root> &
|
||||
VariantProps<typeof progressVariants>;
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
}: ProgressProps) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"border relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
className={cn(progressVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
className={cn(indicatorVariants({ variant }))}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
|
||||
@@ -47,7 +47,9 @@ export function pullEnv(): Env {
|
||||
? true
|
||||
: false,
|
||||
enableClients:
|
||||
process.env.FLAGS_ENABLE_CLIENTS === "true" ? true : false
|
||||
}
|
||||
process.env.FLAGS_ENABLE_CLIENTS === "true" ? true : false,
|
||||
hideSupporterKey:
|
||||
process.env.HIDE_SUPPORTER_KEY === "true" ? true : false
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,5 +25,6 @@ export type Env = {
|
||||
disableLocalSites: boolean;
|
||||
disableBasicWireguardSites: boolean;
|
||||
enableClients: boolean;
|
||||
};
|
||||
hideSupporterKey: boolean;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user