Merge branch 'dev' into feat/labels-on-sites-and-resources

This commit is contained in:
Fred KISSIE
2026-05-14 18:15:14 +02:00
62 changed files with 2319 additions and 311 deletions

View File

@@ -1,5 +1,6 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import OrgRolesTagField from "@app/components/OrgRolesTagField";
import {
@@ -25,6 +26,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { useUserContext } from "@app/hooks/useUserContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build";
@@ -32,7 +34,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UserType } from "@server/types/UserTypes";
import { useTranslations } from "next-intl";
import { useParams } from "next/navigation";
import { useActionState, useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -42,13 +44,15 @@ const accessControlsFormSchema = z.object({
roles: z.array(
z.object({
id: z.string(),
text: z.string()
text: z.string(),
isAdmin: z.boolean().optional()
})
)
});
export default function AccessControlsPage() {
const { orgUser: user, updateOrgUser } = userOrgUserContext();
const { user: sessionUser } = useUserContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
@@ -72,7 +76,8 @@ export default function AccessControlsPage() {
autoProvisioned: user.autoProvisioned || false,
roles: (user.roles ?? []).map((r) => ({
id: r.roleId.toString(),
text: r.name
text: r.name,
isAdmin: r.isAdmin === true
}))
}
});
@@ -84,7 +89,8 @@ export default function AccessControlsPage() {
"roles",
(user.roles ?? []).map((r) => ({
id: r.roleId.toString(),
text: r.name
text: r.name,
isAdmin: r.isAdmin === true
}))
);
form.setValue("autoProvisioned", user.autoProvisioned || false);
@@ -95,11 +101,11 @@ export default function AccessControlsPage() {
? t("singleRolePerUserPlanNotice")
: t("singleRolePerUserEditionNotice");
const [, action, isSubmitting] = useActionState(onSubmit, null);
async function onSubmit() {
const isValid = await form.trigger();
if (!isValid) return;
const [isSaving, setIsSaving] = useState(false);
const [confirmRemoveOwnAdminOpen, setConfirmRemoveOwnAdminOpen] =
useState(false);
async function executeSave() {
const values = form.getValues();
if (values.roles.length === 0) {
@@ -111,6 +117,7 @@ export default function AccessControlsPage() {
return;
}
setIsSaving(true);
try {
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const updateRoleRequest = supportsMultipleRolesPerUser
@@ -130,7 +137,8 @@ export default function AccessControlsPage() {
roleIds,
roles: values.roles.map((r) => ({
roleId: parseInt(r.id, 10),
name: r.text
name: r.text,
isAdmin: r.isAdmin === true
})),
autoProvisioned: values.autoProvisioned
});
@@ -149,11 +157,61 @@ export default function AccessControlsPage() {
t("accessRoleErrorAddDescription")
)
});
} finally {
setIsSaving(false);
}
}
async function handleAccessControlsSubmit(e: React.FormEvent) {
e.preventDefault();
const isValid = await form.trigger();
if (!isValid) return;
const values = form.getValues();
if (values.roles.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
description: t("accessRoleSelectPlease")
});
return;
}
const willHaveAdminRole = values.roles.some(
(r) => r.isAdmin === true
);
const isRemovingOwnAdmin =
sessionUser.userId === user.userId &&
user.isAdmin &&
!willHaveAdminRole;
if (isRemovingOwnAdmin) {
setConfirmRemoveOwnAdminOpen(true);
return;
}
await executeSave();
}
return (
<SettingsContainer>
<ConfirmDeleteDialog
open={confirmRemoveOwnAdminOpen}
setOpen={setConfirmRemoveOwnAdminOpen}
title={t("removeOwnAdminRoleConfirmTitle")}
dialog={
<div className="space-y-2">
<p>{t("removeOwnAdminRoleConfirmDescription")}</p>
</div>
}
buttonText={t("removeOwnAdminRoleConfirmButton")}
string={t("removeOwnAdminRoleConfirmPhrase")}
onConfirm={executeSave}
/>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -168,7 +226,7 @@ export default function AccessControlsPage() {
<SettingsSectionForm>
<Form {...form}>
<form
action={action}
onSubmit={(e) => void handleAccessControlsSubmit(e)}
className="space-y-4"
id="access-controls-form"
>
@@ -237,8 +295,8 @@ export default function AccessControlsPage() {
<SettingsSectionFooter>
<Button
type="submit"
loading={isSubmitting}
disabled={isSubmitting}
loading={isSaving}
disabled={isSaving}
form="access-controls-form"
>
{t("accessControlsSubmit")}

View File

@@ -645,6 +645,12 @@ export default function ConnectionLogsPage() {
</span>
)}
</div>*/}
<div>
<strong>Client Endpoint:</strong>{" "}
<span className="font-mono">
{row.clientEndpoint ?? "-"}
</span>
</div>
<div>
<strong>Site:</strong> {row.siteName ?? "-"}
{row.siteNiceId && (

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback } from "react";
import { useParams } from "next/navigation";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -22,7 +22,18 @@ import {
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import { Switch } from "@app/components/ui/switch";
import { Globe, MoreHorizontal, Plus } from "lucide-react";
import {
Globe,
MoreHorizontal,
Plus,
AlertCircle,
ChevronDown
} from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { AxiosResponse } from "axios";
import { build } from "@server/build";
import Image from "next/image";
@@ -38,7 +49,10 @@ import {
HttpDestinationCredenza,
parseHttpConfig
} from "@app/components/HttpDestinationCredenza";
import { S3DestinationCredenza } from "@app/components/S3DestinationCredenza";
import {
S3DestinationCredenza,
parseS3Config
} from "@app/components/S3DestinationCredenza";
import { DatadogDestinationCredenza } from "@app/components/DatadogDestinationCredenza";
import { useTranslations } from "next-intl";
@@ -64,6 +78,42 @@ interface DestinationCardProps {
disabled?: boolean;
}
function getDestinationDisplay(destination: Destination): {
name: string;
typeLabel: string;
detail: string;
icon: React.ReactNode;
} {
if (destination.type === "s3") {
const cfg = parseS3Config(destination.config);
const detail = cfg.bucket
? `s3://${cfg.bucket}${cfg.prefix ? `/${cfg.prefix.replace(/^\/+/, "")}` : ""}`
: "";
return {
name: cfg.name,
typeLabel: "Amazon S3",
detail,
icon: (
<Image
src="/third-party/s3.png"
alt="Amazon S3"
width={16}
height={16}
className="rounded-sm"
/>
)
};
}
// Default: HTTP
const cfg = parseHttpConfig(destination.config);
return {
name: cfg.name,
typeLabel: "HTTP",
detail: cfg.url,
icon: <Globe className="h-3.5 w-3.5 text-black" />
};
}
function DestinationCard({
destination,
onToggle,
@@ -73,25 +123,25 @@ function DestinationCard({
disabled = false
}: DestinationCardProps) {
const t = useTranslations();
const cfg = parseHttpConfig(destination.config);
const { name, typeLabel, detail, icon } =
getDestinationDisplay(destination);
return (
<div className="relative flex flex-col rounded-lg border bg-card text-card-foreground p-5 gap-3">
{/* Top row: icon + name/type + toggle */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
{/* Squirkle icon: gray outer → white inner → black globe */}
<div className="shrink-0 flex items-center justify-center w-10 h-10 rounded-2xl bg-muted">
<div className="flex items-center justify-center w-6 h-6 rounded-xl bg-white shadow-sm">
<Globe className="h-3.5 w-3.5 text-black" />
{icon}
</div>
</div>
<div className="min-w-0">
<p className="font-semibold text-sm leading-tight truncate">
{cfg.name || t("streamingUnnamedDestination")}
{name || t("streamingUnnamedDestination")}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
HTTP
{typeLabel}
</p>
</div>
</div>
@@ -105,15 +155,40 @@ function DestinationCard({
/>
</div>
{/* URL preview */}
{/* Detail preview (URL for HTTP, s3:// path for S3) */}
<p className="text-xs text-muted-foreground truncate">
{cfg.url || (
{detail || (
<span className="italic">
{t("streamingNoUrlConfigured")}
</span>
)}
</p>
{/* Error indicator */}
{destination.lastError && (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 text-left cursor-pointer rounded px-0 hover:opacity-75 transition-opacity"
>
<AlertCircle className="h-3.5 w-3.5 text-destructive shrink-0" />
<p className="text-xs text-destructive">
{t("streamingLastSyncError")}
</p>
<ChevronDown className="h-3 w-3 text-destructive shrink-0 ml-auto" />
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="end"
className="w-80 text-xs break-words"
>
{destination.lastError}
</PopoverContent>
</Popover>
)}
{/* Footer: edit button + three-dots menu */}
<div className="mt-auto pt-5 flex gap-2">
<Button
@@ -485,7 +560,7 @@ export default function StreamingDestinationsPage() {
if (!v) setDeleteTarget(null);
}}
string={
parseHttpConfig(deleteTarget.config).name ||
getDestinationDisplay(deleteTarget).name ||
t("streamingDeleteDialogThisDestination")
}
title={t("streamingDeleteTitle")}
@@ -493,7 +568,7 @@ export default function StreamingDestinationsPage() {
<p>
{t("streamingDeleteDialogAreYouSure")}{" "}
<span>
{parseHttpConfig(deleteTarget.config).name ||
{getDestinationDisplay(deleteTarget).name ||
t("streamingDeleteDialogThisDestination")}
</span>
{t("streamingDeleteDialogPermanentlyRemoved")}

View File

@@ -84,6 +84,7 @@ import {
AlertTriangle,
CircleCheck,
CircleX,
ExternalLink,
Info,
Plus,
Settings
@@ -961,13 +962,18 @@ function ProxyResourceTargetsForm({
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && (
<p className="text-sm text-muted-foreground mt-3 flex items-start gap-1.5">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>
Round robin routing will not work between
sites that are not connected to the same
node, but failover will work.
</span>
<p className="text-sm text-muted-foreground mt-3">
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
.
</p>
)}
</SettingsSectionBody>

View File

@@ -82,8 +82,8 @@ import { AxiosResponse } from "axios";
import {
CircleCheck,
CircleX,
ExternalLink,
Info,
InfoIcon,
Plus,
Settings,
SquareArrowOutUpRight
@@ -1425,16 +1425,22 @@ export default function Page() {
</Button>
</div>
)}
{build === "enterprise" &&
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && (
<p className="text-sm text-muted-foreground mt-3 flex items-start gap-1.5">
<InfoIcon className="h-4 w-4 shrink-0 mt-0.5" />
<span>
Round robin routing will not work between
sites that are not connected to the same
node, but failover will work.
</span>
new Set(targets.map((t) => t.siteId)).size >
1 && (
<p className="text-sm text-muted-foreground mt-3">
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
.
</p>
)}
</SettingsSectionBody>

View File

@@ -13,6 +13,8 @@ import DomainCertForm from "@app/components/DomainCertForm";
import { build } from "@server/build";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import { Lock } from "lucide-react";
import { Badge } from "@app/components/ui/badge";
interface DomainPageClientProps {
initialDomain: GetDomainResponse;
@@ -49,7 +51,22 @@ export default function DomainPageClient({
<>
<div className="flex justify-between">
<SettingsSectionTitle
title={domain.baseDomain}
title={
<span className="flex items-center gap-2">
{domain.baseDomain}
{domain.configManaged && (
<Badge
variant="secondary"
className="flex items-center gap-1 text-sm font-normal"
>
<Lock className="h-3 w-3" />
{t("configManaged", {
fallback: "Config Managed"
})}
</Badge>
)}
</span>
}
description={t("domainSettingDescription")}
/>
{env.flags.usePangolinDns && domain.failed ? (
@@ -90,4 +107,4 @@ export default function DomainPageClient({
</div>
</>
);
}
}

View File

@@ -16,6 +16,7 @@ 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 { Lock } from "lucide-react";
import { useTranslations } from "next-intl";
import CreateDomainForm from "@app/components/CreateDomainForm";
import { useToast } from "@app/hooks/useToast";
@@ -72,7 +73,11 @@ export default function DomainsTable({ domains, orgId }: Props) {
const { org } = useOrgContext();
const queryClient = useQueryClient();
const { data: rawDomains, isRefetching, refetch } = useQuery({
const {
data: rawDomains,
isRefetching,
refetch
} = useQuery({
...orgQueries.domains({ orgId }),
initialData: domains as any,
refetchInterval: durationToMs(10, "seconds")
@@ -80,12 +85,15 @@ export default function DomainsTable({ domains, orgId }: Props) {
const tableData = useMemo(
() =>
(rawDomains ?? []).map((d) => ({
...d,
baseDomain: toUnicode(d.baseDomain),
type: d.type ?? "",
errorMessage: d.errorMessage ?? null
} as DomainRow)),
(rawDomains ?? []).map(
(d) =>
({
...d,
baseDomain: toUnicode(d.baseDomain),
type: d.type ?? "",
errorMessage: d.errorMessage ?? null
}) as DomainRow
),
[rawDomains]
);
@@ -198,12 +206,17 @@ export default function DomainsTable({ domains, orgId }: Props) {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="red" className="cursor-help">
<Badge
variant="red"
className="cursor-help"
>
{t("failed", { fallback: "Failed" })}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="break-words">{errorMessage}</p>
<p className="break-words">
{errorMessage}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -220,12 +233,17 @@ export default function DomainsTable({ domains, orgId }: Props) {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="yellow" className="cursor-help">
<Badge
variant="yellow"
className="cursor-help"
>
{t("pending")}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="break-words">{errorMessage}</p>
<p className="break-words">
{errorMessage}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -253,6 +271,25 @@ export default function DomainsTable({ domains, orgId }: Props) {
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const domain = row.original;
return (
<span className="flex items-center gap-2">
{domain.baseDomain}
{domain.configManaged && (
<Badge
variant="secondary"
className="flex items-center gap-1 text-xs font-normal"
>
<Lock className="h-3 w-3" />
{t("configManaged", {
fallback: "Config Managed"
})}
</Badge>
)}
</span>
);
}
},
...(env.env.flags.usePangolinDns ? [typeColumn] : []),
@@ -283,16 +320,18 @@ export default function DomainsTable({ domains, orgId }: Props) {
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedDomain(domain);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
{!domain.configManaged && (
<DropdownMenuItem
onClick={() => {
setSelectedDomain(domain);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{domain.failed && (
@@ -315,7 +354,9 @@ export default function DomainsTable({ domains, orgId }: Props) {
href={`/${orgId}/settings/domains/${domain.domainId}`}
>
<Button variant={"outline"}>
{t("edit")}
{domain.configManaged
? t("view", { fallback: "View" })
: t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>

View File

@@ -19,7 +19,8 @@ import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Textarea } from "@app/components/ui/textarea";
import { Checkbox } from "@app/components/ui/checkbox";
import { Plus, X } from "lucide-react";
import { Plus, X, AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
@@ -56,6 +57,8 @@ export interface Destination {
sendActionLogs: boolean;
sendConnectionLogs: boolean;
sendRequestLogs: boolean;
lastError: string | null;
lastErrorAt: number | null;
createdAt: number;
updatedAt: number;
}
@@ -122,9 +125,7 @@ function HeadersEditor({ headers, onChange }: HeadersEditorProps) {
/>
<Input
value={h.value}
onChange={(e) =>
updateRow(i, "value", e.target.value)
}
onChange={(e) => updateRow(i, "value", e.target.value)}
placeholder={t("httpDestHeaderValuePlaceholder")}
className="flex-1"
/>
@@ -200,10 +201,7 @@ export function HttpDestinationCredenza({
if (!raw) return null;
try {
const parsed = new URL(raw);
if (
parsed.protocol !== "http:" &&
parsed.protocol !== "https:"
) {
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return t("httpDestUrlErrorHttpRequired");
}
if (build === "saas" && parsed.protocol !== "https:") {
@@ -216,9 +214,7 @@ export function HttpDestinationCredenza({
})();
const isValid =
cfg.name.trim() !== "" &&
cfg.url.trim() !== "" &&
urlError === null;
cfg.name.trim() !== "" && cfg.url.trim() !== "" && urlError === null;
async function handleSave() {
if (!isValid) return;
@@ -253,10 +249,7 @@ export function HttpDestinationCredenza({
title: editing
? t("httpDestUpdateFailed")
: t("httpDestCreateFailed"),
description: formatAxiosError(
e,
t("streamingUnexpectedError")
)
description: formatAxiosError(e, t("streamingUnexpectedError"))
});
} finally {
setSaving(false);
@@ -280,6 +273,14 @@ export function HttpDestinationCredenza({
</CredenzaHeader>
<CredenzaBody>
{editing?.lastError && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="break-words">
{editing.lastError}
</AlertDescription>
</Alert>
)}
<HorizontalTabs
clientSide
items={[
@@ -357,7 +358,9 @@ export function HttpDestinationCredenza({
{t("httpDestAuthNoneTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthNoneDescription")}
{t(
"httpDestAuthNoneDescription"
)}
</p>
</div>
</div>
@@ -375,15 +378,21 @@ export function HttpDestinationCredenza({
htmlFor="auth-bearer"
className="cursor-pointer font-medium"
>
{t("httpDestAuthBearerTitle")}
{t(
"httpDestAuthBearerTitle"
)}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthBearerDescription")}
{t(
"httpDestAuthBearerDescription"
)}
</p>
</div>
{cfg.authType === "bearer" && (
<Input
placeholder={t("httpDestAuthBearerPlaceholder")}
placeholder={t(
"httpDestAuthBearerPlaceholder"
)}
value={
cfg.bearerToken ?? ""
}
@@ -411,15 +420,21 @@ export function HttpDestinationCredenza({
htmlFor="auth-basic"
className="cursor-pointer font-medium"
>
{t("httpDestAuthBasicTitle")}
{t(
"httpDestAuthBasicTitle"
)}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthBasicDescription")}
{t(
"httpDestAuthBasicDescription"
)}
</p>
</div>
{cfg.authType === "basic" && (
<Input
placeholder={t("httpDestAuthBasicPlaceholder")}
placeholder={t(
"httpDestAuthBasicPlaceholder"
)}
value={
cfg.basicCredentials ??
""
@@ -448,16 +463,22 @@ export function HttpDestinationCredenza({
htmlFor="auth-custom"
className="cursor-pointer font-medium"
>
{t("httpDestAuthCustomTitle")}
{t(
"httpDestAuthCustomTitle"
)}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthCustomDescription")}
{t(
"httpDestAuthCustomDescription"
)}
</p>
</div>
{cfg.authType === "custom" && (
<div className="flex gap-2">
<Input
placeholder={t("httpDestAuthCustomHeaderNamePlaceholder")}
placeholder={t(
"httpDestAuthCustomHeaderNamePlaceholder"
)}
value={
cfg.customHeaderName ??
""
@@ -472,7 +493,9 @@ export function HttpDestinationCredenza({
className="flex-1"
/>
<Input
placeholder={t("httpDestAuthCustomHeaderValuePlaceholder")}
placeholder={t(
"httpDestAuthCustomHeaderValuePlaceholder"
)}
value={
cfg.customHeaderValue ??
""
@@ -593,10 +616,14 @@ export function HttpDestinationCredenza({
htmlFor="fmt-json-array"
className="cursor-pointer font-medium"
>
{t("httpDestFormatJsonArrayTitle")}
{t(
"httpDestFormatJsonArrayTitle"
)}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestFormatJsonArrayDescription")}
{t(
"httpDestFormatJsonArrayDescription"
)}
</p>
</div>
</div>
@@ -616,7 +643,9 @@ export function HttpDestinationCredenza({
{t("httpDestFormatNdjsonTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestFormatNdjsonDescription")}
{t(
"httpDestFormatNdjsonDescription"
)}
</p>
</div>
</div>
@@ -636,7 +665,9 @@ export function HttpDestinationCredenza({
{t("httpDestFormatSingleTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestFormatSingleDescription")}
{t(
"httpDestFormatSingleDescription"
)}
</p>
</div>
</div>
@@ -717,7 +748,9 @@ export function HttpDestinationCredenza({
{t("httpDestConnectionLogsTitle")}
</label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestConnectionLogsDescription")}
{t(
"httpDestConnectionLogsDescription"
)}
</p>
</div>
</div>
@@ -739,7 +772,9 @@ export function HttpDestinationCredenza({
{t("httpDestRequestLogsTitle")}
</label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestRequestLogsDescription")}
{t(
"httpDestRequestLogsDescription"
)}
</p>
</div>
</div>
@@ -764,10 +799,12 @@ export function HttpDestinationCredenza({
loading={saving}
disabled={!isValid || saving}
>
{editing ? t("httpDestSaveChanges") : t("httpDestCreateDestination")}
{editing
? t("httpDestSaveChanges")
: t("httpDestCreateDestination")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
}

View File

@@ -99,7 +99,7 @@ export default function InviteStatusCard({
router.push(redirectUrl);
} else if (!user && type === "not_logged_in") {
const redirectUrl = email
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}`
? `/auth/login?redirect=/invite?token=${tokenParam}&user=${email}`
: `/auth/login?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl);
} else {
@@ -113,7 +113,7 @@ export default function InviteStatusCard({
async function goToLogin() {
await api.post("/auth/logout", {});
const redirectUrl = email
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}`
? `/auth/login?redirect=/invite?token=${tokenParam}&user=${email}`
: `/auth/login?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl);
}

View File

@@ -16,6 +16,7 @@ import Link from "next/link";
import { replacePlaceholder } from "@app/lib/replacePlaceholder";
import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv";
import { build } from "@server/build";
type OrgLoginPageProps = {
loginPage: LoadLoginPageResponse | undefined;
@@ -52,19 +53,21 @@ export default async function OrgLoginPage({
const t = await getTranslations();
return (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{env.branding.appName || "Pangolin"}
</Link>
</span>
</div>
{build !== "enterprise" || !env.branding.hidePoweredBy ? (
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{env.branding.appName || "Pangolin"}
</Link>
</span>
</div>
) : null}
<Card className="w-full max-w-md">
<CardHeader>
{branding?.logoUrl && (

View File

@@ -375,7 +375,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
{!accessDenied ? (
<div>
{isUnlocked() && build === "enterprise" ? (
!env.branding.resourceAuthPage?.hidePoweredBy && (
!env.branding.resourceAuthPage?.hidePoweredBy &&
!env.branding.hidePoweredBy && (
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import {
Credenza,
CredenzaBody,
@@ -12,13 +12,64 @@ import {
CredenzaTitle
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import { ContactSalesBanner } from "@app/components/ContactSalesBanner";
import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label";
import { Switch } from "@app/components/ui/switch";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Checkbox } from "@app/components/ui/checkbox";
import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl";
import { Destination } from "@app/components/HttpDestinationCredenza";
// ── Types ──────────────────────────────────────────────────────────────────────
export type S3PayloadFormat = "json_array" | "ndjson" | "csv";
export interface S3Config {
name: string;
accessKeyId: string;
secretAccessKey: string;
region: string;
bucket: string;
prefix: string;
endpoint: string;
format: S3PayloadFormat;
gzip: boolean;
}
// ── Helpers ────────────────────────────────────────────────────────────────────
export const defaultS3Config = (): S3Config => ({
name: "",
accessKeyId: "",
secretAccessKey: "",
region: "us-east-1",
bucket: "",
prefix: "",
endpoint: "",
format: "json_array",
gzip: false
});
export function parseS3Config(raw: string): S3Config {
try {
return { ...defaultS3Config(), ...JSON.parse(raw) };
} catch {
return defaultS3Config();
}
}
// ── Component ──────────────────────────────────────────────────────────────────
export interface S3DestinationCredenzaProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: any;
editing: Destination | null;
orgId: string;
onSaved: () => void;
}
@@ -28,18 +79,84 @@ export function S3DestinationCredenza({
onOpenChange,
editing,
orgId,
onSaved,
onSaved
}: S3DestinationCredenzaProps) {
const api = createApiClient(useEnvContext());
const t = useTranslations();
const [saving, setSaving] = useState(false);
const [cfg, setCfg] = useState<S3Config>(defaultS3Config());
const [sendAccessLogs, setSendAccessLogs] = useState(false);
const [sendActionLogs, setSendActionLogs] = useState(false);
const [sendConnectionLogs, setSendConnectionLogs] = useState(false);
const [sendRequestLogs, setSendRequestLogs] = useState(false);
useEffect(() => {
if (open) {
setCfg(editing ? parseS3Config(editing.config) : defaultS3Config());
setSendAccessLogs(editing?.sendAccessLogs ?? false);
setSendActionLogs(editing?.sendActionLogs ?? false);
setSendConnectionLogs(editing?.sendConnectionLogs ?? false);
setSendRequestLogs(editing?.sendRequestLogs ?? false);
}
}, [open, editing]);
const update = (patch: Partial<S3Config>) =>
setCfg((prev) => ({ ...prev, ...patch }));
const isValid =
cfg.name.trim() !== "" &&
cfg.accessKeyId.trim() !== "" &&
cfg.secretAccessKey.trim() !== "" &&
cfg.region.trim() !== "" &&
cfg.bucket.trim() !== "";
async function handleSave() {
if (!isValid) return;
setSaving(true);
try {
const payload = {
type: "s3",
config: JSON.stringify(cfg),
sendAccessLogs,
sendActionLogs,
sendConnectionLogs,
sendRequestLogs
};
if (editing) {
await api.post(
`/org/${orgId}/event-streaming-destination/${editing.destinationId}`,
payload
);
toast({ title: t("s3DestUpdatedSuccess") });
} else {
await api.put(
`/org/${orgId}/event-streaming-destination`,
payload
);
toast({ title: t("s3DestCreatedSuccess") });
}
onSaved();
onOpenChange(false);
} catch (e) {
toast({
variant: "destructive",
title: editing
? t("s3DestUpdateFailed")
: t("s3DestCreateFailed"),
description: formatAxiosError(e, t("streamingUnexpectedError"))
});
} finally {
setSaving(false);
}
}
return (
<Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent className="sm:max-w-2xl">
<CredenzaHeader>
<CredenzaTitle>
{editing
? t("S3DestEditTitle")
: t("S3DestAddTitle")}
{editing ? t("S3DestEditTitle") : t("S3DestAddTitle")}
</CredenzaTitle>
<CredenzaDescription>
{editing
@@ -49,13 +166,375 @@ export function S3DestinationCredenza({
</CredenzaHeader>
<CredenzaBody>
<ContactSalesBanner />
{editing?.lastError && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="break-words">
{editing.lastError}
</AlertDescription>
</Alert>
)}
<HorizontalTabs
clientSide
items={[
{ title: t("s3DestTabSettings"), href: "" },
{ title: t("s3DestTabFormat"), href: "" },
{ title: t("httpDestTabLogs"), href: "" }
]}
>
{/* ── Settings tab ────────────────────────────── */}
<div className="space-y-6 mt-4 p-1">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="s3-name">
{t("s3DestNameLabel")}
</Label>
<Input
id="s3-name"
placeholder={t("s3DestNamePlaceholder")}
value={cfg.name}
onChange={(e) =>
update({ name: e.target.value })
}
/>
</div>
{/* AWS Access Key ID */}
<div className="space-y-2">
<Label htmlFor="s3-access-key-id">
{t("s3DestAccessKeyIdLabel")}
</Label>
<Input
id="s3-access-key-id"
placeholder="AKIAIOSFODNN7EXAMPLE"
value={cfg.accessKeyId}
onChange={(e) =>
update({
accessKeyId: e.target.value
})
}
autoComplete="off"
/>
</div>
{/* AWS Secret Access Key */}
<div className="space-y-2">
<Label htmlFor="s3-secret-key">
{t("s3DestSecretAccessKeyLabel")}
</Label>
<Input
id="s3-secret-key"
type="password"
placeholder={t(
"s3DestSecretAccessKeyPlaceholder"
)}
value={cfg.secretAccessKey}
onChange={(e) =>
update({
secretAccessKey: e.target.value
})
}
autoComplete="new-password"
/>
</div>
{/* Region */}
<div className="space-y-2">
<Label htmlFor="s3-region">
{t("s3DestRegionLabel")}
</Label>
<Input
id="s3-region"
placeholder="us-east-1"
value={cfg.region}
onChange={(e) =>
update({ region: e.target.value })
}
/>
</div>
{/* Bucket */}
<div className="space-y-2">
<Label htmlFor="s3-bucket">
{t("s3DestBucketLabel")}
</Label>
<Input
id="s3-bucket"
placeholder="my-logs-bucket"
value={cfg.bucket}
onChange={(e) =>
update({ bucket: e.target.value })
}
/>
</div>
{/* Prefix */}
<div className="space-y-2">
<Label htmlFor="s3-prefix">
{t("s3DestPrefixLabel")}
</Label>
<Input
id="s3-prefix"
placeholder="pangolin/logs"
value={cfg.prefix}
onChange={(e) =>
update({ prefix: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
{t("s3DestPrefixDescription")}
</p>
</div>
{/* Custom endpoint (optional for S3-compatible storage) */}
<div className="space-y-2">
<Label htmlFor="s3-endpoint">
{t("s3DestEndpointLabel")}
</Label>
<Input
id="s3-endpoint"
placeholder="https://s3.example.com"
value={cfg.endpoint}
onChange={(e) =>
update({ endpoint: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
{t("s3DestEndpointDescription")}
</p>
</div>
</div>
{/* ── Format tab ───────────────────────────────── */}
<div className="space-y-6 mt-4 p-1">
{/* Gzip compression toggle */}
<div className="flex items-start gap-3 rounded-md border p-3">
<Switch
id="s3-gzip"
checked={cfg.gzip}
onCheckedChange={(v) => update({ gzip: v })}
className="mt-0.5"
/>
<div>
<Label
htmlFor="s3-gzip"
className="cursor-pointer font-medium"
>
{t("s3DestGzipLabel")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("s3DestGzipDescription")}
</p>
</div>
</div>
{/* Payload format selector */}
<div className="space-y-3">
<div>
<label className="font-medium block">
{t("s3DestFormatTitle")}
</label>
<p className="text-sm text-muted-foreground mt-0.5">
{t("s3DestFormatDescription")}
</p>
</div>
<RadioGroup
value={cfg.format}
onValueChange={(v) =>
update({
format: v as S3PayloadFormat
})
}
className="gap-2"
>
{/* JSON Array */}
<label className="flex items-start gap-3 rounded-md border p-3 cursor-pointer has-[:checked]:border-primary has-[:checked]:bg-primary/5">
<RadioGroupItem
value="json_array"
className="mt-0.5"
/>
<div>
<p className="text-sm font-medium leading-none">
{t(
"httpDestFormatJsonArrayTitle"
)}
</p>
<p className="text-xs text-muted-foreground mt-1">
{t(
"s3DestFormatJsonArrayDescription"
)}
</p>
</div>
</label>
{/* NDJSON */}
<label className="flex items-start gap-3 rounded-md border p-3 cursor-pointer has-[:checked]:border-primary has-[:checked]:bg-primary/5">
<RadioGroupItem
value="ndjson"
className="mt-0.5"
/>
<div>
<p className="text-sm font-medium leading-none">
{t("httpDestFormatNdjsonTitle")}
</p>
<p className="text-xs text-muted-foreground mt-1">
{t(
"s3DestFormatNdjsonDescription"
)}
</p>
</div>
</label>
{/* CSV */}
<label className="flex items-start gap-3 rounded-md border p-3 cursor-pointer has-[:checked]:border-primary has-[:checked]:bg-primary/5">
<RadioGroupItem
value="csv"
className="mt-0.5"
/>
<div>
<p className="text-sm font-medium leading-none">
{t("s3DestFormatCsvTitle")}
</p>
<p className="text-xs text-muted-foreground mt-1">
{t(
"s3DestFormatCsvDescription"
)}
</p>
</div>
</label>
</RadioGroup>
</div>
</div>
{/* ── Logs tab ──────────────────────────────────── */}
<div className="space-y-6 mt-4 p-1">
<div>
<label className="font-medium block">
{t("httpDestLogTypesTitle")}
</label>
<p className="text-sm text-muted-foreground mt-0.5">
{t("httpDestLogTypesDescription")}
</p>
</div>
<div className="space-y-3">
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="s3-log-access"
checked={sendAccessLogs}
onCheckedChange={(v) =>
setSendAccessLogs(v === true)
}
className="mt-0.5"
/>
<div>
<Label
htmlFor="s3-log-access"
className="cursor-pointer font-medium"
>
{t("httpDestAccessLogsTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAccessLogsDescription")}
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="s3-log-action"
checked={sendActionLogs}
onCheckedChange={(v) =>
setSendActionLogs(v === true)
}
className="mt-0.5"
/>
<div>
<Label
htmlFor="s3-log-action"
className="cursor-pointer font-medium"
>
{t("httpDestActionLogsTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestActionLogsDescription")}
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="s3-log-connection"
checked={sendConnectionLogs}
onCheckedChange={(v) =>
setSendConnectionLogs(v === true)
}
className="mt-0.5"
/>
<div>
<Label
htmlFor="s3-log-connection"
className="cursor-pointer font-medium"
>
{t("httpDestConnectionLogsTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t(
"httpDestConnectionLogsDescription"
)}
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="s3-log-request"
checked={sendRequestLogs}
onCheckedChange={(v) =>
setSendRequestLogs(v === true)
}
className="mt-0.5"
/>
<div>
<Label
htmlFor="s3-log-request"
className="cursor-pointer font-medium"
>
{t("httpDestRequestLogsTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t(
"httpDestRequestLogsDescription"
)}
</p>
</div>
</div>
</div>
</div>
</HorizontalTabs>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("cancel")}</Button>
<Button
type="button"
variant="outline"
disabled={saving}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="button"
onClick={handleSave}
loading={saving}
disabled={!isValid || saving}
>
{editing
? t("s3DestSaveChanges")
: t("s3DestCreateDestination")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -61,6 +61,8 @@ export default function ShareLinksTable({
const api = createApiClient(useEnvContext());
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedLink, setSelectedLink] = useState<ShareLinkRow | null>(null);
const [rows, setRows] = useState<ShareLinkRow[]>(shareLinks);
const [isRefreshing, setIsRefreshing] = useState(false);
@@ -92,6 +94,7 @@ export default function ShareLinksTable({
title: t("shareErrorDelete"),
description: formatAxiosError(e, t("shareErrorDeleteMessage"))
});
throw e;
});
const newRows = rows.filter((r) => r.accessTokenId !== id);
@@ -293,9 +296,10 @@ export default function ShareLinksTable({
{/* </DropdownMenu> */}
<Button
variant={"outline"}
onClick={() =>
deleteSharelink(row.original.accessTokenId)
}
onClick={() => {
setSelectedLink(resourceRow);
setIsDeleteModalOpen(true);
}}
>
{t("delete")}
</Button>
@@ -307,6 +311,30 @@ export default function ShareLinksTable({
return (
<>
{selectedLink && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
if (!val) setSelectedLink(null);
}}
dialog={
<div className="space-y-2">
<p>{t("shareQuestionRemove")}</p>
<p>{t("shareMessageRemove")}</p>
</div>
}
buttonText={t("shareDeleteConfirm")}
onConfirm={async () =>
deleteSharelink(selectedLink.accessTokenId)
}
string={
selectedLink.title || selectedLink.resourceName
}
title={t("shareDelete")}
/>
)}
<CreateShareLinkForm
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}

View File

@@ -99,6 +99,14 @@ export default function UsersTable({
];
}, [searchParams.toString()]);
const isRemovingSelf = useMemo(() => {
if (!selectedUser || !user) return false;
return (
`${selectedUser.username}-${selectedUser.idpId}` ===
`${user.username}-${user.idpId}`
);
}, [selectedUser, user]);
function handleFilterChange(
column: string,
value: string | undefined | null
@@ -223,10 +231,7 @@ export default function UsersTable({
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const userRow = row.original;
const isCurrentUser =
`${userRow.username}-${userRow.idpId}` ===
`${user?.username}-${user?.idpId}`;
const isDisabled = userRow.isOwner || isCurrentUser;
const canRemoveFromOrg = !userRow.isOwner;
return (
<div className="flex items-center justify-end">
<div>
@@ -235,7 +240,6 @@ export default function UsersTable({
<Button
variant="ghost"
className="h-8 w-8 p-0"
disabled={isDisabled}
>
<span className="sr-only">
{t("openMenu")}
@@ -247,16 +251,12 @@ export default function UsersTable({
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
className="block w-full"
aria-disabled={isDisabled}
onClick={(e) =>
isDisabled && e.preventDefault()
}
>
<DropdownMenuItem disabled={isDisabled}>
<DropdownMenuItem>
{t("accessUserManage")}
</DropdownMenuItem>
</Link>
{!isDisabled && (
{canRemoveFromOrg && (
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(true);
@@ -271,25 +271,14 @@ export default function UsersTable({
</DropdownMenuContent>
</DropdownMenu>
</div>
{isDisabled ? (
<Button
variant={"outline"}
className="ml-2"
disabled
>
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button variant={"outline"} className="ml-2">
{t("manage")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
) : (
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button variant={"outline"} className="ml-2">
{t("manage")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
)}
</Link>
</div>
);
}
@@ -359,22 +348,45 @@ export default function UsersTable({
}}
dialog={
<div className="space-y-2">
<p>{t("userQuestionOrgRemove")}</p>
<p>{t("userMessageOrgRemove")}</p>
<p>
{t(
isRemovingSelf
? "userQuestionOrgRemoveSelf"
: "userQuestionOrgRemove"
)}
</p>
<p>
{t(
isRemovingSelf
? "userMessageOrgRemoveSelf"
: "userMessageOrgRemove"
)}
</p>
</div>
}
buttonText={t("userRemoveOrgConfirm")}
buttonText={t(
isRemovingSelf
? "userRemoveOrgConfirmSelf"
: "userRemoveOrgConfirm"
)}
warningText={
isRemovingSelf ? t("userRemoveOrgSelfWarning") : undefined
}
onConfirm={async () => startTransition(removeUser)}
string={
selectedUser
? getUserDisplayName({
email: selectedUser.email,
name: selectedUser.name,
username: selectedUser.username
})
: ""
isRemovingSelf
? t("userRemoveOrgConfirmPhraseSelf")
: selectedUser
? getUserDisplayName({
email: selectedUser.email,
name: selectedUser.name,
username: selectedUser.username
})
: ""
}
title={t("userRemoveOrg")}
title={t(
isRemovingSelf ? "userRemoveOrgSelf" : "userRemoveOrg"
)}
/>
<ControlledDataTable

View File

@@ -11,7 +11,7 @@ import { cn } from "@app/lib/cn";
import { CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl";
export type TagValue = { text: string; id: string };
export type TagValue = { text: string; id: string; isAdmin?: boolean };
export type MultiSelectTagsProps<T extends TagValue> = {
emptyPlaceholder?: string;

View File

@@ -6,7 +6,7 @@ import { useDebounce } from "use-debounce";
import { useTranslations } from "next-intl";
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
export type SelectedRole = { id: string; text: string };
export type SelectedRole = { id: string; text: string; isAdmin?: boolean };
export type RolesSelectorProps = {
orgId: string;

View File

@@ -81,6 +81,8 @@ export function pullEnv(): Env {
process.env.BRANDING_HIDE_AUTH_LAYOUT_FOOTER === "true"
? true
: false,
hidePoweredBy:
process.env.BRANDING_HIDE_POWERED_BY === "true" ? true : false,
logo: {
lightPath: process.env.BRANDING_LOGO_LIGHT_PATH as string,
darkPath: process.env.BRANDING_LOGO_DARK_PATH as string,

View File

@@ -41,6 +41,7 @@ export type Env = {
appName?: string;
background_image_path?: string;
hideAuthLayoutFooter?: boolean;
hidePoweredBy?: boolean;
logo?: {
lightPath?: string;
darkPath?: string;