mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-01 23:46:38 +00:00
@@ -45,8 +45,17 @@ import { useTranslations } from "next-intl";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ListRolesResponse } from "@server/routers/role";
|
||||
import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
|
||||
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import {
|
||||
compileRoleMappingExpression,
|
||||
createMappingBuilderRule,
|
||||
detectRoleMappingConfig,
|
||||
ensureMappingBuilderRuleIds,
|
||||
MappingBuilderRule,
|
||||
RoleMappingMode
|
||||
} from "@app/lib/idpRoleMapping";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const { env } = useEnvContext();
|
||||
@@ -56,9 +65,15 @@ export default function GeneralPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||
const [roleMappingMode, setRoleMappingMode] = useState<
|
||||
"role" | "expression"
|
||||
>("role");
|
||||
const [roleMappingMode, setRoleMappingMode] =
|
||||
useState<RoleMappingMode>("fixedRoles");
|
||||
const [fixedRoleNames, setFixedRoleNames] = useState<string[]>([]);
|
||||
const [mappingBuilderClaimPath, setMappingBuilderClaimPath] =
|
||||
useState("groups");
|
||||
const [mappingBuilderRules, setMappingBuilderRules] = useState<
|
||||
MappingBuilderRule[]
|
||||
>([createMappingBuilderRule()]);
|
||||
const [rawRoleExpression, setRawRoleExpression] = useState("");
|
||||
const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc");
|
||||
|
||||
const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
|
||||
@@ -190,34 +205,8 @@ export default function GeneralPage() {
|
||||
// Set the variant
|
||||
setVariant(idpVariant as "oidc" | "google" | "azure");
|
||||
|
||||
// Check if roleMapping matches the basic pattern '{role name}' (simple single role)
|
||||
// This should NOT match complex expressions like 'Admin' || 'Member'
|
||||
const isBasicRolePattern =
|
||||
roleMapping &&
|
||||
typeof roleMapping === "string" &&
|
||||
/^'[^']+'$/.test(roleMapping);
|
||||
|
||||
// Determine if roleMapping is a number (roleId) or matches basic pattern
|
||||
const isRoleId =
|
||||
!isNaN(Number(roleMapping)) && roleMapping !== "";
|
||||
const isRoleName = isBasicRolePattern;
|
||||
|
||||
// Extract role name from basic pattern for matching
|
||||
let extractedRoleName = null;
|
||||
if (isRoleName) {
|
||||
extractedRoleName = roleMapping.slice(1, -1); // Remove quotes
|
||||
}
|
||||
|
||||
// Try to find matching role by name if we have a basic pattern
|
||||
let matchingRoleId = undefined;
|
||||
if (extractedRoleName && availableRoles.length > 0) {
|
||||
const matchingRole = availableRoles.find(
|
||||
(role) => role.name === extractedRoleName
|
||||
);
|
||||
if (matchingRole) {
|
||||
matchingRoleId = matchingRole.roleId;
|
||||
}
|
||||
}
|
||||
const detectedRoleMappingConfig =
|
||||
detectRoleMappingConfig(roleMapping);
|
||||
|
||||
// Extract tenant ID from Azure URLs if present
|
||||
let tenantId = "";
|
||||
@@ -238,9 +227,7 @@ export default function GeneralPage() {
|
||||
clientSecret: data.idpOidcConfig.clientSecret,
|
||||
autoProvision: data.idp.autoProvision,
|
||||
roleMapping: roleMapping || null,
|
||||
roleId: isRoleId
|
||||
? Number(roleMapping)
|
||||
: matchingRoleId || null
|
||||
roleId: null
|
||||
};
|
||||
|
||||
// Add variant-specific fields
|
||||
@@ -259,10 +246,18 @@ export default function GeneralPage() {
|
||||
|
||||
form.reset(formData);
|
||||
|
||||
// Set the role mapping mode based on the data
|
||||
// Default to "expression" unless it's a simple roleId or basic '{role name}' pattern
|
||||
setRoleMappingMode(
|
||||
matchingRoleId && isRoleName ? "role" : "expression"
|
||||
setRoleMappingMode(detectedRoleMappingConfig.mode);
|
||||
setFixedRoleNames(detectedRoleMappingConfig.fixedRoleNames);
|
||||
setMappingBuilderClaimPath(
|
||||
detectedRoleMappingConfig.mappingBuilder.claimPath
|
||||
);
|
||||
setMappingBuilderRules(
|
||||
ensureMappingBuilderRuleIds(
|
||||
detectedRoleMappingConfig.mappingBuilder.rules
|
||||
)
|
||||
);
|
||||
setRawRoleExpression(
|
||||
detectedRoleMappingConfig.rawExpression
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -327,7 +322,26 @@ export default function GeneralPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const roleName = roles.find((r) => r.roleId === data.roleId)?.name;
|
||||
const roleMappingExpression = compileRoleMappingExpression({
|
||||
mode: roleMappingMode,
|
||||
fixedRoleNames,
|
||||
mappingBuilder: {
|
||||
claimPath: mappingBuilderClaimPath,
|
||||
rules: mappingBuilderRules
|
||||
},
|
||||
rawExpression: rawRoleExpression
|
||||
});
|
||||
|
||||
if (data.autoProvision && !roleMappingExpression) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description:
|
||||
"A role mapping is required when auto-provisioning is enabled.",
|
||||
variant: "destructive"
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build payload based on variant
|
||||
let payload: any = {
|
||||
@@ -335,10 +349,7 @@ export default function GeneralPage() {
|
||||
clientId: data.clientId,
|
||||
clientSecret: data.clientSecret,
|
||||
autoProvision: data.autoProvision,
|
||||
roleMapping:
|
||||
roleMappingMode === "role"
|
||||
? `'${roleName}'`
|
||||
: data.roleMapping || ""
|
||||
roleMapping: roleMappingExpression
|
||||
};
|
||||
|
||||
// Add variant-specific fields
|
||||
@@ -438,16 +449,6 @@ export default function GeneralPage() {
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
|
||||
<Alert variant="neutral" className="">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("redirectUrlAbout")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("redirectUrlAboutDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* IDP Type Indicator */}
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
@@ -493,46 +494,47 @@ export default function GeneralPage() {
|
||||
{t("idpAutoProvisionUsers")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("idpAutoProvisionUsersDescription")}
|
||||
<IdpAutoProvisionUsersDescription />
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.autoProvisioning}
|
||||
/>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.autoProvisioning}
|
||||
/>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="general-settings-form"
|
||||
>
|
||||
<AutoProvisionConfigWidget
|
||||
control={form.control}
|
||||
autoProvision={form.watch(
|
||||
"autoProvision"
|
||||
)}
|
||||
onAutoProvisionChange={(checked) => {
|
||||
form.setValue(
|
||||
"autoProvision",
|
||||
checked
|
||||
);
|
||||
}}
|
||||
roleMappingMode={roleMappingMode}
|
||||
onRoleMappingModeChange={(data) => {
|
||||
setRoleMappingMode(data);
|
||||
// Clear roleId and roleMapping when mode changes
|
||||
form.setValue("roleId", null);
|
||||
form.setValue("roleMapping", null);
|
||||
}}
|
||||
roles={roles}
|
||||
roleIdFieldName="roleId"
|
||||
roleMappingFieldName="roleMapping"
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="general-settings-form"
|
||||
>
|
||||
<AutoProvisionConfigWidget
|
||||
autoProvision={form.watch("autoProvision")}
|
||||
onAutoProvisionChange={(checked) => {
|
||||
form.setValue("autoProvision", checked);
|
||||
}}
|
||||
roleMappingMode={roleMappingMode}
|
||||
onRoleMappingModeChange={(data) => {
|
||||
setRoleMappingMode(data);
|
||||
}}
|
||||
roles={roles}
|
||||
fixedRoleNames={fixedRoleNames}
|
||||
onFixedRoleNamesChange={setFixedRoleNames}
|
||||
mappingBuilderClaimPath={
|
||||
mappingBuilderClaimPath
|
||||
}
|
||||
onMappingBuilderClaimPathChange={
|
||||
setMappingBuilderClaimPath
|
||||
}
|
||||
mappingBuilderRules={mappingBuilderRules}
|
||||
onMappingBuilderRulesChange={
|
||||
setMappingBuilderRules
|
||||
}
|
||||
rawExpression={rawRoleExpression}
|
||||
onRawExpressionChange={setRawRoleExpression}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -832,29 +834,6 @@ export default function GeneralPage() {
|
||||
className="space-y-4"
|
||||
id="general-settings-form"
|
||||
>
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("idpJmespathAbout")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"idpJmespathAboutDescription"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://jmespath.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center"
|
||||
>
|
||||
{t(
|
||||
"idpJmespathAboutDescriptionLink"
|
||||
)}{" "}
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="identifierPath"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
|
||||
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import {
|
||||
SettingsContainer,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { OidcIdpProviderTypeSelect } from "@app/components/idp/OidcIdpProviderTypeSelect";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
@@ -27,21 +28,26 @@ import {
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { applyOidcIdpProviderType } from "@app/lib/idp/oidcIdpProviderDefaults";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { ListRolesResponse } from "@server/routers/role";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
compileRoleMappingExpression,
|
||||
createMappingBuilderRule,
|
||||
MappingBuilderRule,
|
||||
RoleMappingMode
|
||||
} from "@app/lib/idpRoleMapping";
|
||||
|
||||
export default function Page() {
|
||||
const { env } = useEnvContext();
|
||||
@@ -49,9 +55,15 @@ export default function Page() {
|
||||
const router = useRouter();
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||
const [roleMappingMode, setRoleMappingMode] = useState<
|
||||
"role" | "expression"
|
||||
>("role");
|
||||
const [roleMappingMode, setRoleMappingMode] =
|
||||
useState<RoleMappingMode>("fixedRoles");
|
||||
const [fixedRoleNames, setFixedRoleNames] = useState<string[]>([]);
|
||||
const [mappingBuilderClaimPath, setMappingBuilderClaimPath] =
|
||||
useState("groups");
|
||||
const [mappingBuilderRules, setMappingBuilderRules] = useState<
|
||||
MappingBuilderRule[]
|
||||
>([createMappingBuilderRule()]);
|
||||
const [rawRoleExpression, setRawRoleExpression] = useState("");
|
||||
const t = useTranslations();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
@@ -84,49 +96,6 @@ export default function Page() {
|
||||
|
||||
type CreateIdpFormValues = z.infer<typeof createIdpFormSchema>;
|
||||
|
||||
interface ProviderTypeOption {
|
||||
id: "oidc" | "google" | "azure";
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const providerTypes: ReadonlyArray<ProviderTypeOption> = [
|
||||
{
|
||||
id: "oidc",
|
||||
title: "OAuth2/OIDC",
|
||||
description: t("idpOidcDescription")
|
||||
},
|
||||
{
|
||||
id: "google",
|
||||
title: t("idpGoogleTitle"),
|
||||
description: t("idpGoogleDescription"),
|
||||
icon: (
|
||||
<Image
|
||||
src="/idp/google.png"
|
||||
alt={t("idpGoogleAlt")}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "azure",
|
||||
title: t("idpAzureTitle"),
|
||||
description: t("idpAzureDescription"),
|
||||
icon: (
|
||||
<Image
|
||||
src="/idp/azure.png"
|
||||
alt={t("idpAzureAlt")}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded"
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(createIdpFormSchema),
|
||||
defaultValues: {
|
||||
@@ -174,47 +143,6 @@ export default function Page() {
|
||||
fetchRoles();
|
||||
}, []);
|
||||
|
||||
// Handle provider type changes and set defaults
|
||||
const handleProviderChange = (value: "oidc" | "google" | "azure") => {
|
||||
form.setValue("type", value);
|
||||
|
||||
if (value === "google") {
|
||||
// Set Google defaults
|
||||
form.setValue(
|
||||
"authUrl",
|
||||
"https://accounts.google.com/o/oauth2/v2/auth"
|
||||
);
|
||||
form.setValue("tokenUrl", "https://oauth2.googleapis.com/token");
|
||||
form.setValue("identifierPath", "email");
|
||||
form.setValue("emailPath", "email");
|
||||
form.setValue("namePath", "name");
|
||||
form.setValue("scopes", "openid profile email");
|
||||
} else if (value === "azure") {
|
||||
// Set Azure Entra ID defaults (URLs will be constructed dynamically)
|
||||
form.setValue(
|
||||
"authUrl",
|
||||
"https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/authorize"
|
||||
);
|
||||
form.setValue(
|
||||
"tokenUrl",
|
||||
"https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/token"
|
||||
);
|
||||
form.setValue("identifierPath", "email");
|
||||
form.setValue("emailPath", "email");
|
||||
form.setValue("namePath", "name");
|
||||
form.setValue("scopes", "openid profile email");
|
||||
form.setValue("tenantId", "");
|
||||
} else {
|
||||
// Reset to OIDC defaults
|
||||
form.setValue("authUrl", "");
|
||||
form.setValue("tokenUrl", "");
|
||||
form.setValue("identifierPath", "sub");
|
||||
form.setValue("namePath", "name");
|
||||
form.setValue("emailPath", "email");
|
||||
form.setValue("scopes", "openid profile email");
|
||||
}
|
||||
};
|
||||
|
||||
async function onSubmit(data: CreateIdpFormValues) {
|
||||
setCreateLoading(true);
|
||||
|
||||
@@ -228,7 +156,26 @@ export default function Page() {
|
||||
tokenUrl = tokenUrl?.replace("{{TENANT_ID}}", data.tenantId);
|
||||
}
|
||||
|
||||
const roleName = roles.find((r) => r.roleId === data.roleId)?.name;
|
||||
const roleMappingExpression = compileRoleMappingExpression({
|
||||
mode: roleMappingMode,
|
||||
fixedRoleNames,
|
||||
mappingBuilder: {
|
||||
claimPath: mappingBuilderClaimPath,
|
||||
rules: mappingBuilderRules
|
||||
},
|
||||
rawExpression: rawRoleExpression
|
||||
});
|
||||
|
||||
if (data.autoProvision && !roleMappingExpression) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description:
|
||||
"A role mapping is required when auto-provisioning is enabled.",
|
||||
variant: "destructive"
|
||||
});
|
||||
setCreateLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: data.name,
|
||||
@@ -240,10 +187,7 @@ export default function Page() {
|
||||
emailPath: data.emailPath,
|
||||
namePath: data.namePath,
|
||||
autoProvision: data.autoProvision,
|
||||
roleMapping:
|
||||
roleMappingMode === "role"
|
||||
? `'${roleName}'`
|
||||
: data.roleMapping || "",
|
||||
roleMapping: roleMappingExpression,
|
||||
scopes: data.scopes,
|
||||
variant: data.type
|
||||
};
|
||||
@@ -308,23 +252,12 @@ export default function Page() {
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t("idpType")}
|
||||
</span>
|
||||
</div>
|
||||
<StrategySelect
|
||||
options={providerTypes}
|
||||
defaultValue={form.getValues("type")}
|
||||
onChange={(value) => {
|
||||
handleProviderChange(
|
||||
value as "oidc" | "google" | "azure"
|
||||
);
|
||||
}}
|
||||
cols={3}
|
||||
/>
|
||||
</div>
|
||||
<OidcIdpProviderTypeSelect
|
||||
value={form.watch("type")}
|
||||
onTypeChange={(next) => {
|
||||
applyOidcIdpProviderType(form.setValue, next);
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
@@ -364,47 +297,48 @@ export default function Page() {
|
||||
{t("idpAutoProvisionUsers")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("idpAutoProvisionUsersDescription")}
|
||||
<IdpAutoProvisionUsersDescription />
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.autoProvisioning}
|
||||
/>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="create-idp-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<AutoProvisionConfigWidget
|
||||
control={form.control}
|
||||
autoProvision={
|
||||
form.watch(
|
||||
"autoProvision"
|
||||
) as boolean
|
||||
} // is this right?
|
||||
onAutoProvisionChange={(checked) => {
|
||||
form.setValue(
|
||||
"autoProvision",
|
||||
checked
|
||||
);
|
||||
}}
|
||||
roleMappingMode={roleMappingMode}
|
||||
onRoleMappingModeChange={(data) => {
|
||||
setRoleMappingMode(data);
|
||||
// Clear roleId and roleMapping when mode changes
|
||||
form.setValue("roleId", null);
|
||||
form.setValue("roleMapping", null);
|
||||
}}
|
||||
roles={roles}
|
||||
roleIdFieldName="roleId"
|
||||
roleMappingFieldName="roleMapping"
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.autoProvisioning}
|
||||
/>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="create-idp-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<AutoProvisionConfigWidget
|
||||
autoProvision={
|
||||
form.watch("autoProvision") as boolean
|
||||
} // is this right?
|
||||
onAutoProvisionChange={(checked) => {
|
||||
form.setValue("autoProvision", checked);
|
||||
}}
|
||||
roleMappingMode={roleMappingMode}
|
||||
onRoleMappingModeChange={(data) => {
|
||||
setRoleMappingMode(data);
|
||||
}}
|
||||
roles={roles}
|
||||
fixedRoleNames={fixedRoleNames}
|
||||
onFixedRoleNamesChange={setFixedRoleNames}
|
||||
mappingBuilderClaimPath={
|
||||
mappingBuilderClaimPath
|
||||
}
|
||||
onMappingBuilderClaimPathChange={
|
||||
setMappingBuilderClaimPath
|
||||
}
|
||||
mappingBuilderRules={mappingBuilderRules}
|
||||
onMappingBuilderRulesChange={
|
||||
setMappingBuilderRules
|
||||
}
|
||||
rawExpression={rawRoleExpression}
|
||||
onRawExpressionChange={setRawRoleExpression}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -679,16 +613,6 @@ export default function Page() {
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("idpOidcConfigureAlert")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("idpOidcConfigureAlertDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
|
||||
@@ -3,13 +3,12 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import InvitationsTable, {
|
||||
InvitationRow
|
||||
} from "../../../../../components/InvitationsTable";
|
||||
} from "@app/components/InvitationsTable";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import { cache } from "react";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
@@ -29,9 +28,8 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
|
||||
let invitations: {
|
||||
inviteId: string;
|
||||
email: string;
|
||||
expiresAt: string;
|
||||
roleId: number;
|
||||
roleName?: string;
|
||||
expiresAt: number;
|
||||
roles: { roleId: number; roleName: string | null }[];
|
||||
}[] = [];
|
||||
let hasInvitations = false;
|
||||
|
||||
@@ -66,12 +64,15 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
|
||||
}
|
||||
|
||||
const invitationRows: InvitationRow[] = invitations.map((invite) => {
|
||||
const names = invite.roles
|
||||
.map((r) => r.roleName || t("accessRoleUnknown"))
|
||||
.filter(Boolean);
|
||||
return {
|
||||
id: invite.inviteId,
|
||||
email: invite.email,
|
||||
expiresAt: new Date(Number(invite.expiresAt)).toISOString(),
|
||||
role: invite.roleName || t("accessRoleUnknown"),
|
||||
roleId: invite.roleId
|
||||
roleLabels: names,
|
||||
roleIds: invite.roles.map((r) => r.roleId)
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -8,18 +8,10 @@ import {
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import OrgRolesTagField from "@app/components/OrgRolesTagField";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { InviteUserResponse } from "@server/routers/user";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -44,34 +36,69 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const accessControlsFormSchema = z.object({
|
||||
username: z.string(),
|
||||
autoProvisioned: z.boolean(),
|
||||
roles: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
export default function AccessControlsPage() {
|
||||
const { orgUser: user } = userOrgUserContext();
|
||||
const { orgUser: user, updateOrgUser } = userOrgUserContext();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const { orgId } = useParams();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||
const [activeRoleTagIndex, setActiveRoleTagIndex] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const formSchema = z.object({
|
||||
username: z.string(),
|
||||
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }),
|
||||
autoProvisioned: z.boolean()
|
||||
});
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isPaid = isPaidUser(tierMatrix.fullRbac);
|
||||
const supportsMultipleRolesPerUser = isPaid;
|
||||
const showMultiRolePaywallMessage =
|
||||
!env.flags.disableEnterpriseFeatures &&
|
||||
((build === "saas" && !isPaid) ||
|
||||
(build === "enterprise" && !isPaid) ||
|
||||
(build === "oss" && !isPaid));
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
resolver: zodResolver(accessControlsFormSchema),
|
||||
defaultValues: {
|
||||
username: user.username!,
|
||||
roleId: user.roleId?.toString(),
|
||||
autoProvisioned: user.autoProvisioned || false
|
||||
autoProvisioned: user.autoProvisioned || false,
|
||||
roles: (user.roles ?? []).map((r) => ({
|
||||
id: r.roleId.toString(),
|
||||
text: r.name
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
const currentRoleIds = user.roleIds ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue(
|
||||
"roles",
|
||||
(user.roles ?? []).map((r) => ({
|
||||
id: r.roleId.toString(),
|
||||
text: r.name
|
||||
}))
|
||||
);
|
||||
}, [user.userId, currentRoleIds.join(",")]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchRoles() {
|
||||
const res = await api
|
||||
@@ -94,32 +121,59 @@ export default function AccessControlsPage() {
|
||||
}
|
||||
|
||||
fetchRoles();
|
||||
|
||||
form.setValue("roleId", user.roleId.toString());
|
||||
form.setValue("autoProvisioned", user.autoProvisioned || false);
|
||||
}, []);
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true);
|
||||
const allRoleOptions = roles.map((role) => ({
|
||||
id: role.roleId.toString(),
|
||||
text: role.name
|
||||
}));
|
||||
|
||||
const paywallMessage =
|
||||
build === "saas"
|
||||
? t("singleRolePerUserPlanNotice")
|
||||
: t("singleRolePerUserEditionNotice");
|
||||
|
||||
async function onSubmit(values: z.infer<typeof accessControlsFormSchema>) {
|
||||
if (values.roles.length === 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("accessRoleErrorAdd"),
|
||||
description: t("accessRoleSelectPlease")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Execute both API calls simultaneously
|
||||
const [roleRes, userRes] = await Promise.all([
|
||||
api.post<AxiosResponse<InviteUserResponse>>(
|
||||
`/role/${values.roleId}/add/${user.userId}`
|
||||
),
|
||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||
const updateRoleRequest = supportsMultipleRolesPerUser
|
||||
? api.post(`/user/${user.userId}/org/${orgId}/roles`, {
|
||||
roleIds
|
||||
})
|
||||
: api.post(`/role/${roleIds[0]}/add/${user.userId}`);
|
||||
|
||||
await Promise.all([
|
||||
updateRoleRequest,
|
||||
api.post(`/org/${orgId}/user/${user.userId}`, {
|
||||
autoProvisioned: values.autoProvisioned
|
||||
})
|
||||
]);
|
||||
|
||||
if (roleRes.status === 200 && userRes.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("userSaved"),
|
||||
description: t("userSavedDescription")
|
||||
});
|
||||
}
|
||||
updateOrgUser({
|
||||
roleIds,
|
||||
roles: values.roles.map((r) => ({
|
||||
roleId: parseInt(r.id, 10),
|
||||
name: r.text
|
||||
})),
|
||||
autoProvisioned: values.autoProvisioned
|
||||
});
|
||||
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("userSaved"),
|
||||
description: t("userSavedDescription")
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@@ -130,7 +184,6 @@ export default function AccessControlsPage() {
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -154,7 +207,6 @@ export default function AccessControlsPage() {
|
||||
className="space-y-4"
|
||||
id="access-controls-form"
|
||||
>
|
||||
{/* IDP Type Display */}
|
||||
{user.type !== UserType.Internal &&
|
||||
user.idpType && (
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
@@ -171,48 +223,22 @@ export default function AccessControlsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("role")}</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
// If auto provision is enabled, set it to false when role changes
|
||||
if (user.idpAutoProvision) {
|
||||
form.setValue(
|
||||
"autoProvisioned",
|
||||
false
|
||||
);
|
||||
}
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"accessRoleSelect"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem
|
||||
key={role.roleId}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
<OrgRolesTagField
|
||||
form={form}
|
||||
name="roles"
|
||||
label={t("roles")}
|
||||
placeholder={t("accessRoleSelect2")}
|
||||
allRoleOptions={allRoleOptions}
|
||||
supportsMultipleRolesPerUser={
|
||||
supportsMultipleRolesPerUser
|
||||
}
|
||||
showMultiRolePaywallMessage={
|
||||
showMultiRolePaywallMessage
|
||||
}
|
||||
paywallMessage={paywallMessage}
|
||||
loading={loading}
|
||||
activeTagIndex={activeRoleTagIndex}
|
||||
setActiveTagIndex={setActiveRoleTagIndex}
|
||||
/>
|
||||
|
||||
{user.idpAutoProvision && (
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
} from "@app/components/ui/select";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
|
||||
import { InviteUserResponse } from "@server/routers/user";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -49,6 +49,7 @@ import { build } from "@server/build";
|
||||
import Image from "next/image";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import OrgRolesTagField from "@app/components/OrgRolesTagField";
|
||||
|
||||
type UserType = "internal" | "oidc";
|
||||
|
||||
@@ -76,7 +77,14 @@ export default function Page() {
|
||||
const api = createApiClient({ env });
|
||||
const t = useTranslations();
|
||||
|
||||
const { hasSaasSubscription } = usePaidStatus();
|
||||
const { hasSaasSubscription, isPaidUser } = usePaidStatus();
|
||||
const isPaid = isPaidUser(tierMatrix.fullRbac);
|
||||
const supportsMultipleRolesPerUser = isPaid;
|
||||
const showMultiRolePaywallMessage =
|
||||
!env.flags.disableEnterpriseFeatures &&
|
||||
((build === "saas" && !isPaid) ||
|
||||
(build === "enterprise" && !isPaid) ||
|
||||
(build === "oss" && !isPaid));
|
||||
|
||||
const [selectedOption, setSelectedOption] = useState<string | null>(
|
||||
"internal"
|
||||
@@ -89,19 +97,34 @@ export default function Page() {
|
||||
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
|
||||
const [userOptions, setUserOptions] = useState<UserOption[]>([]);
|
||||
const [dataLoaded, setDataLoaded] = useState(false);
|
||||
const [activeInviteRoleTagIndex, setActiveInviteRoleTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [activeOidcRoleTagIndex, setActiveOidcRoleTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const roleTagsFieldSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
)
|
||||
.min(1, { message: t("accessRoleSelectPlease") });
|
||||
|
||||
const internalFormSchema = z.object({
|
||||
email: z.email({ message: t("emailInvalid") }),
|
||||
validForHours: z
|
||||
.string()
|
||||
.min(1, { message: t("inviteValidityDuration") }),
|
||||
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
|
||||
roles: roleTagsFieldSchema
|
||||
});
|
||||
|
||||
const googleAzureFormSchema = z.object({
|
||||
email: z.email({ message: t("emailInvalid") }),
|
||||
name: z.string().optional(),
|
||||
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
|
||||
roles: roleTagsFieldSchema
|
||||
});
|
||||
|
||||
const genericOidcFormSchema = z.object({
|
||||
@@ -111,7 +134,7 @@ export default function Page() {
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
name: z.string().optional(),
|
||||
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
|
||||
roles: roleTagsFieldSchema
|
||||
});
|
||||
|
||||
const formatIdpType = (type: string) => {
|
||||
@@ -166,12 +189,22 @@ export default function Page() {
|
||||
{ hours: 168, name: t("day", { count: 7 }) }
|
||||
];
|
||||
|
||||
const allRoleOptions = roles.map((role) => ({
|
||||
id: role.roleId.toString(),
|
||||
text: role.name
|
||||
}));
|
||||
|
||||
const invitePaywallMessage =
|
||||
build === "saas"
|
||||
? t("singleRolePerUserPlanNotice")
|
||||
: t("singleRolePerUserEditionNotice");
|
||||
|
||||
const internalForm = useForm({
|
||||
resolver: zodResolver(internalFormSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
validForHours: "72",
|
||||
roleId: ""
|
||||
roles: [] as { id: string; text: string }[]
|
||||
}
|
||||
});
|
||||
|
||||
@@ -180,7 +213,7 @@ export default function Page() {
|
||||
defaultValues: {
|
||||
email: "",
|
||||
name: "",
|
||||
roleId: ""
|
||||
roles: [] as { id: string; text: string }[]
|
||||
}
|
||||
});
|
||||
|
||||
@@ -190,7 +223,7 @@ export default function Page() {
|
||||
username: "",
|
||||
email: "",
|
||||
name: "",
|
||||
roleId: ""
|
||||
roles: [] as { id: string; text: string }[]
|
||||
}
|
||||
});
|
||||
|
||||
@@ -305,16 +338,17 @@ export default function Page() {
|
||||
) {
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<InviteUserResponse>>(
|
||||
`/org/${orgId}/create-invite`,
|
||||
{
|
||||
email: values.email,
|
||||
roleId: parseInt(values.roleId),
|
||||
validHours: parseInt(values.validForHours),
|
||||
sendEmail: sendEmail
|
||||
} as InviteUserBody
|
||||
)
|
||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||
|
||||
const res = await api.post<AxiosResponse<InviteUserResponse>>(
|
||||
`/org/${orgId}/create-invite`,
|
||||
{
|
||||
email: values.email,
|
||||
roleIds,
|
||||
validHours: parseInt(values.validForHours),
|
||||
sendEmail
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
if (e.response?.status === 409) {
|
||||
toast({
|
||||
@@ -358,6 +392,8 @@ export default function Page() {
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||
|
||||
const res = await api
|
||||
.put(`/org/${orgId}/user`, {
|
||||
username: values.email, // Use email as username for Google/Azure
|
||||
@@ -365,7 +401,7 @@ export default function Page() {
|
||||
name: values.name,
|
||||
type: "oidc",
|
||||
idpId: selectedUserOption.idpId,
|
||||
roleId: parseInt(values.roleId)
|
||||
roleIds
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
@@ -400,6 +436,8 @@ export default function Page() {
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||
|
||||
const res = await api
|
||||
.put(`/org/${orgId}/user`, {
|
||||
username: values.username,
|
||||
@@ -407,7 +445,7 @@ export default function Page() {
|
||||
name: values.name,
|
||||
type: "oidc",
|
||||
idpId: selectedUserOption.idpId,
|
||||
roleId: parseInt(values.roleId)
|
||||
roleIds
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
@@ -575,52 +613,32 @@ export default function Page() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={
|
||||
internalForm.control
|
||||
}
|
||||
name="roleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("role")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"accessRoleSelect"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map(
|
||||
(
|
||||
role
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
role.roleId
|
||||
}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{
|
||||
role.name
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<OrgRolesTagField
|
||||
form={internalForm}
|
||||
name="roles"
|
||||
label={t("roles")}
|
||||
placeholder={t(
|
||||
"accessRoleSelect2"
|
||||
)}
|
||||
allRoleOptions={
|
||||
allRoleOptions
|
||||
}
|
||||
supportsMultipleRolesPerUser={
|
||||
supportsMultipleRolesPerUser
|
||||
}
|
||||
showMultiRolePaywallMessage={
|
||||
showMultiRolePaywallMessage
|
||||
}
|
||||
paywallMessage={
|
||||
invitePaywallMessage
|
||||
}
|
||||
loading={loading}
|
||||
activeTagIndex={
|
||||
activeInviteRoleTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveInviteRoleTagIndex
|
||||
}
|
||||
/>
|
||||
|
||||
{env.email.emailEnabled && (
|
||||
@@ -764,52 +782,32 @@ export default function Page() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={
|
||||
googleAzureForm.control
|
||||
}
|
||||
name="roleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("role")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"accessRoleSelect"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map(
|
||||
(
|
||||
role
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
role.roleId
|
||||
}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{
|
||||
role.name
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<OrgRolesTagField
|
||||
form={googleAzureForm}
|
||||
name="roles"
|
||||
label={t("roles")}
|
||||
placeholder={t(
|
||||
"accessRoleSelect2"
|
||||
)}
|
||||
allRoleOptions={
|
||||
allRoleOptions
|
||||
}
|
||||
supportsMultipleRolesPerUser={
|
||||
supportsMultipleRolesPerUser
|
||||
}
|
||||
showMultiRolePaywallMessage={
|
||||
showMultiRolePaywallMessage
|
||||
}
|
||||
paywallMessage={
|
||||
invitePaywallMessage
|
||||
}
|
||||
loading={loading}
|
||||
activeTagIndex={
|
||||
activeOidcRoleTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveOidcRoleTagIndex
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -909,52 +907,32 @@ export default function Page() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={
|
||||
genericOidcForm.control
|
||||
}
|
||||
name="roleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("role")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"accessRoleSelect"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map(
|
||||
(
|
||||
role
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
role.roleId
|
||||
}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{
|
||||
role.name
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<OrgRolesTagField
|
||||
form={genericOidcForm}
|
||||
name="roles"
|
||||
label={t("roles")}
|
||||
placeholder={t(
|
||||
"accessRoleSelect2"
|
||||
)}
|
||||
allRoleOptions={
|
||||
allRoleOptions
|
||||
}
|
||||
supportsMultipleRolesPerUser={
|
||||
supportsMultipleRolesPerUser
|
||||
}
|
||||
showMultiRolePaywallMessage={
|
||||
showMultiRolePaywallMessage
|
||||
}
|
||||
paywallMessage={
|
||||
invitePaywallMessage
|
||||
}
|
||||
loading={loading}
|
||||
activeTagIndex={
|
||||
activeOidcRoleTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveOidcRoleTagIndex
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -3,13 +3,12 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { ListUsersResponse } from "@server/routers/user";
|
||||
import { AxiosResponse } from "axios";
|
||||
import UsersTable, { UserRow } from "../../../../../components/UsersTable";
|
||||
import UsersTable, { UserRow } from "@app/components/UsersTable";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import { cache } from "react";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
@@ -86,9 +85,14 @@ export default async function UsersPage(props: UsersPageProps) {
|
||||
idpId: user.idpId,
|
||||
idpName: user.idpName || t("idpNameInternal"),
|
||||
status: t("userConfirmed"),
|
||||
role: user.isOwner
|
||||
? t("accessRoleOwner")
|
||||
: user.roleName || t("accessRoleMember"),
|
||||
roleLabels: user.isOwner
|
||||
? [t("accessRoleOwner")]
|
||||
: (() => {
|
||||
const names = (user.roles ?? [])
|
||||
.map((r) => r.roleName)
|
||||
.filter((n): n is string => Boolean(n?.length));
|
||||
return names.length ? names : [t("accessRoleMember")];
|
||||
})(),
|
||||
isOwner: user.isOwner || false
|
||||
};
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AxiosResponse } from "axios";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import OrgApiKeysTable, {
|
||||
OrgApiKeyRow
|
||||
} from "../../../../components/OrgApiKeysTable";
|
||||
} from "@app/components/OrgApiKeysTable";
|
||||
import { ListOrgApiKeysResponse } from "@server/routers/apiKeys";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import DomainsTable, { DomainRow } from "../../../../components/DomainsTable";
|
||||
import DomainsTable, { DomainRow } from "@app/components/DomainsTable";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { cache } from "react";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
|
||||
@@ -79,7 +79,8 @@ const SecurityFormSchema = z.object({
|
||||
passwordExpiryDays: z.number().nullable().optional(),
|
||||
settingsLogRetentionDaysRequest: z.number(),
|
||||
settingsLogRetentionDaysAccess: z.number(),
|
||||
settingsLogRetentionDaysAction: z.number()
|
||||
settingsLogRetentionDaysAction: z.number(),
|
||||
settingsLogRetentionDaysConnection: z.number()
|
||||
});
|
||||
|
||||
const LOG_RETENTION_OPTIONS = [
|
||||
@@ -120,7 +121,8 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
SecurityFormSchema.pick({
|
||||
settingsLogRetentionDaysRequest: true,
|
||||
settingsLogRetentionDaysAccess: true,
|
||||
settingsLogRetentionDaysAction: true
|
||||
settingsLogRetentionDaysAction: true,
|
||||
settingsLogRetentionDaysConnection: true
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
@@ -129,7 +131,9 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
settingsLogRetentionDaysAccess:
|
||||
org.settingsLogRetentionDaysAccess ?? 15,
|
||||
settingsLogRetentionDaysAction:
|
||||
org.settingsLogRetentionDaysAction ?? 15
|
||||
org.settingsLogRetentionDaysAction ?? 15,
|
||||
settingsLogRetentionDaysConnection:
|
||||
org.settingsLogRetentionDaysConnection ?? 15
|
||||
},
|
||||
mode: "onChange"
|
||||
});
|
||||
@@ -155,7 +159,9 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
settingsLogRetentionDaysAccess:
|
||||
data.settingsLogRetentionDaysAccess,
|
||||
settingsLogRetentionDaysAction:
|
||||
data.settingsLogRetentionDaysAction
|
||||
data.settingsLogRetentionDaysAction,
|
||||
settingsLogRetentionDaysConnection:
|
||||
data.settingsLogRetentionDaysConnection
|
||||
} as any;
|
||||
|
||||
// Update organization
|
||||
@@ -473,6 +479,107 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsLogRetentionDaysConnection"
|
||||
render={({ field }) => {
|
||||
const isDisabled = !isPaidUser(
|
||||
tierMatrix.connectionLogs
|
||||
);
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"logRetentionConnectionLabel"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value.toString()}
|
||||
onValueChange={(
|
||||
value
|
||||
) => {
|
||||
if (
|
||||
!isDisabled
|
||||
) {
|
||||
field.onChange(
|
||||
parseInt(
|
||||
value,
|
||||
10
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
isDisabled
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectLogRetention"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOG_RETENTION_OPTIONS.filter(
|
||||
(option) => {
|
||||
if (build != "saas") {
|
||||
return true;
|
||||
}
|
||||
|
||||
let maxDays: number;
|
||||
|
||||
if (!subscriptionTier) {
|
||||
// No tier
|
||||
maxDays = 3;
|
||||
} else if (subscriptionTier == "enterprise") {
|
||||
// Enterprise - no limit
|
||||
return true;
|
||||
} else if (subscriptionTier == "tier3") {
|
||||
maxDays = 90;
|
||||
} else if (subscriptionTier == "tier2") {
|
||||
maxDays = 30;
|
||||
} else if (subscriptionTier == "tier1") {
|
||||
maxDays = 7;
|
||||
} else {
|
||||
// Default to most restrictive
|
||||
maxDays = 3;
|
||||
}
|
||||
|
||||
// Filter out options that exceed the max
|
||||
// Special values: -1 (forever) and 9001 (end of year) should be filtered
|
||||
if (option.value < 0 || option.value > maxDays) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
).map(
|
||||
(
|
||||
option
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
option.value
|
||||
}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{t(
|
||||
option.label
|
||||
)}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -465,7 +465,11 @@ export default function GeneralPage() {
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`}
|
||||
href={
|
||||
row.original.type === "ssh"
|
||||
? `/${row.original.orgId}/settings/resources/client?query=${row.original.resourceNiceId}`
|
||||
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -493,7 +497,8 @@ export default function GeneralPage() {
|
||||
{
|
||||
value: "whitelistedEmail",
|
||||
label: "Whitelisted Email"
|
||||
}
|
||||
},
|
||||
{ value: "ssh", label: "SSH" }
|
||||
]}
|
||||
selectedValue={filters.type}
|
||||
onValueChange={(value) =>
|
||||
@@ -507,13 +512,12 @@ export default function GeneralPage() {
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
// should be capitalized first letter
|
||||
return (
|
||||
<span>
|
||||
{row.original.type.charAt(0).toUpperCase() +
|
||||
row.original.type.slice(1) || "-"}
|
||||
</span>
|
||||
);
|
||||
const typeLabel =
|
||||
row.original.type === "ssh"
|
||||
? "SSH"
|
||||
: row.original.type.charAt(0).toUpperCase() +
|
||||
row.original.type.slice(1);
|
||||
return <span>{typeLabel || "-"}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
760
src/app/[orgId]/settings/logs/connection/page.tsx
Normal file
760
src/app/[orgId]/settings/logs/connection/page.tsx
Normal file
@@ -0,0 +1,760 @@
|
||||
"use client";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ColumnFilter } from "@app/components/ColumnFilter";
|
||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { LogDataTable } from "@app/components/LogDataTable";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import { ArrowUpRight, Laptop, User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
|
||||
function formatBytes(bytes: number | null): string {
|
||||
if (bytes === null || bytes === undefined) return "—";
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const value = bytes / Math.pow(1024, i);
|
||||
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatDuration(startedAt: number, endedAt: number | null): string {
|
||||
if (endedAt === null || endedAt === undefined) return "Active";
|
||||
const durationSec = endedAt - startedAt;
|
||||
if (durationSec < 0) return "—";
|
||||
if (durationSec < 60) return `${durationSec}s`;
|
||||
if (durationSec < 3600) {
|
||||
const m = Math.floor(durationSec / 60);
|
||||
const s = durationSec % 60;
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
const h = Math.floor(durationSec / 3600);
|
||||
const m = Math.floor((durationSec % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
export default function ConnectionLogsPage() {
|
||||
const router = useRouter();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
const { orgId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
const [filterAttributes, setFilterAttributes] = useState<{
|
||||
protocols: string[];
|
||||
destAddrs: string[];
|
||||
clients: { id: number; name: string }[];
|
||||
resources: { id: number; name: string | null }[];
|
||||
users: { id: string; email: string | null }[];
|
||||
}>({
|
||||
protocols: [],
|
||||
destAddrs: [],
|
||||
clients: [],
|
||||
resources: [],
|
||||
users: []
|
||||
});
|
||||
|
||||
// Filter states - unified object for all filters
|
||||
const [filters, setFilters] = useState<{
|
||||
protocol?: string;
|
||||
destAddr?: string;
|
||||
clientId?: string;
|
||||
siteResourceId?: string;
|
||||
userId?: string;
|
||||
}>({
|
||||
protocol: searchParams.get("protocol") || undefined,
|
||||
destAddr: searchParams.get("destAddr") || undefined,
|
||||
clientId: searchParams.get("clientId") || undefined,
|
||||
siteResourceId: searchParams.get("siteResourceId") || undefined,
|
||||
userId: searchParams.get("userId") || undefined
|
||||
});
|
||||
|
||||
// Pagination state
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Initialize page size from storage or default
|
||||
const [pageSize, setPageSize] = useStoredPageSize(
|
||||
"connection-audit-logs",
|
||||
20
|
||||
);
|
||||
|
||||
// Set default date range to last 7 days
|
||||
const getDefaultDateRange = () => {
|
||||
// if the time is in the url params, use that instead
|
||||
const startParam = searchParams.get("start");
|
||||
const endParam = searchParams.get("end");
|
||||
if (startParam && endParam) {
|
||||
return {
|
||||
startDate: {
|
||||
date: new Date(startParam)
|
||||
},
|
||||
endDate: {
|
||||
date: new Date(endParam)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const lastWeek = getSevenDaysAgo();
|
||||
|
||||
return {
|
||||
startDate: {
|
||||
date: lastWeek
|
||||
},
|
||||
endDate: {
|
||||
date: now
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const [dateRange, setDateRange] = useState<{
|
||||
startDate: DateTimeValue;
|
||||
endDate: DateTimeValue;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
if (build === "oss") {
|
||||
return;
|
||||
}
|
||||
const defaultRange = getDefaultDateRange();
|
||||
queryDateTime(
|
||||
defaultRange.startDate,
|
||||
defaultRange.endDate,
|
||||
0,
|
||||
pageSize
|
||||
);
|
||||
}, [orgId]); // Re-run if orgId changes
|
||||
|
||||
const handleDateRangeChange = (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue
|
||||
) => {
|
||||
setDateRange({ startDate, endDate });
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
// put the search params in the url for the time
|
||||
updateUrlParamsForAllFilters({
|
||||
start: startDate.date?.toISOString() || "",
|
||||
end: endDate.date?.toISOString() || ""
|
||||
});
|
||||
|
||||
queryDateTime(startDate, endDate, 0, pageSize);
|
||||
};
|
||||
|
||||
// Handle page changes
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
newPage,
|
||||
pageSize
|
||||
);
|
||||
};
|
||||
|
||||
// Handle page size changes
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(0); // Reset to first page when changing page size
|
||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||
};
|
||||
|
||||
// Handle filter changes generically
|
||||
const handleFilterChange = (
|
||||
filterType: keyof typeof filters,
|
||||
value: string | undefined
|
||||
) => {
|
||||
// Create new filters object with updated value
|
||||
const newFilters = {
|
||||
...filters,
|
||||
[filterType]: value
|
||||
};
|
||||
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
|
||||
// Update URL params
|
||||
updateUrlParamsForAllFilters(newFilters);
|
||||
|
||||
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
0,
|
||||
pageSize,
|
||||
newFilters
|
||||
);
|
||||
};
|
||||
|
||||
const updateUrlParamsForAllFilters = (
|
||||
newFilters:
|
||||
| typeof filters
|
||||
| {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
Object.entries(newFilters).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
});
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const queryDateTime = async (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue,
|
||||
page: number = currentPage,
|
||||
size: number = pageSize,
|
||||
filtersParam?: typeof filters
|
||||
) => {
|
||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||
if (!isPaidUser(tierMatrix.connectionLogs)) {
|
||||
console.log(
|
||||
"Access denied: subscription inactive or license locked"
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Use the provided filters or fall back to current state
|
||||
const activeFilters = filtersParam || filters;
|
||||
|
||||
// Convert the date/time values to API parameters
|
||||
const params: any = {
|
||||
limit: size,
|
||||
offset: page * size,
|
||||
...activeFilters
|
||||
};
|
||||
|
||||
if (startDate?.date) {
|
||||
const startDateTime = new Date(startDate.date);
|
||||
if (startDate.time) {
|
||||
const [hours, minutes, seconds] = startDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
startDateTime.setHours(hours, minutes, seconds || 0);
|
||||
}
|
||||
params.timeStart = startDateTime.toISOString();
|
||||
}
|
||||
|
||||
if (endDate?.date) {
|
||||
const endDateTime = new Date(endDate.date);
|
||||
if (endDate.time) {
|
||||
const [hours, minutes, seconds] = endDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
endDateTime.setHours(hours, minutes, seconds || 0);
|
||||
} else {
|
||||
// If no time is specified, set to NOW
|
||||
const now = new Date();
|
||||
endDateTime.setHours(
|
||||
now.getHours(),
|
||||
now.getMinutes(),
|
||||
now.getSeconds(),
|
||||
now.getMilliseconds()
|
||||
);
|
||||
}
|
||||
params.timeEnd = endDateTime.toISOString();
|
||||
}
|
||||
|
||||
const res = await api.get(`/org/${orgId}/logs/connection`, {
|
||||
params
|
||||
});
|
||||
if (res.status === 200) {
|
||||
setRows(res.data.data.log || []);
|
||||
setTotalCount(res.data.data.pagination?.total || 0);
|
||||
setFilterAttributes(res.data.data.filterAttributes);
|
||||
console.log("Fetched connection logs:", res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("Failed to filter logs"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshData = async () => {
|
||||
console.log("Data refreshed");
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
// Refresh data with current date range and pagination
|
||||
await queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
currentPage,
|
||||
pageSize
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportData = async () => {
|
||||
try {
|
||||
// Prepare query params for export
|
||||
const params: any = {
|
||||
timeStart: dateRange.startDate?.date
|
||||
? new Date(dateRange.startDate.date).toISOString()
|
||||
: undefined,
|
||||
timeEnd: dateRange.endDate?.date
|
||||
? new Date(dateRange.endDate.date).toISOString()
|
||||
: undefined,
|
||||
...filters
|
||||
};
|
||||
|
||||
const response = await api.get(
|
||||
`/org/${orgId}/logs/connection/export`,
|
||||
{
|
||||
responseType: "blob",
|
||||
params
|
||||
}
|
||||
);
|
||||
|
||||
// Create a URL for the blob and trigger a download
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
const epoch = Math.floor(Date.now() / 1000);
|
||||
link.setAttribute(
|
||||
"download",
|
||||
`connection-audit-logs-${orgId}-${epoch}.csv`
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode?.removeChild(link);
|
||||
} catch (error) {
|
||||
let apiErrorMessage: string | null = null;
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
const data = error.response.data;
|
||||
|
||||
if (data instanceof Blob && data.type === "application/json") {
|
||||
// Parse the Blob as JSON
|
||||
const text = await data.text();
|
||||
const errorData = JSON.parse(text);
|
||||
apiErrorMessage = errorData.message;
|
||||
}
|
||||
}
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: apiErrorMessage ?? t("exportError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: "startedAt",
|
||||
header: ({ column }) => {
|
||||
return t("timestamp");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="whitespace-nowrap">
|
||||
{new Date(
|
||||
row.original.startedAt * 1000
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "protocol",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("protocol")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.protocols.map(
|
||||
(protocol) => ({
|
||||
label: protocol.toUpperCase(),
|
||||
value: protocol
|
||||
})
|
||||
)}
|
||||
selectedValue={filters.protocol}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("protocol", value)
|
||||
}
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="whitespace-nowrap font-mono text-xs">
|
||||
{row.original.protocol?.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "resourceName",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("resource")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.resources.map((res) => ({
|
||||
value: res.id.toString(),
|
||||
label: res.name || "Unnamed Resource"
|
||||
}))}
|
||||
selectedValue={filters.siteResourceId}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("siteResourceId", value)
|
||||
}
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
if (row.original.resourceName && row.original.resourceNiceId) {
|
||||
return (
|
||||
<Link
|
||||
href={`/${row.original.orgId}/settings/resources/client/?query=${row.original.resourceNiceId}`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-6"
|
||||
>
|
||||
{row.original.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="whitespace-nowrap">
|
||||
{row.original.resourceName ?? "—"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "clientName",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("client")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.clients.map((c) => ({
|
||||
value: c.id.toString(),
|
||||
label: c.name
|
||||
}))}
|
||||
selectedValue={filters.clientId}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("clientId", value)
|
||||
}
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const clientType = row.original.clientType === "olm" ? "machine" : "user";
|
||||
if (row.original.clientName && row.original.clientNiceId) {
|
||||
return (
|
||||
<Link
|
||||
href={`/${row.original.orgId}/settings/clients/${clientType}/${row.original.clientNiceId}`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-6"
|
||||
>
|
||||
<Laptop className="mr-1 h-3 w-3" />
|
||||
{row.original.clientName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="whitespace-nowrap">
|
||||
{row.original.clientName ?? "—"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "userEmail",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("user")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.users.map((u) => ({
|
||||
value: u.id,
|
||||
label: u.email || u.id
|
||||
}))}
|
||||
selectedValue={filters.userId}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("userId", value)
|
||||
}
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
if (row.original.userEmail || row.original.userId) {
|
||||
return (
|
||||
<span className="flex items-center gap-1 whitespace-nowrap">
|
||||
<User className="h-4 w-4" />
|
||||
{row.original.userEmail ?? row.original.userId}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span>—</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "sourceAddr",
|
||||
header: ({ column }) => {
|
||||
return t("sourceAddress");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="whitespace-nowrap font-mono text-xs">
|
||||
{row.original.sourceAddr}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "destAddr",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("destinationAddress")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.destAddrs.map((addr) => ({
|
||||
value: addr,
|
||||
label: addr
|
||||
}))}
|
||||
selectedValue={filters.destAddr}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("destAddr", value)
|
||||
}
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="whitespace-nowrap font-mono text-xs">
|
||||
{row.original.destAddr}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "duration",
|
||||
header: ({ column }) => {
|
||||
return t("duration");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="whitespace-nowrap">
|
||||
{formatDuration(
|
||||
row.original.startedAt,
|
||||
row.original.endedAt
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const renderExpandedRow = (row: any) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
||||
<div className="space-y-2">
|
||||
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
||||
Connection Details
|
||||
</div>*/}
|
||||
<div>
|
||||
<strong>Session ID:</strong>{" "}
|
||||
<span className="font-mono">
|
||||
{row.sessionId ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Protocol:</strong>{" "}
|
||||
{row.protocol?.toUpperCase() ?? "—"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Source:</strong>{" "}
|
||||
<span className="font-mono">
|
||||
{row.sourceAddr ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Destination:</strong>{" "}
|
||||
<span className="font-mono">
|
||||
{row.destAddr ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
||||
Resource & Site
|
||||
</div>*/}
|
||||
{/*<div>
|
||||
<strong>Resource:</strong>{" "}
|
||||
{row.resourceName ?? "—"}
|
||||
{row.resourceNiceId && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({row.resourceNiceId})
|
||||
</span>
|
||||
)}
|
||||
</div>*/}
|
||||
<div>
|
||||
<strong>Site:</strong> {row.siteName ?? "—"}
|
||||
{row.siteNiceId && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({row.siteNiceId})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Site ID:</strong> {row.siteId ?? "—"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Started At:</strong>{" "}
|
||||
{row.startedAt
|
||||
? new Date(
|
||||
row.startedAt * 1000
|
||||
).toLocaleString()
|
||||
: "—"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Ended At:</strong>{" "}
|
||||
{row.endedAt
|
||||
? new Date(
|
||||
row.endedAt * 1000
|
||||
).toLocaleString()
|
||||
: "Active"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Duration:</strong>{" "}
|
||||
{formatDuration(row.startedAt, row.endedAt)}
|
||||
</div>
|
||||
{/*<div>
|
||||
<strong>Resource ID:</strong>{" "}
|
||||
{row.siteResourceId ?? "—"}
|
||||
</div>*/}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
||||
Client & Transfer
|
||||
</div>*/}
|
||||
{/*<div>
|
||||
<strong>Bytes Sent (TX):</strong>{" "}
|
||||
{formatBytes(row.bytesTx)}
|
||||
</div>*/}
|
||||
{/*<div>
|
||||
<strong>Bytes Received (RX):</strong>{" "}
|
||||
{formatBytes(row.bytesRx)}
|
||||
</div>*/}
|
||||
{/*<div>
|
||||
<strong>Total Transfer:</strong>{" "}
|
||||
{formatBytes(
|
||||
(row.bytesTx ?? 0) + (row.bytesRx ?? 0)
|
||||
)}
|
||||
</div>*/}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("connectionLogs")}
|
||||
description={t("connectionLogsDescription")}
|
||||
/>
|
||||
|
||||
<PaidFeaturesAlert tiers={tierMatrix.connectionLogs} />
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
title={t("connectionLogs")}
|
||||
searchPlaceholder={t("searchLogs")}
|
||||
searchColumn="protocol"
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
onExport={() => startTransition(exportData)}
|
||||
isExporting={isExporting}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
dateRange={{
|
||||
start: dateRange.startDate,
|
||||
end: dateRange.endDate
|
||||
}}
|
||||
defaultSort={{
|
||||
id: "startedAt",
|
||||
desc: true
|
||||
}}
|
||||
// Server-side pagination props
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
isLoading={isLoading}
|
||||
// Row expansion props
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
disabled={
|
||||
!isPaidUser(tierMatrix.connectionLogs) || build === "oss"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
481
src/app/[orgId]/settings/logs/streaming/page.tsx
Normal file
481
src/app/[orgId]/settings/logs/streaming/page.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { Globe, MoreHorizontal, Plus } from "lucide-react";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { build } from "@server/build";
|
||||
import Image from "next/image";
|
||||
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import {
|
||||
Destination,
|
||||
HttpDestinationCredenza,
|
||||
parseHttpConfig
|
||||
} from "@app/components/HttpDestinationCredenza";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
// ── Re-export Destination so the rest of the file can use it ──────────────────
|
||||
|
||||
interface ListDestinationsResponse {
|
||||
destinations: Destination[];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Destination card ───────────────────────────────────────────────────────────
|
||||
|
||||
interface DestinationCardProps {
|
||||
destination: Destination;
|
||||
onToggle: (id: number, enabled: boolean) => void;
|
||||
onEdit: (destination: Destination) => void;
|
||||
onDelete: (destination: Destination) => void;
|
||||
isToggling: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function DestinationCard({
|
||||
destination,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isToggling,
|
||||
disabled = false
|
||||
}: DestinationCardProps) {
|
||||
const t = useTranslations();
|
||||
const cfg = parseHttpConfig(destination.config);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col rounded-lg border bg-card text-card-foreground p-5 gap-3">
|
||||
{/* Top row: icon + name/type + toggle */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{/* Squirkle icon: gray outer → white inner → black globe */}
|
||||
<div className="shrink-0 flex items-center justify-center w-10 h-10 rounded-2xl bg-muted">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-xl bg-white shadow-sm">
|
||||
<Globe className="h-3.5 w-3.5 text-black" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-sm leading-tight truncate">
|
||||
{cfg.name || t("streamingUnnamedDestination")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
HTTP
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={destination.enabled}
|
||||
onCheckedChange={(v) =>
|
||||
onToggle(destination.destinationId, v)
|
||||
}
|
||||
disabled={isToggling || disabled}
|
||||
className="shrink-0 mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL preview */}
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{cfg.url || (
|
||||
<span className="italic">{t("streamingNoUrlConfigured")}</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Footer: edit button + three-dots menu */}
|
||||
<div className="mt-auto pt-5 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onEdit(destination)}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
disabled={disabled}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => onDelete(destination)}
|
||||
>
|
||||
{t("delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Add destination card ───────────────────────────────────────────────────────
|
||||
|
||||
function AddDestinationCard({ onClick }: { onClick: () => void }) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-transparent transition-colors p-5 min-h-35 w-full text-muted-foreground hover:border-primary hover:text-primary hover:bg-primary/5 cursor-pointer"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex items-center justify-center w-9 h-9 rounded-md border-2 border-dashed border-current">
|
||||
<Plus className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">{t("streamingAddDestination")}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Destination type picker ────────────────────────────────────────────────────
|
||||
|
||||
type DestinationType = "http" | "s3" | "datadog";
|
||||
|
||||
interface DestinationTypePickerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (type: DestinationType) => void;
|
||||
isPaywalled?: boolean;
|
||||
}
|
||||
|
||||
function DestinationTypePicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
isPaywalled = false
|
||||
}: DestinationTypePickerProps) {
|
||||
const t = useTranslations();
|
||||
const [selected, setSelected] = useState<DestinationType>("http");
|
||||
|
||||
const destinationTypeOptions: ReadonlyArray<StrategyOption<DestinationType>> = [
|
||||
{
|
||||
id: "http",
|
||||
title: t("streamingHttpWebhookTitle"),
|
||||
description: t("streamingHttpWebhookDescription"),
|
||||
icon: <Globe className="h-6 w-6" />
|
||||
},
|
||||
{
|
||||
id: "s3",
|
||||
title: t("streamingS3Title"),
|
||||
description: t("streamingS3Description"),
|
||||
disabled: true,
|
||||
icon: (
|
||||
<Image
|
||||
src="/third-party/s3.png"
|
||||
alt={t("streamingS3Title")}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded-sm"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "datadog",
|
||||
title: t("streamingDatadogTitle"),
|
||||
description: t("streamingDatadogDescription"),
|
||||
disabled: true,
|
||||
icon: (
|
||||
<Image
|
||||
src="/third-party/dd.png"
|
||||
alt={t("streamingDatadogTitle")}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded-sm"
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setSelected("http");
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||
<CredenzaContent className="sm:max-w-lg">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("streamingAddDestination")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("streamingTypePickerDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className={isPaywalled ? "pointer-events-none opacity-50" : ""}>
|
||||
<StrategySelect
|
||||
options={destinationTypeOptions}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
cols={1}
|
||||
/>
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
onClick={() => onSelect(selected)}
|
||||
disabled={isPaywalled}
|
||||
>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function StreamingDestinationsPage() {
|
||||
const { orgId } = useParams() as { orgId: string };
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isEnterprise = isPaidUser(tierMatrix[TierFeature.SIEM]);
|
||||
const t = useTranslations();
|
||||
|
||||
const [destinations, setDestinations] = useState<Destination[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [typePickerOpen, setTypePickerOpen] = useState(false);
|
||||
const [editingDestination, setEditingDestination] =
|
||||
useState<Destination | null>(null);
|
||||
const [togglingIds, setTogglingIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Delete state
|
||||
const [deleteTarget, setDeleteTarget] = useState<Destination | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const loadDestinations = useCallback(async () => {
|
||||
if (build == "oss") {
|
||||
setDestinations([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.get<AxiosResponse<ListDestinationsResponse>>(
|
||||
`/org/${orgId}/event-streaming-destinations`
|
||||
);
|
||||
setDestinations(res.data.data.destinations ?? []);
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("streamingFailedToLoad"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("streamingUnexpectedError")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDestinations();
|
||||
}, [loadDestinations]);
|
||||
|
||||
const handleToggle = async (destinationId: number, enabled: boolean) => {
|
||||
// Optimistic update
|
||||
setDestinations((prev) =>
|
||||
prev.map((d) =>
|
||||
d.destinationId === destinationId ? { ...d, enabled } : d
|
||||
)
|
||||
);
|
||||
setTogglingIds((prev) => new Set(prev).add(destinationId));
|
||||
|
||||
try {
|
||||
await api.post(
|
||||
`/org/${orgId}/event-streaming-destination/${destinationId}`,
|
||||
{ enabled }
|
||||
);
|
||||
} catch (e) {
|
||||
// Revert on failure
|
||||
setDestinations((prev) =>
|
||||
prev.map((d) =>
|
||||
d.destinationId === destinationId
|
||||
? { ...d, enabled: !enabled }
|
||||
: d
|
||||
)
|
||||
);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("streamingFailedToUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("streamingUnexpectedError")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setTogglingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(destinationId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCard = (destination: Destination) => {
|
||||
setDeleteTarget(destination);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.delete(
|
||||
`/org/${orgId}/event-streaming-destination/${deleteTarget.destinationId}`
|
||||
);
|
||||
toast({ title: t("streamingDeletedSuccess") });
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTarget(null);
|
||||
loadDestinations();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("streamingFailedToDelete"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("streamingUnexpectedError")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setTypePickerOpen(true);
|
||||
};
|
||||
|
||||
const handleTypePicked = (_type: DestinationType) => {
|
||||
setTypePickerOpen(false);
|
||||
setEditingDestination(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (destination: Destination) => {
|
||||
setEditingDestination(destination);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("streamingTitle")}
|
||||
description={t("streamingDescription")}
|
||||
/>
|
||||
|
||||
<PaidFeaturesAlert tiers={tierMatrix[TierFeature.SIEM]} />
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border bg-card p-5 min-h-36 animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{destinations.map((dest) => (
|
||||
<DestinationCard
|
||||
key={dest.destinationId}
|
||||
destination={dest}
|
||||
onToggle={handleToggle}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDeleteCard}
|
||||
isToggling={togglingIds.has(dest.destinationId)}
|
||||
disabled={!isEnterprise}
|
||||
/>
|
||||
))}
|
||||
{/* Add card is always clickable — paywall is enforced inside the picker */}
|
||||
<AddDestinationCard onClick={openCreate} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DestinationTypePicker
|
||||
open={typePickerOpen}
|
||||
onOpenChange={setTypePickerOpen}
|
||||
onSelect={handleTypePicked}
|
||||
isPaywalled={!isEnterprise}
|
||||
/>
|
||||
|
||||
<HttpDestinationCredenza
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
editing={editingDestination}
|
||||
orgId={orgId}
|
||||
onSaved={loadDestinations}
|
||||
/>
|
||||
|
||||
{deleteTarget && (
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
setOpen={(v) => {
|
||||
setDeleteDialogOpen(v);
|
||||
if (!v) setDeleteTarget(null);
|
||||
}}
|
||||
string={
|
||||
parseHttpConfig(deleteTarget.config).name || t("streamingDeleteDialogThisDestination")
|
||||
}
|
||||
title={t("streamingDeleteTitle")}
|
||||
dialog={
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("streamingDeleteDialogAreYouSure")}{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{parseHttpConfig(deleteTarget.config).name ||
|
||||
t("streamingDeleteDialogThisDestination")}
|
||||
</span>
|
||||
{t("streamingDeleteDialogPermanentlyRemoved")}
|
||||
</p>
|
||||
}
|
||||
buttonText={t("streamingDeleteButtonText")}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
src/app/[orgId]/settings/provisioning/keys/page.tsx
Normal file
84
src/app/[orgId]/settings/provisioning/keys/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import SiteProvisioningKeysTable, {
|
||||
SiteProvisioningKeyRow
|
||||
} from "@app/components/SiteProvisioningKeysTable";
|
||||
import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import DismissableBanner from "@app/components/DismissableBanner";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, Plug } from "lucide-react";
|
||||
|
||||
type ProvisioningKeysPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ProvisioningKeysPage(
|
||||
props: ProvisioningKeysPageProps
|
||||
) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
let siteProvisioningKeys: ListSiteProvisioningKeysResponse["siteProvisioningKeys"] =
|
||||
[];
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<ListSiteProvisioningKeysResponse>
|
||||
>(
|
||||
`/org/${params.orgId}/site-provisioning-keys`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
siteProvisioningKeys = res.data.data.siteProvisioningKeys;
|
||||
} catch (e) {}
|
||||
|
||||
const rows: SiteProvisioningKeyRow[] = siteProvisioningKeys.map((k) => ({
|
||||
name: k.name,
|
||||
id: k.siteProvisioningKeyId,
|
||||
key: `${k.siteProvisioningKeyId}••••••••••••••••••••${k.lastChars}`,
|
||||
createdAt: k.createdAt,
|
||||
lastUsed: k.lastUsed,
|
||||
maxBatchSize: k.maxBatchSize,
|
||||
numUsed: k.numUsed,
|
||||
validUntil: k.validUntil,
|
||||
approveNewSites: k.approveNewSites
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<DismissableBanner
|
||||
storageKey="sites-banner-dismissed"
|
||||
version={1}
|
||||
title={t("provisioningKeysBannerTitle")}
|
||||
titleIcon={<Plug className="w-5 h-5 text-primary" />}
|
||||
description={t("provisioningKeysBannerDescription")}
|
||||
>
|
||||
<Link
|
||||
href="https://docs.pangolin.net/manage/sites/install-site"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
{t("provisioningKeysBannerButtonText")}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</DismissableBanner>
|
||||
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix[TierFeature.SiteProvisioningKeys]}
|
||||
/>
|
||||
|
||||
<SiteProvisioningKeysTable keys={rows} orgId={params.orgId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
src/app/[orgId]/settings/provisioning/layout.tsx
Normal file
38
src/app/[orgId]/settings/provisioning/layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
interface ProvisioningLayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ orgId: string }>;
|
||||
}
|
||||
|
||||
export default async function ProvisioningLayout({
|
||||
children,
|
||||
params
|
||||
}: ProvisioningLayoutProps) {
|
||||
const { orgId } = await params;
|
||||
const t = await getTranslations();
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: t("provisioningKeys"),
|
||||
href: `/${orgId}/settings/provisioning/keys`
|
||||
},
|
||||
{
|
||||
title: t("pendingSites"),
|
||||
href: `/${orgId}/settings/provisioning/pending`
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("provisioningManage")}
|
||||
description={t("provisioningDescription")}
|
||||
/>
|
||||
|
||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
src/app/[orgId]/settings/provisioning/page.tsx
Normal file
10
src/app/[orgId]/settings/provisioning/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type ProvisioningPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export default async function ProvisioningPage(props: ProvisioningPageProps) {
|
||||
const params = await props.params;
|
||||
redirect(`/${params.orgId}/settings/provisioning/keys`);
|
||||
}
|
||||
110
src/app/[orgId]/settings/provisioning/pending/page.tsx
Normal file
110
src/app/[orgId]/settings/provisioning/pending/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { SiteRow } from "@app/components/SitesTable";
|
||||
import PendingSitesTable from "@app/components/PendingSitesTable";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import DismissableBanner from "@app/components/DismissableBanner";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, Plug } from "lucide-react";
|
||||
|
||||
type PendingSitesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PendingSitesPage(props: PendingSitesPageProps) {
|
||||
const params = await props.params;
|
||||
|
||||
const incomingSearchParams = new URLSearchParams(await props.searchParams);
|
||||
incomingSearchParams.set("status", "pending");
|
||||
|
||||
let sites: ListSitesResponse["sites"] = [];
|
||||
let pagination: ListSitesResponse["pagination"] = {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListSitesResponse>>(
|
||||
`/org/${params.orgId}/sites?${incomingSearchParams.toString()}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
const responseData = res.data.data;
|
||||
sites = responseData.sites;
|
||||
pagination = responseData.pagination;
|
||||
} catch (e) {}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
function formatSize(mb: number, type: string): string {
|
||||
if (type === "local") {
|
||||
return "-";
|
||||
}
|
||||
if (mb >= 1024 * 1024) {
|
||||
return t("terabytes", { count: (mb / (1024 * 1024)).toFixed(2) });
|
||||
} else if (mb >= 1024) {
|
||||
return t("gigabytes", { count: (mb / 1024).toFixed(2) });
|
||||
} else {
|
||||
return t("megabytes", { count: mb.toFixed(2) });
|
||||
}
|
||||
}
|
||||
|
||||
const siteRows: SiteRow[] = sites.map((site) => ({
|
||||
name: site.name,
|
||||
id: site.siteId,
|
||||
nice: site.niceId.toString(),
|
||||
address: site.address?.split("/")[0],
|
||||
mbIn: formatSize(site.megabytesIn || 0, site.type),
|
||||
mbOut: formatSize(site.megabytesOut || 0, site.type),
|
||||
orgId: params.orgId,
|
||||
type: site.type as any,
|
||||
online: site.online,
|
||||
newtVersion: site.newtVersion || undefined,
|
||||
newtUpdateAvailable: site.newtUpdateAvailable || false,
|
||||
exitNodeName: site.exitNodeName || undefined,
|
||||
exitNodeEndpoint: site.exitNodeEndpoint || undefined,
|
||||
remoteExitNodeId: (site as any).remoteExitNodeId || undefined
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<DismissableBanner
|
||||
storageKey="sites-banner-dismissed"
|
||||
version={1}
|
||||
title={t("pendingSitesBannerTitle")}
|
||||
titleIcon={<Plug className="w-5 h-5 text-primary" />}
|
||||
description={t("pendingSitesBannerDescription")}
|
||||
>
|
||||
<Link
|
||||
href="https://docs.pangolin.net/manage/sites/install-site"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
{t("pendingSitesBannerButtonText")}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</DismissableBanner>
|
||||
<PendingSitesTable
|
||||
sites={siteRows}
|
||||
orgId={params.orgId}
|
||||
rowCount={pagination.total}
|
||||
pagination={{
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -129,12 +129,13 @@ export default function ResourceAuthenticationPage() {
|
||||
orgId: org.org.orgId
|
||||
})
|
||||
);
|
||||
const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery(
|
||||
orgQueries.identityProviders({
|
||||
const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery({
|
||||
...orgQueries.identityProviders({
|
||||
orgId: org.org.orgId,
|
||||
useOrgOnlyIdp: env.app.identityProviderMode === "org"
|
||||
})
|
||||
);
|
||||
}),
|
||||
enabled: isPaidUser(tierMatrix.orgOidc)
|
||||
});
|
||||
|
||||
const pageLoading =
|
||||
isLoadingOrgRoles ||
|
||||
|
||||
@@ -124,20 +124,15 @@ export default function ReverseProxyTargetsPage(props: {
|
||||
resourceId: resource.resourceId
|
||||
})
|
||||
);
|
||||
const { data: sites = [], isLoading: isLoadingSites } = useQuery(
|
||||
orgQueries.sites({
|
||||
orgId: params.orgId
|
||||
})
|
||||
);
|
||||
|
||||
if (isLoadingSites || isLoadingTargets) {
|
||||
if (isLoadingTargets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<ProxyResourceTargetsForm
|
||||
sites={sites}
|
||||
orgId={params.orgId}
|
||||
initialTargets={remoteTargets}
|
||||
resource={resource}
|
||||
/>
|
||||
@@ -160,12 +155,12 @@ export default function ReverseProxyTargetsPage(props: {
|
||||
}
|
||||
|
||||
function ProxyResourceTargetsForm({
|
||||
sites,
|
||||
orgId,
|
||||
initialTargets,
|
||||
resource
|
||||
}: {
|
||||
initialTargets: LocalTarget[];
|
||||
sites: ListSitesResponse["sites"];
|
||||
orgId: string;
|
||||
resource: GetResourceResponse;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
@@ -243,17 +238,21 @@ function ProxyResourceTargetsForm({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { data: sites = [] } = useQuery(
|
||||
orgQueries.sites({
|
||||
orgId
|
||||
})
|
||||
);
|
||||
|
||||
const updateTarget = useCallback(
|
||||
(targetId: number, data: Partial<LocalTarget>) => {
|
||||
setTargets((prevTargets) => {
|
||||
const site = sites.find((site) => site.siteId === data.siteId);
|
||||
return prevTargets.map((target) =>
|
||||
target.targetId === targetId
|
||||
? {
|
||||
...target,
|
||||
...data,
|
||||
updated: true,
|
||||
siteType: site ? site.type : target.siteType
|
||||
updated: true
|
||||
}
|
||||
: target
|
||||
);
|
||||
@@ -453,7 +452,7 @@ function ProxyResourceTargetsForm({
|
||||
return (
|
||||
<ResourceTargetAddressItem
|
||||
isHttp={isHttp}
|
||||
sites={sites}
|
||||
orgId={orgId}
|
||||
getDockerStateForSite={getDockerStateForSite}
|
||||
proxyTarget={row.original}
|
||||
refreshContainersForSite={refreshContainersForSite}
|
||||
@@ -619,6 +618,7 @@ function ProxyResourceTargetsForm({
|
||||
method: isHttp ? "http" : null,
|
||||
port: 0,
|
||||
siteId: sites.length > 0 ? sites[0].siteId : 0,
|
||||
siteName: sites.length > 0 ? sites[0].name : "",
|
||||
path: isHttp ? null : null,
|
||||
pathMatchType: isHttp ? null : null,
|
||||
rewritePath: isHttp ? null : null,
|
||||
|
||||
@@ -6,7 +6,9 @@ import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
@@ -76,6 +78,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { COUNTRIES } from "@server/db/countries";
|
||||
import { MAJOR_ASNS } from "@server/db/asns";
|
||||
import { REGIONS, getRegionNameById, isValidRegionId } from "@server/db/regions";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -123,7 +126,10 @@ export default function ResourceRules(props: {
|
||||
const [countrySelectValue, setCountrySelectValue] = useState("");
|
||||
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
|
||||
useState(false);
|
||||
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false);
|
||||
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] =
|
||||
useState(false);
|
||||
const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] =
|
||||
useState(false);
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
@@ -143,14 +149,15 @@ export default function ResourceRules(props: {
|
||||
IP: "IP",
|
||||
CIDR: t("ipAddressRange"),
|
||||
COUNTRY: t("country"),
|
||||
ASN: "ASN"
|
||||
ASN: "ASN",
|
||||
REGION: t("region")
|
||||
} as const;
|
||||
|
||||
const addRuleForm = useForm({
|
||||
resolver: zodResolver(addRuleSchema),
|
||||
defaultValues: {
|
||||
action: "ACCEPT",
|
||||
match: "IP",
|
||||
match: "PATH",
|
||||
value: ""
|
||||
}
|
||||
});
|
||||
@@ -263,6 +270,20 @@ export default function ResourceRules(props: {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
data.match === "REGION" &&
|
||||
!isValidRegionId(data.value)
|
||||
) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("rulesErrorInvalidRegion"),
|
||||
description:
|
||||
t("rulesErrorInvalidRegionDescription") ||
|
||||
"Invalid region."
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// find the highest priority and add one
|
||||
let priority = data.priority;
|
||||
@@ -316,6 +337,8 @@ export default function ResourceRules(props: {
|
||||
return t("rulesMatchCountry");
|
||||
case "ASN":
|
||||
return "Enter an Autonomous System Number (e.g., AS15169 or 15169)";
|
||||
case "REGION":
|
||||
return t("rulesMatchRegion");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,16 +564,12 @@ export default function ResourceRules(props: {
|
||||
<Select
|
||||
defaultValue={row.original.match}
|
||||
onValueChange={(
|
||||
value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN"
|
||||
value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION"
|
||||
) =>
|
||||
updateRule(row.original.ruleId, {
|
||||
match: value,
|
||||
value:
|
||||
value === "COUNTRY"
|
||||
? "US"
|
||||
: value === "ASN"
|
||||
? "AS15169"
|
||||
: row.original.value
|
||||
value === "COUNTRY" ? "US" : value === "ASN" ? "AS15169" : value === "REGION" ? "021" : row.original.value
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -566,6 +585,11 @@ export default function ResourceRules(props: {
|
||||
{RuleMatch.COUNTRY}
|
||||
</SelectItem>
|
||||
)}
|
||||
{isMaxmindAvailable && (
|
||||
<SelectItem value="REGION">
|
||||
{RuleMatch.REGION}
|
||||
</SelectItem>
|
||||
)}
|
||||
{isMaxmindAsnAvailable && (
|
||||
<SelectItem value="ASN">{RuleMatch.ASN}</SelectItem>
|
||||
)}
|
||||
@@ -645,14 +669,14 @@ export default function ResourceRules(props: {
|
||||
>
|
||||
{row.original.value
|
||||
? (() => {
|
||||
const found = MAJOR_ASNS.find(
|
||||
const found = MAJOR_ASNS.find(
|
||||
(asn) =>
|
||||
asn.code ===
|
||||
row.original.value
|
||||
);
|
||||
return found
|
||||
? `${found.name} (${row.original.value})`
|
||||
: `Custom (${row.original.value})`;
|
||||
);
|
||||
return found
|
||||
? `${found.name} (${row.original.value})`
|
||||
: `Custom (${row.original.value})`;
|
||||
})()
|
||||
: "Select ASN"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
@@ -722,6 +746,88 @@ export default function ResourceRules(props: {
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : row.original.match === "REGION" ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="min-w-[200px] justify-between"
|
||||
>
|
||||
{(() => {
|
||||
const regionName = getRegionNameById(row.original.value);
|
||||
if (!regionName) {
|
||||
return t("selectRegion");
|
||||
}
|
||||
return `${t(regionName)} (${row.original.value})`;
|
||||
})()}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="min-w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("searchRegions")}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("noRegionFound")}
|
||||
</CommandEmpty>
|
||||
{REGIONS.map((continent) => (
|
||||
<CommandGroup key={continent.id} heading={t(continent.name)}>
|
||||
<CommandItem
|
||||
value={continent.id}
|
||||
keywords={[
|
||||
t(continent.name),
|
||||
continent.id
|
||||
]}
|
||||
onSelect={() => {
|
||||
updateRule(
|
||||
row.original.ruleId,
|
||||
{ value: continent.id }
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
row.original.value === continent.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{t(continent.name)} ({continent.id})
|
||||
</CommandItem>
|
||||
{continent.includes.map((subregion) => (
|
||||
<CommandItem
|
||||
key={subregion.id}
|
||||
value={subregion.id}
|
||||
keywords={[
|
||||
t(subregion.name),
|
||||
subregion.id
|
||||
]}
|
||||
onSelect={() => {
|
||||
updateRule(
|
||||
row.original.ruleId,
|
||||
{ value: subregion.id }
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
row.original.value === subregion.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{t(subregion.name)} ({subregion.id})
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Input
|
||||
defaultValue={row.original.value}
|
||||
@@ -932,6 +1038,13 @@ export default function ResourceRules(props: {
|
||||
}
|
||||
</SelectItem>
|
||||
)}
|
||||
{isMaxmindAvailable && (
|
||||
<SelectItem value="REGION">
|
||||
{
|
||||
RuleMatch.REGION
|
||||
}
|
||||
</SelectItem>
|
||||
)}
|
||||
{isMaxmindAsnAvailable && (
|
||||
<SelectItem value="ASN">
|
||||
{
|
||||
@@ -1197,6 +1310,112 @@ export default function ResourceRules(props: {
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : addRuleForm.watch(
|
||||
"match"
|
||||
) === "REGION" ? (
|
||||
<Popover
|
||||
open={
|
||||
openAddRuleRegionSelect
|
||||
}
|
||||
onOpenChange={
|
||||
setOpenAddRuleRegionSelect
|
||||
}
|
||||
>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={
|
||||
openAddRuleRegionSelect
|
||||
}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{field.value
|
||||
? (() => {
|
||||
const regionName = getRegionNameById(field.value);
|
||||
const translatedName = regionName ? t(regionName) : field.value;
|
||||
return `${translatedName} (${field.value})`;
|
||||
})()
|
||||
: t(
|
||||
"selectRegion"
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"searchRegions"
|
||||
)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t(
|
||||
"noRegionFound"
|
||||
)}
|
||||
</CommandEmpty>
|
||||
{REGIONS.map((continent) => (
|
||||
<CommandGroup key={continent.id} heading={t(continent.name)}>
|
||||
<CommandItem
|
||||
value={continent.id}
|
||||
keywords={[
|
||||
t(continent.name),
|
||||
continent.id
|
||||
]}
|
||||
onSelect={() => {
|
||||
field.onChange(
|
||||
continent.id
|
||||
);
|
||||
setOpenAddRuleRegionSelect(
|
||||
false
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
field.value === continent.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{t(continent.name)} ({continent.id})
|
||||
</CommandItem>
|
||||
{continent.includes.map((subregion) => (
|
||||
<CommandItem
|
||||
key={subregion.id}
|
||||
value={subregion.id}
|
||||
keywords={[
|
||||
t(subregion.name),
|
||||
subregion.id
|
||||
]}
|
||||
onSelect={() => {
|
||||
field.onChange(
|
||||
subregion.id
|
||||
);
|
||||
setOpenAddRuleRegionSelect(
|
||||
false
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
field.value === subregion.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{t(subregion.name)} ({subregion.id})
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Input {...field} />
|
||||
)}
|
||||
|
||||
@@ -216,9 +216,7 @@ export default function Page() {
|
||||
const [remoteExitNodes, setRemoteExitNodes] = useState<
|
||||
ListRemoteExitNodesResponse["remoteExitNodes"]
|
||||
>([]);
|
||||
const [loadingExitNodes, setLoadingExitNodes] = useState(
|
||||
build === "saas"
|
||||
);
|
||||
const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas");
|
||||
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [showSnippets, setShowSnippets] = useState(false);
|
||||
@@ -282,6 +280,7 @@ export default function Page() {
|
||||
method: isHttp ? "http" : null,
|
||||
port: 0,
|
||||
siteId: sites.length > 0 ? sites[0].siteId : 0,
|
||||
siteName: sites.length > 0 ? sites[0].name : "",
|
||||
path: isHttp ? null : null,
|
||||
pathMatchType: isHttp ? null : null,
|
||||
rewritePath: isHttp ? null : null,
|
||||
@@ -336,8 +335,7 @@ export default function Page() {
|
||||
|
||||
// In saas mode with no exit nodes, force HTTP
|
||||
const showTypeSelector =
|
||||
build !== "saas" ||
|
||||
(!loadingExitNodes && remoteExitNodes.length > 0);
|
||||
build !== "saas" || (!loadingExitNodes && remoteExitNodes.length > 0);
|
||||
|
||||
const baseForm = useForm({
|
||||
resolver: zodResolver(baseResourceFormSchema),
|
||||
@@ -600,7 +598,10 @@ export default function Page() {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorCreate"),
|
||||
description: formatAxiosError(e, t("resourceErrorCreateMessageDescription"))
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("resourceErrorCreateMessageDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -826,7 +827,8 @@ export default function Page() {
|
||||
cell: ({ row }) => (
|
||||
<ResourceTargetAddressItem
|
||||
isHttp={isHttp}
|
||||
sites={sites}
|
||||
orgId={orgId!.toString()}
|
||||
// sites={sites}
|
||||
getDockerStateForSite={getDockerStateForSite}
|
||||
proxyTarget={row.original}
|
||||
refreshContainersForSite={refreshContainersForSite}
|
||||
|
||||
@@ -9,7 +9,7 @@ import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { ListAccessTokensResponse } from "@server/routers/accessToken";
|
||||
import ShareLinksTable, {
|
||||
ShareLinkRow
|
||||
} from "../../../../components/ShareLinksTable";
|
||||
} from "@app/components/ShareLinksTable";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
type ShareLinksPageProps = {
|
||||
|
||||
@@ -39,6 +39,7 @@ import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { NewtSiteInstallCommands } from "@app/components/newt-install-commands";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
export default function CredentialsPage() {
|
||||
const { env } = useEnvContext();
|
||||
@@ -100,7 +101,9 @@ export default function CredentialsPage() {
|
||||
generatedPublicKey = generatedKeypair.publicKey;
|
||||
setPublicKey(generatedPublicKey);
|
||||
|
||||
const res = await api.get(`/org/${orgId}/pick-site-defaults`);
|
||||
const res = await api.get<
|
||||
AxiosResponse<PickSiteDefaultsResponse>
|
||||
>(`/org/${orgId}/pick-site-defaults`);
|
||||
if (res && res.status === 200) {
|
||||
const data = res.data.data;
|
||||
setSiteDefaults(data);
|
||||
@@ -108,7 +111,7 @@ export default function CredentialsPage() {
|
||||
// generate config with the fetched data
|
||||
generatedWgConfig = generateWireGuardConfig(
|
||||
generatedKeypair.privateKey,
|
||||
data.publicKey,
|
||||
generatedKeypair.publicKey,
|
||||
data.subnet,
|
||||
data.address,
|
||||
data.endpoint,
|
||||
@@ -322,7 +325,7 @@ export default function CredentialsPage() {
|
||||
{!loadingDefaults && (
|
||||
<>
|
||||
{wgConfig ? (
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||
<div className="flex flex-col lg:flex-row items-center gap-4">
|
||||
<CopyTextBox
|
||||
text={wgConfig}
|
||||
outline={true}
|
||||
@@ -342,25 +345,20 @@ export default function CredentialsPage() {
|
||||
text={generateObfuscatedWireGuardConfig(
|
||||
{
|
||||
subnet:
|
||||
siteDefaults?.subnet ||
|
||||
site?.subnet ||
|
||||
siteDefaults?.subnet ||
|
||||
null,
|
||||
address:
|
||||
siteDefaults?.address ||
|
||||
site?.address ||
|
||||
siteDefaults?.address ||
|
||||
null,
|
||||
endpoint:
|
||||
siteDefaults?.endpoint ||
|
||||
site?.endpoint ||
|
||||
siteDefaults?.endpoint ||
|
||||
null,
|
||||
listenPort:
|
||||
siteDefaults?.listenPort ||
|
||||
site?.listenPort ||
|
||||
null,
|
||||
publicKey:
|
||||
siteDefaults?.publicKey ||
|
||||
site?.publicKey ||
|
||||
site?.pubKey ||
|
||||
siteDefaults?.listenPort ||
|
||||
null
|
||||
}
|
||||
)}
|
||||
|
||||
@@ -6,9 +6,9 @@ import { redirect } from "next/navigation";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import SiteInfoCard from "../../../../../components/SiteInfoCard";
|
||||
import SiteInfoCard from "@app/components/SiteInfoCard";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { build } from "@server/build";
|
||||
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
@@ -18,6 +18,7 @@ export default async function SitesPage(props: SitesPageProps) {
|
||||
const params = await props.params;
|
||||
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
searchParams.set("status", "approved");
|
||||
|
||||
let sites: ListSitesResponse["sites"] = [];
|
||||
let pagination: ListSitesResponse["pagination"] = {
|
||||
|
||||
Reference in New Issue
Block a user