Various fixes for rc

This commit is contained in:
Owen
2025-10-27 16:33:21 -07:00
parent 6b18a24f9b
commit 15d63ddffa
10 changed files with 544 additions and 452 deletions

View File

@@ -1744,7 +1744,6 @@
"orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.", "orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.",
"orgAuthSignInWithPangolin": "Sign in with Pangolin", "orgAuthSignInWithPangolin": "Sign in with Pangolin",
"subscriptionRequiredToUse": "A subscription is required to use this feature.", "subscriptionRequiredToUse": "A subscription is required to use this feature.",
"licenseRequiredToUse": "An Enterprise license is required to use this feature.",
"idpDisabled": "Identity providers are disabled.", "idpDisabled": "Identity providers are disabled.",
"orgAuthPageDisabled": "Organization auth page is disabled.", "orgAuthPageDisabled": "Organization auth page is disabled.",
"domainRestartedDescription": "Domain verification restarted successfully", "domainRestartedDescription": "Domain verification restarted successfully",
@@ -2040,5 +2039,7 @@
"version2": "Version 2", "version2": "Version 2",
"versionDescription": "Version 1 is text-based and widely supported. Version 2 is binary and more efficient but less compatible.", "versionDescription": "Version 1 is text-based and widely supported. Version 2 is binary and more efficient but less compatible.",
"warning": "Warning", "warning": "Warning",
"proxyProtocolWarning": "Your backend application must be configured to accept Proxy Protocol connections. If your backend doesn't support Proxy Protocol, enabling this will break all connections. Make sure to configure your backend to trust Proxy Protocol headers from Traefik." "proxyProtocolWarning": "Your backend application must be configured to accept Proxy Protocol connections. If your backend doesn't support Proxy Protocol, enabling this will break all connections. Make sure to configure your backend to trust Proxy Protocol headers from Traefik.",
"restarting": "Restarting...",
"manual": "Manual"
} }

View File

@@ -105,7 +105,7 @@ export async function logRequestAudit(
try { try {
if (data.orgId) { if (data.orgId) {
const retentionDays = await getRetentionDays(data.orgId); const retentionDays = await getRetentionDays(data.orgId);
if (retentionDays === 0) { if (retentionDays == 0) {
// do not log // do not log
return; return;
} }

View File

@@ -14,7 +14,7 @@ import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2"; import { sha256 } from "@oslojs/crypto/sha2";
import { response } from "@server/lib/response"; import { response } from "@server/lib/response";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { logAccessAudit } from "#private/lib/logAccessAudit"; import { logAccessAudit } from "#dynamic/lib/logAccessAudit";
const getExchangeTokenParams = z const getExchangeTokenParams = z
.object({ .object({

View File

@@ -9,7 +9,7 @@ export default async function migration() {
try { try {
await db.execute(sql`BEGIN`); await db.execute(sql`BEGIN`);
await db.execute(sql`UPDATE "resourceRules" SET "match" = "COUNTRY" WHERE "match" = "GEOIP"`); await db.execute(sql`UPDATE "resourceRules" SET "match" = 'COUNTRY' WHERE "match" = 'GEOIP'`);
await db.execute(sql` await db.execute(sql`
CREATE TABLE "accessAuditLog" ( CREATE TABLE "accessAuditLog" (

View File

@@ -15,7 +15,7 @@ export default async function migration() {
db.transaction(() => { db.transaction(() => {
db.prepare( db.prepare(
`UPDATE resourceRules SET match = "COUNTRY" WHERE match = "GEOIP"` `UPDATE 'resourceRules' SET 'match' = 'COUNTRY' WHERE 'match' = 'GEOIP'`
).run(); ).run();
db.prepare( db.prepare(
@@ -155,7 +155,7 @@ export default async function migration() {
).run(); ).run();
db.prepare( db.prepare(
`INSERT INTO '__new_resources'("resourceId", "resourceGuid", "orgId", "niceId", "name", "subdomain", "fullDomain", "domainId", "ssl", "blockAccess", "sso", "http", "protocol", "proxyPort", "emailWhitelistEnabled", "applyRules", "enabled", "stickySession", "tlsServerName", "setHostHeader", "enableProxy", "skipToIdpId", "headers", "proxyProtocol", "proxyProtocolVersion") SELECT "resourceId", "resourceGuid", "orgId", "niceId", "name", "subdomain", "fullDomain", "domainId", "ssl", "blockAccess", "sso", "http", "protocol", "proxyPort", "emailWhitelistEnabled", "applyRules", "enabled", "stickySession", "tlsServerName", "setHostHeader", "enableProxy", "skipToIdpId", "headers", "proxyProtocol", "proxyProtocolVersion" FROM 'resources';` `INSERT INTO '__new_resources'("resourceId", "resourceGuid", "orgId", "niceId", "name", "subdomain", "fullDomain", "domainId", "ssl", "blockAccess", "sso", "http", "protocol", "proxyPort", "emailWhitelistEnabled", "applyRules", "enabled", "stickySession", "tlsServerName", "setHostHeader", "enableProxy", "skipToIdpId", "headers") SELECT "resourceId", "resourceGuid", "orgId", "niceId", "name", "subdomain", "fullDomain", "domainId", "ssl", "blockAccess", "sso", "http", "protocol", "proxyPort", "emailWhitelistEnabled", "applyRules", "enabled", "stickySession", "tlsServerName", "setHostHeader", "enableProxy", "skipToIdpId", "headers" FROM 'resources';`
).run(); ).run();
db.prepare(`DROP TABLE 'resources';`).run(); db.prepare(`DROP TABLE 'resources';`).run();
db.prepare( db.prepare(

View File

@@ -12,93 +12,98 @@ import { useDomain } from "@app/contexts/domainContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
export default function DomainSettingsPage() { export default function DomainSettingsPage() {
const { domain, orgId } = useDomain(); const { domain, orgId } = useDomain();
const router = useRouter(); const router = useRouter();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [restartingDomains, setRestartingDomains] = useState<Set<string>>(new Set()); const [restartingDomains, setRestartingDomains] = useState<Set<string>>(
const t = useTranslations(); new Set()
);
const t = useTranslations();
const { env } = useEnvContext();
const refreshData = async () => { const refreshData = async () => {
setIsRefreshing(true); setIsRefreshing(true);
try { try {
await new Promise((resolve) => setTimeout(resolve, 200)); await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh(); router.refresh();
} catch { } catch {
toast({ toast({
title: t("error"), title: t("error"),
description: t("refreshError"), description: t("refreshError"),
variant: "destructive", variant: "destructive"
}); });
} finally { } finally {
setIsRefreshing(false); setIsRefreshing(false);
}
};
const restartDomain = async (domainId: string) => {
setRestartingDomains((prev) => new Set(prev).add(domainId));
try {
await api.post(`/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;
});
}
};
if (!domain) {
return null;
} }
};
const restartDomain = async (domainId: string) => { const isRestarting = restartingDomains.has(domain.domainId);
setRestartingDomains((prev) => new Set(prev).add(domainId));
try {
await api.post(`/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;
});
}
};
if (!domain) { return (
return null; <>
} <div className="flex justify-between">
<SettingsSectionTitle
const isRestarting = restartingDomains.has(domain.domainId); title={domain.baseDomain}
description={t("domainSettingDescription")}
return ( />
<> {env.flags.usePangolinDns && (
<div className="flex justify-between"> <Button
<SettingsSectionTitle variant="outline"
title={domain.baseDomain} onClick={() => restartDomain(domain.domainId)}
description={t("domainSettingDescription")} disabled={isRestarting}
/> >
<Button {isRestarting ? (
variant="outline" <>
onClick={() => restartDomain(domain.domainId)} <RefreshCw
disabled={isRestarting} className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
> />
{isRestarting ? ( {t("restarting", { fallback: "Restarting..." })}
<> </>
<RefreshCw ) : (
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} <>
/> <RefreshCw
{t("restarting", { fallback: "Restarting..." })} className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
</> />
) : ( {t("restart", { fallback: "Restart" })}
<> </>
<RefreshCw )}
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} </Button>
/> )}
{t("restart", { fallback: "Restart" })} </div>
</> <div className="space-y-6">
)} <DomainInfoCard orgId={orgId} domainId={domain.domainId} />
</Button> </div>
</div> </>
<div className="space-y-6"> );
<DomainInfoCard orgId={orgId} domainId={domain.domainId} /> }
</div>
</>
);
}

View File

@@ -416,7 +416,7 @@ export default function GeneralPage() {
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-full"> <DropdownMenuContent>
{LOG_RETENTION_OPTIONS.filter( {LOG_RETENTION_OPTIONS.filter(
(option) => { (option) => {
if ( if (
@@ -627,243 +627,256 @@ export default function GeneralPage() {
</form> </form>
</Form> </Form>
{build === "saas" && <AuthPageSettings ref={authPageSettingsRef} />} {build !== "oss" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("securitySettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("securitySettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SecurityFeaturesAlert />
{/* Security Settings Section */} <SettingsSectionForm>
<SettingsSection> <Form {...form}>
<SettingsSectionHeader> <form
<SettingsSectionTitle> onSubmit={form.handleSubmit(onSubmit)}
{t("securitySettings")} className="space-y-4"
</SettingsSectionTitle> id="security-settings-form"
<SettingsSectionDescription> >
{t("securitySettingsDescription")} <FormField
</SettingsSectionDescription> control={form.control}
</SettingsSectionHeader> name="requireTwoFactor"
<SettingsSectionBody> render={({ field }) => {
<SecurityFeaturesAlert /> const isDisabled =
isSecurityFeatureDisabled();
<SettingsSectionForm> return (
<Form {...form}> <FormItem className="col-span-2">
<form <div className="flex items-center gap-2">
onSubmit={form.handleSubmit(onSubmit)} <FormControl>
className="space-y-4" <SwitchInput
id="security-settings-form" id="require-two-factor"
> defaultChecked={
<FormField field.value ||
control={form.control} false
name="requireTwoFactor" }
render={({ field }) => { label={t(
const isDisabled = "requireTwoFactorForAllUsers"
isSecurityFeatureDisabled(); )}
disabled={
isDisabled
}
onCheckedChange={(
val
) => {
if (
!isDisabled
) {
form.setValue(
"requireTwoFactor",
val
);
}
}}
/>
</FormControl>
</div>
<FormMessage />
<FormDescription>
{t(
"requireTwoFactorDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="maxSessionLengthHours"
render={({ field }) => {
const isDisabled =
isSecurityFeatureDisabled();
return ( return (
<FormItem className="col-span-2"> <FormItem className="col-span-2">
<div className="flex items-center gap-2"> <FormLabel>
{t("maxSessionLength")}
</FormLabel>
<FormControl> <FormControl>
<SwitchInput <Select
id="require-two-factor" value={
defaultChecked={ field.value?.toString() ||
field.value || "null"
false
} }
label={t( onValueChange={(
"requireTwoFactorForAllUsers" value
)}
disabled={
isDisabled
}
onCheckedChange={(
val
) => { ) => {
if ( if (
!isDisabled !isDisabled
) { ) {
const numValue =
value ===
"null"
? null
: parseInt(
value,
10
);
form.setValue( form.setValue(
"requireTwoFactor", "maxSessionLengthHours",
val numValue
); );
} }
}} }}
/> disabled={
isDisabled
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectSessionLength"
)}
/>
</SelectTrigger>
<SelectContent>
{SESSION_LENGTH_OPTIONS.map(
(
option
) => (
<SelectItem
key={
option.value ===
null
? "null"
: option.value.toString()
}
value={
option.value ===
null
? "null"
: option.value.toString()
}
>
{t(
option.labelKey
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl> </FormControl>
</div>
<FormMessage />
<FormDescription>
{t(
"requireTwoFactorDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="maxSessionLengthHours"
render={({ field }) => {
const isDisabled =
isSecurityFeatureDisabled();
return (
<FormItem className="col-span-2">
<FormLabel>
{t("maxSessionLength")}
</FormLabel>
<FormControl>
<Select
value={
field.value?.toString() ||
"null"
}
onValueChange={(
value
) => {
if (!isDisabled) {
const numValue =
value ===
"null"
? null
: parseInt(
value,
10
);
form.setValue(
"maxSessionLengthHours",
numValue
);
}
}}
disabled={isDisabled}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectSessionLength"
)}
/>
</SelectTrigger>
<SelectContent>
{SESSION_LENGTH_OPTIONS.map(
(option) => (
<SelectItem
key={
option.value ===
null
? "null"
: option.value.toString()
}
value={
option.value ===
null
? "null"
: option.value.toString()
}
>
{t(
option.labelKey
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"maxSessionLengthDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="passwordExpiryDays"
render={({ field }) => {
const isDisabled =
isSecurityFeatureDisabled();
return (
<FormItem className="col-span-2">
<FormLabel>
{t("passwordExpiryDays")}
</FormLabel>
<FormControl>
<Select
value={
field.value?.toString() ||
"null"
}
onValueChange={(
value
) => {
if (!isDisabled) {
const numValue =
value ===
"null"
? null
: parseInt(
value,
10
);
form.setValue(
"passwordExpiryDays",
numValue
);
}
}}
disabled={isDisabled}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectPasswordExpiry"
)}
/>
</SelectTrigger>
<SelectContent>
{PASSWORD_EXPIRY_OPTIONS.map(
(option) => (
<SelectItem
key={
option.value ===
null
? "null"
: option.value.toString()
}
value={
option.value ===
null
? "null"
: option.value.toString()
}
>
{t(
option.labelKey
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<FormMessage /> <FormMessage />
{t( <FormDescription>
"editPasswordExpiryDescription" {t(
)} "maxSessionLengthDescription"
</FormDescription> )}
</FormItem> </FormDescription>
); </FormItem>
}} );
/> }}
</form> />
</Form> <FormField
</SettingsSectionForm> control={form.control}
</SettingsSectionBody> name="passwordExpiryDays"
</SettingsSection> render={({ field }) => {
const isDisabled =
isSecurityFeatureDisabled();
return (
<FormItem className="col-span-2">
<FormLabel>
{t(
"passwordExpiryDays"
)}
</FormLabel>
<FormControl>
<Select
value={
field.value?.toString() ||
"null"
}
onValueChange={(
value
) => {
if (
!isDisabled
) {
const numValue =
value ===
"null"
? null
: parseInt(
value,
10
);
form.setValue(
"passwordExpiryDays",
numValue
);
}
}}
disabled={
isDisabled
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectPasswordExpiry"
)}
/>
</SelectTrigger>
<SelectContent>
{PASSWORD_EXPIRY_OPTIONS.map(
(
option
) => (
<SelectItem
key={
option.value ===
null
? "null"
: option.value.toString()
}
value={
option.value ===
null
? "null"
: option.value.toString()
}
>
{t(
option.labelKey
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<FormMessage />
{t(
"editPasswordExpiryDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
{build === "saas" && <AuthPageSettings ref={authPageSettingsRef} />} {build === "saas" && <AuthPageSettings ref={authPageSettingsRef} />}

View File

@@ -18,9 +18,15 @@ type Props = {
records: DNSRecordRow[]; records: DNSRecordRow[];
domainId: string; domainId: string;
isRefreshing?: boolean; isRefreshing?: boolean;
type: string | null;
}; };
export default function DNSRecordsTable({ records, domainId, isRefreshing }: Props) { export default function DNSRecordsTable({
records,
domainId,
isRefreshing,
type
}: Props) {
const t = useTranslations(); const t = useTranslations();
const columns: ColumnDef<DNSRecordRow>[] = [ const columns: ColumnDef<DNSRecordRow>[] = [
@@ -28,56 +34,31 @@ export default function DNSRecordsTable({ records, domainId, isRefreshing }: Pro
accessorKey: "baseDomain", accessorKey: "baseDomain",
header: ({ column }) => { header: ({ column }) => {
return ( return (
<div <div>{t("recordName", { fallback: "Record name" })}</div>
>
{t("recordName", { fallback: "Record name" })}
</div>
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
const baseDomain = row.original.baseDomain; const baseDomain = row.original.baseDomain;
return ( return <div>{baseDomain || "-"}</div>;
<div>
{baseDomain || "-"}
</div>
);
} }
}, },
{ {
accessorKey: "recordType", accessorKey: "recordType",
header: ({ column }) => { header: ({ column }) => {
return ( return <div>{t("type")}</div>;
<div
>
{t("type")}
</div>
);
}, },
cell: ({ row }) => { cell: ({ row }) => {
const type = row.original.recordType; const type = row.original.recordType;
return ( return <div className="">{type}</div>;
<div className="">
{type}
</div>
);
} }
}, },
{ {
accessorKey: "ttl", accessorKey: "ttl",
header: ({ column }) => { header: ({ column }) => {
return ( return <div>{t("TTL")}</div>;
<div
>
{t("TTL")}
</div>
);
}, },
cell: ({ row }) => { cell: ({ row }) => {
return ( return <div>{t("auto")}</div>;
<div>
{t("auto")}
</div>
);
} }
}, },
{ {
@@ -87,44 +68,39 @@ export default function DNSRecordsTable({ records, domainId, isRefreshing }: Pro
}, },
cell: ({ row }) => { cell: ({ row }) => {
const value = row.original.value; const value = row.original.value;
return ( return <div>{value}</div>;
<div>
{value}
</div>
);
} }
}, },
{ {
accessorKey: "verified", accessorKey: "verified",
header: ({ column }) => { header: ({ column }) => {
return ( return <div>{t("status")}</div>;
<div
>
{t("status")}
</div>
);
}, },
cell: ({ row }) => { cell: ({ row }) => {
const verified = row.original.verified; const verified = row.original.verified;
return ( return verified ? (
verified ? ( type === "wildcard" ? (
<Badge variant="green">{t("verified")}</Badge> <Badge variant="outlinePrimary">
) : ( {t("manual", { fallback: "Manual" })}
<Badge variant="yellow">
{t("pending", { fallback: "Pending" })}
</Badge> </Badge>
) : (
<Badge variant="green">{t("verified")}</Badge>
) )
) : (
<Badge variant="yellow">
{t("pending", { fallback: "Pending" })}
</Badge>
); );
} }
} }
]; ];
return ( return (
<DNSRecordsDataTable <DNSRecordsDataTable
columns={columns} columns={columns}
data={records} data={records}
isRefreshing={isRefreshing} isRefreshing={isRefreshing}
type={type}
/> />
); );
} }

View File

@@ -29,7 +29,8 @@ import {
import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import Link from "next/link";
import { build } from "@server/build";
type TabFilter = { type TabFilter = {
id: string; id: string;
@@ -55,6 +56,7 @@ type DNSRecordsDataTableProps<TData, TValue> = {
defaultTab?: string; defaultTab?: string;
persistPageSize?: boolean | string; persistPageSize?: boolean | string;
defaultPageSize?: number; defaultPageSize?: number;
type?: string | null;
}; };
export function DNSRecordsDataTable<TData, TValue>({ export function DNSRecordsDataTable<TData, TValue>({
@@ -68,7 +70,7 @@ export function DNSRecordsDataTable<TData, TValue>({
defaultSort, defaultSort,
tabs, tabs,
defaultTab, defaultTab,
type
}: DNSRecordsDataTableProps<TData, TValue>) { }: DNSRecordsDataTableProps<TData, TValue>) {
const t = useTranslations(); const t = useTranslations();
@@ -97,12 +99,9 @@ export function DNSRecordsDataTable<TData, TValue>({
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel()
}); });
return ( return (
<div className="container mx-auto max-w-12xl"> <div className="container mx-auto max-w-12xl">
<Card> <Card>
@@ -112,28 +111,31 @@ export function DNSRecordsDataTable<TData, TValue>({
<h1 className="font-bold">{t("dnsRecord")}</h1> <h1 className="font-bold">{t("dnsRecord")}</h1>
<Badge variant="secondary">{t("required")}</Badge> <Badge variant="secondary">{t("required")}</Badge>
</div> </div>
<Button <Link href="https://docs.pangolin.net/self-host/dns-and-networking">
variant="outline" <Button variant="outline">
> <ExternalLink className="h-4 w-4 mr-1" />
<ExternalLink className="h-4 w-4 mr-1"/> {t("howToAddRecords")}
{t("howToAddRecords")} </Button>
</Button> </Link>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-secondary dark:bg-transparent"> <TableRow
key={headerGroup.id}
className="bg-secondary dark:bg-transparent"
>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<TableHead key={header.id}> <TableHead key={header.id}>
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
header.column.columnDef header.column.columnDef
.header, .header,
header.getContext() header.getContext()
)} )}
</TableHead> </TableHead>
))} ))}
</TableRow> </TableRow>

View File

@@ -10,7 +10,16 @@ import {
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useDomainContext } from "@app/hooks/useDomainContext"; import { useDomainContext } from "@app/hooks/useDomainContext";
import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "./Settings"; import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "./Settings";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { import {
Form, Form,
@@ -21,7 +30,13 @@ import {
FormMessage, FormMessage,
FormDescription FormDescription
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "./ui/select";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import z from "zod"; import z from "zod";
@@ -51,7 +66,6 @@ function toPunycode(domain: string): string {
} }
} }
function isValidDomainFormat(domain: string): boolean { function isValidDomainFormat(domain: string): boolean {
const unicodeRegex = /^(?!:\/\/)([^\s.]+\.)*[^\s.]+$/; const unicodeRegex = /^(?!:\/\/)([^\s.]+\.)*[^\s.]+$/;
@@ -59,9 +73,9 @@ function isValidDomainFormat(domain: string): boolean {
return false; return false;
} }
const parts = domain.split('.'); const parts = domain.split(".");
for (const part of parts) { for (const part of parts) {
if (part.length === 0 || part.startsWith('-') || part.endsWith('-')) { if (part.length === 0 || part.startsWith("-") || part.endsWith("-")) {
return false; return false;
} }
if (part.length > 63) { if (part.length > 63) {
@@ -94,8 +108,10 @@ const certResolverOptions = [
{ id: "custom", title: "Custom Resolver" } { id: "custom", title: "Custom Resolver" }
]; ];
export default function DomainInfoCard({
export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps) { orgId,
domainId
}: DomainInfoCardProps) {
const { domain, updateDomain } = useDomainContext(); const { domain, updateDomain } = useDomainContext();
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -111,21 +127,24 @@ export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps)
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
baseDomain: "", baseDomain: "",
type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns", type:
certResolver: domain.certResolver ?? "", build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns",
certResolver: domain.certResolver,
preferWildcardCert: false preferWildcardCert: false
} }
}); });
useEffect(() => { useEffect(() => {
if (domain.domainId) { if (domain.domainId) {
const certResolverValue = domain.certResolver && domain.certResolver.trim() !== "" const certResolverValue =
? domain.certResolver domain.certResolver && domain.certResolver.trim() !== ""
: null; ? domain.certResolver
: null;
form.reset({ form.reset({
baseDomain: domain.baseDomain || "", baseDomain: domain.baseDomain || "",
type: (domain.type as "ns" | "cname" | "wildcard") || "wildcard", type:
(domain.type as "ns" | "cname" | "wildcard") || "wildcard",
certResolver: certResolverValue, certResolver: certResolverValue,
preferWildcardCert: domain.preferWildcardCert || false preferWildcardCert: domain.preferWildcardCert || false
}); });
@@ -170,7 +189,9 @@ export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps)
if (!orgId || !domainId) { if (!orgId || !domainId) {
toast({ toast({
title: t("error"), title: t("error"),
description: t("orgOrDomainIdMissing", { fallback: "Organization or Domain ID is missing" }), description: t("orgOrDomainIdMissing", {
fallback: "Organization or Domain ID is missing"
}),
variant: "destructive" variant: "destructive"
}); });
return; return;
@@ -179,7 +200,11 @@ export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps)
setSaveLoading(true); setSaveLoading(true);
try { try {
const response = await api.patch( if (!values.certResolver) {
values.certResolver = null;
}
await api.patch(
`/org/${orgId}/domain/${domainId}`, `/org/${orgId}/domain/${domainId}`,
{ {
certResolver: values.certResolver, certResolver: values.certResolver,
@@ -195,7 +220,9 @@ export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps)
toast({ toast({
title: t("success"), title: t("success"),
description: t("domainSettingsUpdated", { fallback: "Domain settings updated successfully" }), description: t("domainSettingsUpdated", {
fallback: "Domain settings updated successfully"
}),
variant: "default" variant: "default"
}); });
} catch (error) { } catch (error) {
@@ -222,30 +249,36 @@ export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps)
} }
}; };
return ( return (
<> <>
<Alert> <Alert>
<AlertDescription> <AlertDescription>
<InfoSections cols={3}> <InfoSections cols={3}>
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>{t("type")}</InfoSectionTitle>
{t("type")}
</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
<span> <span>
{getTypeDisplay(domain.type ? domain.type : "")} {getTypeDisplay(
domain.type ? domain.type : ""
)}
</span> </span>
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>{t("status")}</InfoSectionTitle>
{t("status")}
</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{domain.verified ? ( {domain.verified ? (
<Badge variant="green">{t("verified")}</Badge> domain.type === "wildcard" ? (
<Badge variant="outlinePrimary">
{t("manual", {
fallback: "Manual"
})}
</Badge>
) : (
<Badge variant="green">
{t("verified")}
</Badge>
)
) : ( ) : (
<Badge variant="yellow"> <Badge variant="yellow">
{t("pending", { fallback: "Pending" })} {t("pending", { fallback: "Pending" })}
@@ -257,20 +290,13 @@ export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps)
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{loadingRecords ? ( <DNSRecordsTable
<div className="space-y-4"> domainId={domain.domainId}
{t("loadingDNSRecords", { fallback: "Loading DNS Records..." })} records={dnsRecords}
</div> isRefreshing={isRefreshing}
) : ( type={domain.type}
<DNSRecordsTable />
domainId={domain.domainId}
records={dnsRecords}
isRefreshing={isRefreshing}
/>
)
}
{/* Domain Settings - Only show for wildcard domains */}
{domain.type === "wildcard" && ( {domain.type === "wildcard" && (
<SettingsContainer> <SettingsContainer>
<SettingsSection> <SettingsSection>
@@ -294,33 +320,73 @@ export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps)
name="certResolver" name="certResolver"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t("certResolver")}</FormLabel> <FormLabel>
{t("certResolver")}
</FormLabel>
<FormControl> <FormControl>
<Select <Select
value={ value={
field.value === null ? "default" : field.value ===
(field.value === "" || (field.value && field.value !== "default")) ? "custom" : null
"default" ? "default"
: field.value ===
"" ||
(field.value &&
field.value !==
"default")
? "custom"
: "default"
} }
onValueChange={(val) => { onValueChange={(
if (val === "default") { val
field.onChange(null); ) => {
} else if (val === "custom") { if (
field.onChange(""); val ===
"default"
) {
field.onChange(
null
);
} else if (
val ===
"custom"
) {
field.onChange(
""
);
} else { } else {
field.onChange(val); field.onChange(
val
);
} }
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={t("selectCertResolver")} /> <SelectValue
placeholder={t(
"selectCertResolver"
)}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{certResolverOptions.map((opt) => ( {certResolverOptions.map(
<SelectItem key={opt.id} value={opt.id}> (
{opt.title} opt
</SelectItem> ) => (
))} <SelectItem
key={
opt.id
}
value={
opt.id
}
>
{
opt.title
}
</SelectItem>
)
)}
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>
@@ -328,8 +394,10 @@ export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps)
</FormItem> </FormItem>
)} )}
/> />
{form.watch("certResolver") !== null && {form.watch("certResolver") !==
form.watch("certResolver") !== "default" && ( null &&
form.watch("certResolver") !==
"default" && (
<FormField <FormField
control={form.control} control={form.control}
name="certResolver" name="certResolver"
@@ -337,9 +405,22 @@ export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps)
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input
placeholder={t("enterCustomResolver")} placeholder={t(
value={field.value || ""} "enterCustomResolver"
onChange={(e) => field.onChange(e.target.value)} )}
value={
field.value ||
""
}
onChange={(
e
) =>
field.onChange(
e
.target
.value
)
}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -348,25 +429,39 @@ export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps)
/> />
)} )}
{form.watch("certResolver") !== null && {form.watch("certResolver") !==
form.watch("certResolver") !== "default" && ( null &&
form.watch("certResolver") !==
"default" && (
<FormField <FormField
control={form.control} control={form.control}
name="preferWildcardCert" name="preferWildcardCert"
render={({ field: switchField }) => ( render={({
field: switchField
}) => (
<FormItem className="items-center space-y-2 mt-4"> <FormItem className="items-center space-y-2 mt-4">
<FormControl> <FormControl>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
checked={switchField.value} checked={
onCheckedChange={switchField.onChange} switchField.value
}
onCheckedChange={
switchField.onChange
}
/> />
<FormLabel>{t("preferWildcardCert")}</FormLabel> <FormLabel>
{t(
"preferWildcardCert"
)}
</FormLabel>
</div> </div>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t("preferWildcardCertDescription")} {t(
"preferWildcardCertDescription"
)}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -394,4 +489,4 @@ export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps)
)} )}
</> </>
); );
} }