Merge branch 'dev' into resource-policies

This commit is contained in:
Owen
2026-05-12 21:12:40 -07:00
100 changed files with 4604 additions and 1452 deletions

View File

@@ -134,7 +134,9 @@ export default function AlertingRulesTable({
}: AlertingRulesTableProps) {
const router = useRouter();
const t = useTranslations();
const api = createApiClient(useEnvContext());
const envContext = useEnvContext();
const api = createApiClient(envContext);
const { env } = envContext;
const [isRefreshing, startRefresh] = useTransition();
const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.alertingRules);
@@ -426,9 +428,15 @@ export default function AlertingRulesTable({
searchQuery={query}
manualFiltering
manualSorting
onAdd={() => {
router.push(`/${orgId}/settings/alerting/create`);
}}
onAdd={
!env.flags.disableEnterpriseFeatures
? () => {
router.push(
`/${orgId}/settings/alerting/create`
);
}
: undefined
}
onRefresh={refreshList}
isRefreshing={isRefreshing || isFiltering}
addButtonText={t("alertingAddRule")}

View File

@@ -47,6 +47,7 @@ type AutoProvisionConfigWidgetProps = {
roleMappingFieldIdPrefix?: string;
showFreeformRoleNamesHint?: boolean;
autoProvisionSwitchId?: string;
orgId?: string;
};
export default function AutoProvisionConfigWidget({
@@ -67,7 +68,8 @@ export default function AutoProvisionConfigWidget({
showAutoProvisionSwitch = true,
roleMappingFieldIdPrefix = "org-idp-auto-provision",
showFreeformRoleNamesHint = false,
autoProvisionSwitchId = "auto-provision-toggle"
autoProvisionSwitchId = "auto-provision-toggle",
orgId
}: AutoProvisionConfigWidgetProps) {
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
@@ -106,6 +108,7 @@ export default function AutoProvisionConfigWidget({
showFreeformRoleNamesHint={
showFreeformRoleNamesHint
}
orgId={orgId}
roleMappingMode={roleMappingMode}
onRoleMappingModeChange={onRoleMappingModeChange}
roles={roles}

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

@@ -840,12 +840,16 @@ export function InternalResourceForm({
modeCidrKey
)
},
{
value: "http",
label: t(
modeHttpKey
)
}
...(!disableEnterpriseFeatures
? [
{
value: "http" as const,
label: t(
modeHttpKey
)
}
]
: [])
];
return (
<FormItem>
@@ -1125,30 +1129,6 @@ export function InternalResourceForm({
}}
/>
</div>
<FormField
control={form.control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="internal-resource-ssl"
label={t(enableSslLabelKey)}
description={t(
enableSslDescriptionKey
)}
checked={!!field.value}
onCheckedChange={
field.onChange
}
disabled={
httpSectionDisabled
}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex items-start justify-between gap-4">
<FormField
control={form.control}

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

@@ -129,9 +129,7 @@ export function LayoutSidebar({
user.serverAdmin || Boolean(currentOrg?.isOwner || currentOrg?.isAdmin);
const showTrial =
build === "saas" &&
Boolean(orgId) &&
subscriptionContext?.isTrial;
build === "saas" && Boolean(orgId) && subscriptionContext?.isTrial;
return (
<div
@@ -240,11 +238,16 @@ export function LayoutSidebar({
<div className="px-4">
<ProductUpdates isCollapsed={isSidebarCollapsed} />
</div>
) : <div className="mt-0.2"></div>}
) : (
<div className="mt-0.2"></div>
)}
{showTrial && (
<div className="px-4">
<ShowTrialCard isCollapsed={isSidebarCollapsed} />
<ShowTrialCard
isCollapsed={isSidebarCollapsed}
isOwner={Boolean(currentOrg?.isOwner)}
/>
</div>
)}

View File

@@ -40,6 +40,7 @@ import {
TooltipProvider,
TooltipTrigger
} from "@/components/ui/tooltip";
import CopyToClipboard from "@app/components/CopyToClipboard";
// Update Resource type to include site information
type Resource = {
@@ -64,6 +65,8 @@ type SiteResource = {
destination: string;
mode: string;
protocol: string | null;
ssl: boolean;
fullDomain: string | null;
enabled: boolean;
alias: string | null;
aliasAddress: string | null;
@@ -123,6 +126,7 @@ const ResourceFavicon = ({
// Resource Info component
const ResourceInfo = ({ resource }: { resource: Resource }) => {
const t = useTranslations();
const hasAuthMethods =
resource.sso ||
resource.password ||
@@ -141,7 +145,9 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
{/* Site Information */}
{resource.siteName && (
<div>
<div className="text-xs font-medium mb-1.5">Site</div>
<div className="text-xs font-medium mb-1.5">
{t("site")}
</div>
<div className="flex items-center gap-2">
<Combine className="h-4 w-4 text-foreground shrink-0" />
<span className="text-sm">{resource.siteName}</span>
@@ -157,7 +163,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
}
>
<div className="text-xs font-medium mb-1.5">
Authentication Methods
{t("memberPortalAuthMethods")}
</div>
<div className="flex flex-col gap-1.5">
{resource.sso && (
@@ -166,7 +172,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
<Key className="h-3 w-3 text-blue-700 dark:text-blue-300" />
</div>
<span className="text-sm">
Single Sign-On (SSO)
{t("memberPortalSso")}
</span>
</div>
)}
@@ -176,7 +182,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
<KeyRound className="h-3 w-3 text-purple-700 dark:text-purple-300" />
</div>
<span className="text-sm">
Password Protected
{t("memberPortalPasswordProtected")}
</span>
</div>
)}
@@ -185,7 +191,9 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-emerald-50/50 dark:bg-emerald-950/50">
<Fingerprint className="h-3 w-3 text-emerald-700 dark:text-emerald-300" />
</div>
<span className="text-sm">PIN Code</span>
<span className="text-sm">
{t("memberPortalPinCode")}
</span>
</div>
)}
{resource.whitelist && (
@@ -193,7 +201,9 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-amber-50/50 dark:bg-amber-950/50">
<AtSign className="h-3 w-3 text-amber-700 dark:text-amber-300" />
</div>
<span className="text-sm">Email Whitelist</span>
<span className="text-sm">
{t("memberPortalEmailWhitelist")}
</span>
</div>
)}
</div>
@@ -208,7 +218,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
<span className="text-sm text-destructive">
Resource Disabled
{t("memberPortalResourceDisabled")}
</span>
</div>
</div>
@@ -233,6 +243,7 @@ const PaginationControls = ({
totalItems: number;
itemsPerPage: number;
}) => {
const t = useTranslations();
const startItem = (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
@@ -241,7 +252,11 @@ const PaginationControls = ({
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-8">
<div className="text-sm text-muted-foreground">
Showing {startItem}-{endItem} of {totalItems} resources
{t("memberPortalShowingResources", {
start: startItem,
end: endItem,
total: totalItems
})}
</div>
<div className="flex items-center gap-2">
@@ -253,7 +268,7 @@ const PaginationControls = ({
className="gap-1"
>
<ChevronLeft className="h-4 w-4" />
Previous
{t("memberPortalPrevious")}
</Button>
<div className="flex items-center gap-1">
@@ -309,7 +324,7 @@ const PaginationControls = ({
disabled={currentPage === totalPages}
className="gap-1"
>
Next
{t("memberPortalNext")}
<ChevronRight className="h-4 w-4" />
</Button>
</div>
@@ -389,13 +404,11 @@ export default function MemberResourcesPortal({
response.data.data.siteResources || []
);
} else {
setError("Failed to load resources");
setError(t("memberPortalFailedToLoad"));
}
} catch (err) {
console.error("Error fetching user resources:", err);
setError(
"Failed to load resources. Please check your connection and try again."
);
setError(t("memberPortalFailedToLoadDescription"));
} finally {
setLoading(false);
setRefreshing(false);
@@ -526,8 +539,8 @@ export default function MemberResourcesPortal({
return (
<div className="container mx-auto max-w-12xl">
<SettingsSectionTitle
title="Resources"
description="Resources you have access to in this organization"
title={t("memberPortalTitle")}
description={t("memberPortalDescription")}
/>
{/* Search and Sort Controls - Skeleton */}
@@ -554,8 +567,8 @@ export default function MemberResourcesPortal({
return (
<div className="container mx-auto max-w-12xl">
<SettingsSectionTitle
title="Resources"
description="Resources you have access to in this organization"
title={t("memberPortalTitle")}
description={t("memberPortalDescription")}
/>
<Card>
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
@@ -563,7 +576,7 @@ export default function MemberResourcesPortal({
<AlertCircle className="h-16 w-16 text-destructive/60" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-3">
Unable to Load Resources
{t("memberPortalUnableToLoad")}
</h3>
<p className="text-muted-foreground max-w-lg text-base mb-6">
{error}
@@ -574,7 +587,7 @@ export default function MemberResourcesPortal({
className="gap-2"
>
<RefreshCw className="h-4 w-4" />
Try Again
{t("memberPortalTryAgain")}
</Button>
</CardContent>
</Card>
@@ -585,8 +598,8 @@ export default function MemberResourcesPortal({
return (
<div className="container mx-auto max-w-12xl">
<SettingsSectionTitle
title="Resources"
description="Resources you have access to in this organization"
title={t("memberPortalTitle")}
description={t("memberPortalDescription")}
/>
{/* Search and Sort Controls with Refresh */}
@@ -595,7 +608,7 @@ export default function MemberResourcesPortal({
{/* Search */}
<div className="relative w-full sm:w-80">
<Input
placeholder="Search resources..."
placeholder={t("resourcesSearch")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 bg-card"
@@ -607,26 +620,28 @@ export default function MemberResourcesPortal({
<div className="w-full sm:w-36">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="bg-card">
<SelectValue placeholder="Sort by..." />
<SelectValue
placeholder={t("memberPortalSortBy")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="name-asc">
Name A-Z
{t("memberPortalSortNameAsc")}
</SelectItem>
<SelectItem value="name-desc">
Name Z-A
{t("memberPortalSortNameDesc")}
</SelectItem>
<SelectItem value="domain-asc">
Domain A-Z
{t("memberPortalSortDomainAsc")}
</SelectItem>
<SelectItem value="domain-desc">
Domain Z-A
{t("memberPortalSortDomainDesc")}
</SelectItem>
<SelectItem value="status-enabled">
Enabled First
{t("memberPortalSortEnabledFirst")}
</SelectItem>
<SelectItem value="status-disabled">
Disabled First
{t("memberPortalSortDisabledFirst")}
</SelectItem>
</SelectContent>
</Select>
@@ -644,7 +659,7 @@ export default function MemberResourcesPortal({
<RefreshCw
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
/>
Refresh
{t("memberPortalRefresh")}
</Button>
</div>
@@ -663,13 +678,15 @@ export default function MemberResourcesPortal({
</div>
<h3 className="text-2xl font-semibold text-foreground mb-3">
{searchQuery
? "No Resources Found"
: "No Resources Available"}
? t("memberPortalNoResourcesFound")
: t("memberPortalNoResourcesAvailable")}
</h3>
<p className="text-muted-foreground max-w-lg text-base mb-6">
{searchQuery
? `No resources match "${searchQuery}". Try adjusting your search terms or clearing the search to see all resources.`
: "You don't have access to any resources yet. Contact your administrator to get access to resources you need."}
? t("memberPortalNoResourcesMatchSearch", {
query: searchQuery
})
: t("memberPortalNoResourcesAccess")}
</p>
<div className="flex flex-col sm:flex-row gap-3">
{searchQuery ? (
@@ -678,7 +695,7 @@ export default function MemberResourcesPortal({
variant="outline"
className="gap-2"
>
Clear Search
{t("memberPortalClearSearch")}
</Button>
) : (
<Button
@@ -690,7 +707,7 @@ export default function MemberResourcesPortal({
<RefreshCw
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
/>
Refresh Resources
{t("memberPortalRefreshResources")}
</Button>
)}
</div>
@@ -704,11 +721,12 @@ export default function MemberResourcesPortal({
<div className="mb-4">
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Globe className="h-5 w-5" />
Public Resources
{t("memberPortalPublicResources")}
</h3>
<p className="text-sm text-muted-foreground mt-1">
Web applications and services accessible via
browser
{t(
"memberPortalPublicResourcesDescription"
)}
</p>
</div>
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
@@ -768,9 +786,12 @@ export default function MemberResourcesPortal({
resource.domain
);
toast({
title: "Copied to clipboard",
description:
"Resource URL has been copied to your clipboard.",
title: t(
"memberPortalCopiedToClipboard"
),
description: t(
"memberPortalCopiedUrlDescription"
),
duration: 2000
});
}}
@@ -791,7 +812,7 @@ export default function MemberResourcesPortal({
disabled={!resource.enabled}
>
<ExternalLink className="h-3.5 w-3.5 mr-2" />
Open Resource
{t("memberPortalOpenResource")}
</Button>
</div>
</Card>
@@ -806,11 +827,12 @@ export default function MemberResourcesPortal({
<div className="mb-4">
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Combine className="h-5 w-5" />
Private Resources
{t("memberPortalPrivateResources")}
</h3>
<p className="text-sm text-muted-foreground mt-1">
Internal network resources accessible via
client
{t(
"memberPortalPrivateResourcesDescription"
)}
</p>
</div>
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
@@ -843,11 +865,16 @@ export default function MemberResourcesPortal({
<InfoPopup>
<div className="space-y-2 text-sm">
<div className="text-xs font-medium mb-1.5">
Resource Details
{t(
"memberPortalResourceDetails"
)}
</div>
<div>
<span className="font-medium">
Mode:
{t(
"memberPortalMode"
)}
:
</span>
<span className="ml-2 text-muted-foreground capitalize">
{
@@ -858,7 +885,10 @@ export default function MemberResourcesPortal({
{siteResource.protocol && (
<div>
<span className="font-medium">
Protocol:
{t(
"protocol"
)}
:
</span>
<span className="ml-2 text-muted-foreground uppercase">
{
@@ -869,7 +899,10 @@ export default function MemberResourcesPortal({
)}
<div>
<span className="font-medium">
Destination:
{t(
"memberPortalDestination"
)}
:
</span>
<span className="ml-2 text-muted-foreground">
{
@@ -880,7 +913,10 @@ export default function MemberResourcesPortal({
{siteResource.alias && (
<div>
<span className="font-medium">
Alias:
{t(
"memberPortalAlias"
)}
:
</span>
<span className="ml-2 text-muted-foreground">
{
@@ -891,14 +927,21 @@ export default function MemberResourcesPortal({
)}
<div>
<span className="font-medium">
Status:
{t(
"status"
)}
:
</span>
<span
className={`ml-2 ${siteResource.enabled ? "text-green-600" : "text-red-600"}`}
>
{siteResource.enabled
? "Enabled"
: "Disabled"}
? t(
"enabled"
)
: t(
"disabled"
)}
</span>
</div>
</div>
@@ -907,7 +950,14 @@ export default function MemberResourcesPortal({
</div>
<div className="mt-3">
{siteResource.alias ? (
{siteResource.mode === "http" &&
siteResource.fullDomain ? (
/* HTTP mode - show as clickable link */
<CopyToClipboard
text={`${siteResource.ssl ? "https" : (siteResource.protocol ?? "http")}://${siteResource.fullDomain}`}
isLink={true}
/>
) : siteResource.alias ? (
<>
{/* Alias as primary */}
<div className="flex items-center gap-2 mb-1">
@@ -925,9 +975,13 @@ export default function MemberResourcesPortal({
siteResource.alias!
);
toast({
title: "Copied to clipboard",
title: t(
"memberPortalCopiedToClipboard"
),
description:
"Resource alias has been copied to your clipboard.",
t(
"memberPortalCopiedAliasDescription"
),
duration: 2000
});
}}
@@ -959,9 +1013,13 @@ export default function MemberResourcesPortal({
siteResource.destination
);
toast({
title: "Copied to clipboard",
title: t(
"memberPortalCopiedToClipboard"
),
description:
"Resource destination has been copied to your clipboard.",
t(
"memberPortalCopiedDestinationDescription"
),
duration: 2000
});
}}
@@ -973,10 +1031,34 @@ export default function MemberResourcesPortal({
</div>
</div>
<div className="p-6 pt-0 mt-auto">
<div className="p-6 pt-0 mt-auto space-y-2">
{siteResource.mode === "http" &&
siteResource.fullDomain ? (
<Button
onClick={() =>
window.open(
`${siteResource.ssl ? "https" : (siteResource.protocol ?? "http")}://${siteResource.fullDomain}`,
"_blank"
)
}
className="w-full h-9"
variant="outline"
size="sm"
disabled={
!siteResource.enabled
}
>
<ExternalLink className="h-3.5 w-3.5 mr-2" />
{t(
"memberPortalOpenResource"
)}
</Button>
) : null}
<div className="flex items-center justify-center py-2 px-4 bg-muted/50 rounded text-sm text-muted-foreground">
<Combine className="h-3.5 w-3.5 mr-2" />
Requires Client Connection
{t(
"memberPortalRequiresClientConnection"
)}
</div>
</div>
</Card>

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

@@ -96,6 +96,7 @@ export type ResourceRow = {
targets?: TargetHealth[];
health?: "healthy" | "degraded" | "unhealthy" | "unknown";
sites: ResourceSiteRow[];
wildcard?: boolean;
};
function StatusIcon({
@@ -565,10 +566,14 @@ export default function ProxyResourcesTable({
/>
) : null}
<div className="">
<CopyToClipboard
text={resourceRow.domain}
isLink={true}
/>
{!resourceRow.wildcard ? (
<CopyToClipboard
text={resourceRow.domain}
isLink={true}
/>
) : (
<span>{resourceRow.domain}</span>
)}
</div>
</div>
);

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

@@ -17,7 +17,6 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build";
import { RolesSelector } from "./roles-selector";
import { useParams } from "next/navigation";
export type RoleMappingRoleOption = {
roleId: number;
@@ -40,6 +39,8 @@ export type RoleMappingConfigFieldsProps = {
fieldIdPrefix?: string;
/** When true, show extra hint for global default policies (no org role list). */
showFreeformRoleNamesHint?: boolean;
/** Org ID to use for role lookup. Falls back to URL params when not provided. */
orgId?: string;
};
export default function RoleMappingConfigFields({
@@ -55,14 +56,13 @@ export default function RoleMappingConfigFields({
rawExpression,
onRawExpressionChange,
fieldIdPrefix = "role-mapping",
showFreeformRoleNamesHint = false
showFreeformRoleNamesHint = false,
orgId
}: RoleMappingConfigFieldsProps) {
const t = useTranslations();
const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus();
const { orgId } = useParams();
const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac);
const showSingleRoleDisclaimer =
!env.flags.disableEnterpriseFeatures &&
@@ -95,6 +95,10 @@ export default function RoleMappingConfigFields({
}
}, [supportsMultipleRolesPerUser, fixedRoleNames, onFixedRoleNamesChange]);
const [fixedRolesActiveTagIndex, setFixedRolesActiveTagIndex] = useState<
number | null
>(null);
const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`;
const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`;
const rawRadioId = `${fieldIdPrefix}-raw-expression-mode`;
@@ -161,38 +165,94 @@ export default function RoleMappingConfigFields({
{roleMappingMode === "fixedRoles" && (
<div className="space-y-2 min-w-0 max-w-full">
<RolesSelector
selectedRoles={fixedRoleNames.map((name) => ({
id: name,
text: name
}))}
mapRolesByName
orgId={orgId as string}
onSelectRoles={(nextTags) => {
let names = [
...new Set(nextTags.map((tag) => tag.text))
];
{restrictToOrgRoles ? (
<RolesSelector
selectedRoles={fixedRoleNames.map((name) => ({
id: name,
text: name
}))}
mapRolesByName
orgId={orgId as string}
onSelectRoles={(nextTags) => {
let names = [
...new Set(nextTags.map((tag) => tag.text))
];
if (!supportsMultipleRolesPerUser) {
if (
names.length === 0 &&
fixedRoleNames.length > 0
) {
onFixedRoleNamesChange([
fixedRoleNames[
fixedRoleNames.length - 1
]!
]);
return;
if (!supportsMultipleRolesPerUser) {
if (
names.length === 0 &&
fixedRoleNames.length > 0
) {
onFixedRoleNamesChange([
fixedRoleNames[
fixedRoleNames.length - 1
]!
]);
return;
}
if (names.length > 1) {
names = [names[names.length - 1]!];
}
}
if (names.length > 1) {
names = [names[names.length - 1]!];
}
}
onFixedRoleNamesChange(names);
}}
/>
onFixedRoleNamesChange(names);
}}
/>
) : (
<TagInput
tags={fixedRoleNames.map((name) => ({
id: name,
text: name
}))}
setTags={(nextTags) => {
const prev = fixedRoleNames.map((name) => ({
id: name,
text: name
}));
const next =
typeof nextTags === "function"
? nextTags(prev)
: nextTags;
let names = [
...new Set(next.map((tag) => tag.text))
];
if (!supportsMultipleRolesPerUser) {
if (
names.length === 0 &&
fixedRoleNames.length > 0
) {
onFixedRoleNamesChange([
fixedRoleNames[
fixedRoleNames.length - 1
]!
]);
return;
}
if (names.length > 1) {
names = [names[names.length - 1]!];
}
}
onFixedRoleNamesChange(names);
}}
activeTagIndex={fixedRolesActiveTagIndex}
setActiveTagIndex={setFixedRolesActiveTagIndex}
placeholder={t(
"roleMappingAssignRolesPlaceholderFreeform"
)}
enableAutocomplete={false}
autocompleteOptions={roleOptions}
restrictTagsToAutocompleteOptions={false}
allowDuplicates={false}
sortTags={true}
size="sm"
styleClasses={{
inlineTagsContainer: "min-w-0 max-w-full"
}}
/>
)}
<FormDescription>
{showFreeformRoleNamesHint
? t("roleMappingFixedRolesDescriptionDefaultPolicy")
@@ -242,6 +302,7 @@ export default function RoleMappingConfigFields({
showFreeformRoleNamesHint={
showFreeformRoleNamesHint
}
orgId={orgId}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
@@ -318,7 +379,8 @@ function BuilderRuleRow({
supportsMultipleRolesPerUser,
showRemoveButton,
onChange,
onRemove
onRemove,
orgId
}: {
rule: MappingBuilderRule;
roleOptions: Tag[];
@@ -330,10 +392,10 @@ function BuilderRuleRow({
showRemoveButton: boolean;
onChange: (rule: MappingBuilderRule) => void;
onRemove: () => void;
orgId?: string;
}) {
const t = useTranslations();
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const { orgId } = useParams();
return (
<div

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

@@ -17,9 +17,11 @@ import { useTranslations } from "next-intl";
const TRIAL_DURATION_DAYS = 10;
export default function ShowTrialCard({
isCollapsed
isCollapsed,
isOwner = false
}: {
isCollapsed?: boolean;
isOwner?: boolean;
}) {
const context = useSubscriptionStatusContext();
const params = useParams();
@@ -32,53 +34,55 @@ export default function ShowTrialCard({
const now = Date.now();
const remainingMs = trialExpiresAt - now;
const remainingDays = Math.max(0, Math.ceil(remainingMs / (1000 * 60 * 60 * 24)));
const remainingDays = Math.max(
0,
Math.ceil(remainingMs / (1000 * 60 * 60 * 24))
);
const totalMs = TRIAL_DURATION_DAYS * 24 * 60 * 60 * 1000;
const progressPct = Math.min(100, Math.max(0, ((now - (trialExpiresAt - totalMs)) / totalMs) * 100));
const progressPct = Math.min(
100,
Math.max(0, ((now - (trialExpiresAt - totalMs)) / totalMs) * 100)
);
// Inverted: full bar at start, drains to empty as trial ends
const displayPct = 100 - progressPct;
const billingHref = orgId ? `/${orgId}/settings/billing` : "/";
if (isCollapsed) {
return (
const icon = (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={billingHref}
className="flex items-center justify-center rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 transition-colors"
>
<span className="flex items-center justify-center rounded-md p-2 text-muted-foreground">
<ClockIcon className="h-4 w-4 flex-none" />
</Link>
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>
{remainingDays === 0
? t("trialExpired")
: t("trialDaysLeftShort", { days: remainingDays })}
: t("trialDaysLeftShort", {
days: remainingDays
})}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
if (isOwner) {
return <Link href={billingHref}>{icon}</Link>;
}
return icon;
}
return (
<Link
href={billingHref}
className={cn(
"group cursor-pointer block",
"rounded-md border bg-secondary p-2 py-3 w-full flex flex-col gap-2 text-sm",
"transition duration-200 ease-in-out hover:bg-secondary/80 dark:hover:bg-secondary/60"
)}
>
const cardContent = (
<>
<div className="flex items-center gap-2">
<ClockIcon className="flex-none size-4 text-muted-foreground" />
<p className="font-medium flex-1 leading-tight">
{remainingDays === 0
? t("trialExpired")
: t("trialActive")}
{remainingDays === 0 ? t("trialExpired") : t("trialActive")}
</p>
</div>
<div className="flex flex-col gap-1.5">
@@ -88,11 +92,37 @@ export default function ShowTrialCard({
? t("trialHasEnded")
: t("trialDaysRemaining", { count: remainingDays })}
</small>
<div className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground transition-colors">
<span>{t("trialGoToBilling")}</span>
<ArrowRight className="flex-none size-3" />
</div>
{isOwner && (
<div className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<span>{t("trialGoToBilling")}</span>
<ArrowRight className="flex-none size-3" />
</div>
)}
</div>
</Link>
</>
);
if (isOwner) {
return (
<Link
href={billingHref}
className={cn(
"group cursor-pointer block",
"rounded-md border bg-secondary p-2 py-3 w-full flex flex-col gap-2 text-sm"
)}
>
{cardContent}
</Link>
);
}
return (
<div
className={cn(
"rounded-md border bg-secondary p-2 py-3 w-full flex flex-col gap-2 text-sm"
)}
>
{cardContent}
</div>
);
}

View File

@@ -53,10 +53,12 @@ export default function UptimeAlertSection({
days = 90
}: UptimeAlertSectionProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const envContext = useEnvContext();
const api = createApiClient(envContext);
const queryClient = useQueryClient();
const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.alertingRules);
const { env } = envContext;
const [open, setOpen] = useState(false);
const [name, setName] = useState(
@@ -176,7 +178,9 @@ export default function UptimeAlertSection({
{t("uptimeSectionDescription", { days })}
</SettingsSectionDescription>
</div>
{alertButton}
{!env.flags.disableEnterpriseFeatures
? alertButton
: null}
</div>
</SettingsSectionHeader>
<SettingsSectionBody>

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;