Merge pull request #2752 from fosrl/dev

1.17.0-rc.0
This commit is contained in:
Owen Schwartz
2026-03-31 15:24:25 -07:00
committed by GitHub
231 changed files with 19436 additions and 2555 deletions

View File

@@ -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"

View File

@@ -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>

View File

@@ -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)
};
});

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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
};
});

View File

@@ -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";

View File

@@ -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";

View File

@@ -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>

View File

@@ -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>;
}
},
{

View 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"
}
/>
</>
);
}

View 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}
/>
)}
</>
);
}

View 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} />
</>
);
}

View 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>
</>
);
}

View 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`);
}

View 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
}}
/>
</>
);
}

View File

@@ -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 ||

View File

@@ -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,

View File

@@ -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} />
)}

View File

@@ -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}

View File

@@ -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 = {

View File

@@ -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
}
)}

View File

@@ -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;

View File

@@ -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"] = {