mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-12 18:37:20 +00:00
Merge branch 'dev' into resource-policies
This commit is contained in:
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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")}{" "}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user