mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-01 00:06:38 +00:00
Merge branch 'dev' into feat-blueprint-ui-on-dashboard
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import { internal } from "@app/lib/api";
|
||||
import { formatAxiosError, internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import {
|
||||
CheckOrgUserAccessResponse,
|
||||
GetOrgResponse,
|
||||
ListUserOrgsResponse
|
||||
} from "@server/routers/org";
|
||||
import { GetOrgUserResponse } from "@server/routers/user";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -11,6 +15,9 @@ import SubscriptionStatusProvider from "@app/providers/SubscriptionStatusProvide
|
||||
import { GetOrgSubscriptionResponse } from "@server/routers/billing/types";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { build } from "@server/build";
|
||||
import OrgPolicyResult from "@app/components/OrgPolicyResult";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { Layout } from "@app/components/Layout";
|
||||
|
||||
export default async function OrgLayout(props: {
|
||||
children: React.ReactNode;
|
||||
@@ -32,25 +39,46 @@ export default async function OrgLayout(props: {
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
let accessRes: CheckOrgUserAccessResponse | null = null;
|
||||
try {
|
||||
const getOrgUser = cache(() =>
|
||||
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
||||
`/org/${orgId}/user/${user.userId}`,
|
||||
const checkOrgAccess = cache(() =>
|
||||
internal.get<AxiosResponse<CheckOrgUserAccessResponse>>(
|
||||
`/org/${orgId}/user/${user.userId}/check`,
|
||||
cookie
|
||||
)
|
||||
);
|
||||
const orgUser = await getOrgUser();
|
||||
} catch {
|
||||
const res = await checkOrgAccess();
|
||||
accessRes = res.data.data;
|
||||
} catch (e) {
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
try {
|
||||
const getOrg = cache(() =>
|
||||
internal.get<AxiosResponse<GetOrgResponse>>(`/org/${orgId}`, cookie)
|
||||
if (!accessRes?.allowed) {
|
||||
// For non-admin users, show the member resources portal
|
||||
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||
try {
|
||||
const getOrgs = cache(async () =>
|
||||
internal.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||
`/user/${user.userId}/orgs`,
|
||||
await authCookieHeader()
|
||||
)
|
||||
);
|
||||
const res = await getOrgs();
|
||||
if (res && res.data.data.orgs) {
|
||||
orgs = res.data.data.orgs;
|
||||
}
|
||||
} catch (e) {}
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
|
||||
<OrgPolicyResult
|
||||
orgId={orgId}
|
||||
userId={user.userId}
|
||||
accessRes={accessRes}
|
||||
/>
|
||||
</Layout>
|
||||
</UserProvider>
|
||||
);
|
||||
await getOrg();
|
||||
} catch {
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
let subscriptionStatus = null;
|
||||
|
||||
@@ -72,12 +72,11 @@ interface TunnelTypeOption {
|
||||
}
|
||||
|
||||
type Commands = {
|
||||
mac: Record<string, string[]>;
|
||||
linux: Record<string, string[]>;
|
||||
unix: Record<string, string[]>;
|
||||
windows: Record<string, string[]>;
|
||||
};
|
||||
|
||||
const platforms = ["linux", "mac", "windows"] as const;
|
||||
const platforms = ["unix", "windows"] as const;
|
||||
|
||||
type Platform = (typeof platforms)[number];
|
||||
|
||||
@@ -128,8 +127,8 @@ export default function Page() {
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const [platform, setPlatform] = useState<Platform>("linux");
|
||||
const [architecture, setArchitecture] = useState("amd64");
|
||||
const [platform, setPlatform] = useState<Platform>("unix");
|
||||
const [architecture, setArchitecture] = useState("All");
|
||||
const [commands, setCommands] = useState<Commands | null>(null);
|
||||
|
||||
const [olmId, setOlmId] = useState("");
|
||||
@@ -148,43 +147,15 @@ export default function Page() {
|
||||
version: string
|
||||
) => {
|
||||
const commands = {
|
||||
mac: {
|
||||
"Apple Silicon (arm64)": [
|
||||
`curl -fsSL https://pangolin.net/get-olm.sh | bash`,
|
||||
`sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
"Intel x64 (amd64)": [
|
||||
`curl -fsSL https://pangolin.net/get-olm.sh | bash`,
|
||||
`sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
},
|
||||
linux: {
|
||||
amd64: [
|
||||
`curl -fsSL https://pangolin.net/get-olm.sh | bash`,
|
||||
`sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
arm64: [
|
||||
`curl -fsSL https://pangolin.net/get-olm.sh | bash`,
|
||||
`sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
arm32: [
|
||||
`curl -fsSL https://pangolin.net/get-olm.sh | bash`,
|
||||
`sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
arm32v6: [
|
||||
`curl -fsSL https://pangolin.net/get-olm.sh | bash`,
|
||||
`sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
riscv64: [
|
||||
unix: {
|
||||
All: [
|
||||
`curl -fsSL https://pangolin.net/get-olm.sh | bash`,
|
||||
`sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
},
|
||||
windows: {
|
||||
x64: [
|
||||
`# Download and run the installer`,
|
||||
`curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`,
|
||||
`# Then run olm with your credentials`,
|
||||
`olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
}
|
||||
@@ -194,10 +165,8 @@ export default function Page() {
|
||||
|
||||
const getArchitectures = () => {
|
||||
switch (platform) {
|
||||
case "linux":
|
||||
return ["amd64", "arm64", "arm32", "arm32v6", "riscv64"];
|
||||
case "mac":
|
||||
return ["Apple Silicon (arm64)", "Intel x64 (amd64)"];
|
||||
case "unix":
|
||||
return ["All"];
|
||||
case "windows":
|
||||
return ["x64"];
|
||||
default:
|
||||
@@ -209,12 +178,12 @@ export default function Page() {
|
||||
switch (platformName) {
|
||||
case "windows":
|
||||
return "Windows";
|
||||
case "mac":
|
||||
return "macOS";
|
||||
case "unix":
|
||||
return "Unix & macOS";
|
||||
case "docker":
|
||||
return "Docker";
|
||||
default:
|
||||
return "Linux";
|
||||
return "Unix & macOS";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -249,8 +218,8 @@ export default function Page() {
|
||||
switch (platformName) {
|
||||
case "windows":
|
||||
return <FaWindows className="h-4 w-4 mr-2" />;
|
||||
case "mac":
|
||||
return <FaApple className="h-4 w-4 mr-2" />;
|
||||
case "unix":
|
||||
return <Terminal className="h-4 w-4 mr-2" />;
|
||||
case "docker":
|
||||
return <FaDocker className="h-4 w-4 mr-2" />;
|
||||
case "kubernetes":
|
||||
|
||||
@@ -44,7 +44,9 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
||||
mbIn: formatSize(client.megabytesIn || 0),
|
||||
mbOut: formatSize(client.megabytesOut || 0),
|
||||
orgId: params.orgId,
|
||||
online: client.online
|
||||
online: client.online,
|
||||
olmVersion: client.olmVersion || undefined,
|
||||
olmUpdateAvailable: client.olmUpdateAvailable || false,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
32
src/app/[orgId]/settings/domains/[domainId]/layout.tsx
Normal file
32
src/app/[orgId]/settings/domains/[domainId]/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { GetDomainResponse } from "@server/routers/domain/getDomain";
|
||||
import { AxiosResponse } from "axios";
|
||||
import DomainProvider from "@app/providers/DomainProvider";
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ domainId: string; orgId: string }>;
|
||||
}
|
||||
|
||||
export default async function SettingsLayout({ children, params }: SettingsLayoutProps) {
|
||||
const { domainId, orgId } = await params;
|
||||
let domain = null;
|
||||
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<GetDomainResponse>>(
|
||||
`/org/${orgId}/domain/${domainId}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
domain = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${orgId}/settings/domains`);
|
||||
}
|
||||
|
||||
return (
|
||||
<DomainProvider domain={domain} orgId={orgId}>
|
||||
{children}
|
||||
</DomainProvider>
|
||||
);
|
||||
}
|
||||
109
src/app/[orgId]/settings/domains/[domainId]/page.tsx
Normal file
109
src/app/[orgId]/settings/domains/[domainId]/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import DomainInfoCard from "@app/components/DomainInfoCard";
|
||||
import { useDomain } from "@app/contexts/domainContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function DomainSettingsPage() {
|
||||
const { domain, orgId } = useDomain();
|
||||
const router = useRouter();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [restartingDomains, setRestartingDomains] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const refreshData = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const restartDomain = async (domainId: string) => {
|
||||
setRestartingDomains((prev) => new Set(prev).add(domainId));
|
||||
try {
|
||||
await api.post(`/org/${orgId}/domain/${domainId}/restart`);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("domainRestartedDescription", {
|
||||
fallback: "Domain verification restarted successfully"
|
||||
})
|
||||
});
|
||||
refreshData();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setRestartingDomains((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(domainId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!domain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isRestarting = restartingDomains.has(domain.domainId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<SettingsSectionTitle
|
||||
title={domain.baseDomain}
|
||||
description={t("domainSettingDescription")}
|
||||
/>
|
||||
{env.flags.usePangolinDns && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => restartDomain(domain.domainId)}
|
||||
disabled={isRestarting}
|
||||
>
|
||||
{isRestarting ? (
|
||||
<>
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("restarting", { fallback: "Restarting..." })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("restart", { fallback: "Restart" })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<DomainInfoCard orgId={orgId} domainId={domain.domainId} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export default async function DomainsPage(props: Props) {
|
||||
title={t("domains")}
|
||||
description={t("domainsDescription")}
|
||||
/>
|
||||
<DomainsTable domains={domains} />
|
||||
<DomainsTable domains={domains} orgId={org.org.orgId} />
|
||||
</OrgProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,13 @@ import {
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -42,15 +49,62 @@ import {
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||
|
||||
// Session length options in hours
|
||||
const SESSION_LENGTH_OPTIONS = [
|
||||
{ value: null, labelKey: "unenforced" },
|
||||
{ value: 1, labelKey: "1Hour" },
|
||||
{ value: 3, labelKey: "3Hours" },
|
||||
{ value: 6, labelKey: "6Hours" },
|
||||
{ value: 12, labelKey: "12Hours" },
|
||||
{ value: 24, labelKey: "1DaySession" },
|
||||
{ value: 72, labelKey: "3Days" },
|
||||
{ value: 168, labelKey: "7Days" },
|
||||
{ value: 336, labelKey: "14Days" },
|
||||
{ value: 720, labelKey: "30DaysSession" },
|
||||
{ value: 2160, labelKey: "90DaysSession" },
|
||||
{ value: 4320, labelKey: "180DaysSession" }
|
||||
];
|
||||
|
||||
// Password expiry options in days - will be translated in component
|
||||
const PASSWORD_EXPIRY_OPTIONS = [
|
||||
{ value: null, labelKey: "neverExpire" },
|
||||
{ value: 1, labelKey: "1Day" },
|
||||
{ value: 30, labelKey: "30Days" },
|
||||
{ value: 60, labelKey: "60Days" },
|
||||
{ value: 90, labelKey: "90Days" },
|
||||
{ value: 180, labelKey: "180Days" },
|
||||
{ value: 365, labelKey: "1Year" }
|
||||
];
|
||||
|
||||
// Schema for general organization settings
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string(),
|
||||
subnet: z.string().optional()
|
||||
subnet: z.string().optional(),
|
||||
requireTwoFactor: z.boolean().optional(),
|
||||
maxSessionLengthHours: z.number().nullable().optional(),
|
||||
passwordExpiryDays: z.number().nullable().optional(),
|
||||
settingsLogRetentionDaysRequest: z.number(),
|
||||
settingsLogRetentionDaysAccess: z.number(),
|
||||
settingsLogRetentionDaysAction: z.number()
|
||||
});
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
|
||||
const LOG_RETENTION_OPTIONS = [
|
||||
{ label: "logRetentionDisabled", value: 0 },
|
||||
{ label: "logRetention3Days", value: 3 },
|
||||
{ label: "logRetention7Days", value: 7 },
|
||||
{ label: "logRetention14Days", value: 14 },
|
||||
{ label: "logRetention30Days", value: 30 },
|
||||
{ label: "logRetention90Days", value: 90 },
|
||||
...(build != "saas" ? [{ label: "logRetentionForever", value: -1 }] : [])
|
||||
];
|
||||
|
||||
export default function GeneralPage() {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const { orgUser } = userOrgUserContext();
|
||||
@@ -60,20 +114,61 @@ export default function GeneralPage() {
|
||||
const { user } = useUserContext();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
||||
const subscription = useSubscriptionStatusContext();
|
||||
|
||||
// Check if security features are disabled due to licensing/subscription
|
||||
const isSecurityFeatureDisabled = () => {
|
||||
const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked();
|
||||
const isSaasNotSubscribed =
|
||||
build === "saas" && !subscription?.isSubscribed();
|
||||
return isEnterpriseNotLicensed || isSaasNotSubscribed;
|
||||
};
|
||||
|
||||
const [loadingDelete, setLoadingDelete] = useState(false);
|
||||
const [loadingSave, setLoadingSave] = useState(false);
|
||||
const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] =
|
||||
useState(false);
|
||||
const authPageSettingsRef = useRef<AuthPageSettingsRef>(null);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(GeneralFormSchema),
|
||||
defaultValues: {
|
||||
name: org?.org.name,
|
||||
subnet: org?.org.subnet || "" // Add default value for subnet
|
||||
subnet: org?.org.subnet || "", // Add default value for subnet
|
||||
requireTwoFactor: org?.org.requireTwoFactor || false,
|
||||
maxSessionLengthHours: org?.org.maxSessionLengthHours || null,
|
||||
passwordExpiryDays: org?.org.passwordExpiryDays || null,
|
||||
settingsLogRetentionDaysRequest:
|
||||
org.org.settingsLogRetentionDaysRequest ?? 15,
|
||||
settingsLogRetentionDaysAccess:
|
||||
org.org.settingsLogRetentionDaysAccess ?? 15,
|
||||
settingsLogRetentionDaysAction:
|
||||
org.org.settingsLogRetentionDaysAction ?? 15
|
||||
},
|
||||
mode: "onChange"
|
||||
});
|
||||
|
||||
// Track initial security policy values
|
||||
const initialSecurityValues = {
|
||||
requireTwoFactor: org?.org.requireTwoFactor || false,
|
||||
maxSessionLengthHours: org?.org.maxSessionLengthHours || null,
|
||||
passwordExpiryDays: org?.org.passwordExpiryDays || null
|
||||
};
|
||||
|
||||
// Check if security policies have changed
|
||||
const hasSecurityPolicyChanged = () => {
|
||||
const currentValues = form.getValues();
|
||||
return (
|
||||
currentValues.requireTwoFactor !==
|
||||
initialSecurityValues.requireTwoFactor ||
|
||||
currentValues.maxSessionLengthHours !==
|
||||
initialSecurityValues.maxSessionLengthHours ||
|
||||
currentValues.passwordExpiryDays !==
|
||||
initialSecurityValues.passwordExpiryDays
|
||||
);
|
||||
};
|
||||
|
||||
async function deleteOrg() {
|
||||
setLoadingDelete(true);
|
||||
try {
|
||||
@@ -126,14 +221,36 @@ export default function GeneralPage() {
|
||||
}
|
||||
|
||||
async function onSubmit(data: GeneralFormValues) {
|
||||
// Check if security policies have changed
|
||||
if (hasSecurityPolicyChanged()) {
|
||||
setIsSecurityPolicyConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await performSave(data);
|
||||
}
|
||||
|
||||
async function performSave(data: GeneralFormValues) {
|
||||
setLoadingSave(true);
|
||||
|
||||
try {
|
||||
const reqData = {
|
||||
name: data.name,
|
||||
settingsLogRetentionDaysRequest:
|
||||
data.settingsLogRetentionDaysRequest,
|
||||
settingsLogRetentionDaysAccess:
|
||||
data.settingsLogRetentionDaysAccess,
|
||||
settingsLogRetentionDaysAction:
|
||||
data.settingsLogRetentionDaysAction
|
||||
} as any;
|
||||
if (build !== "oss") {
|
||||
reqData.requireTwoFactor = data.requireTwoFactor || false;
|
||||
reqData.maxSessionLengthHours = data.maxSessionLengthHours;
|
||||
reqData.passwordExpiryDays = data.passwordExpiryDays;
|
||||
}
|
||||
|
||||
// Update organization
|
||||
await api.post(`/org/${org?.org.orgId}`, {
|
||||
name: data.name
|
||||
// subnet: data.subnet // Include subnet in the API request
|
||||
});
|
||||
await api.post(`/org/${org?.org.orgId}`, reqData);
|
||||
|
||||
// Also save auth page settings if they have unsaved changes
|
||||
if (
|
||||
@@ -168,9 +285,7 @@ export default function GeneralPage() {
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<p>
|
||||
{t("orgQuestionRemove")}
|
||||
</p>
|
||||
<p>{t("orgQuestionRemove")}</p>
|
||||
<p>{t("orgMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
@@ -179,23 +294,38 @@ export default function GeneralPage() {
|
||||
string={org?.org.name || ""}
|
||||
title={t("orgDelete")}
|
||||
/>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("orgGeneralSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("orgGeneralSettingsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="org-settings-form"
|
||||
>
|
||||
<ConfirmDeleteDialog
|
||||
open={isSecurityPolicyConfirmOpen}
|
||||
setOpen={setIsSecurityPolicyConfirmOpen}
|
||||
dialog={
|
||||
<div>
|
||||
<p>{t("securityPolicyChangeDescription")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("saveSettings")}
|
||||
onConfirm={() => performSave(form.getValues())}
|
||||
string={t("securityPolicyChangeConfirmMessage")}
|
||||
title={t("securityPolicyChangeWarning")}
|
||||
warningText={t("securityPolicyChangeWarningText")}
|
||||
/>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="org-settings-form"
|
||||
>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("orgGeneralSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("orgGeneralSettingsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
@@ -235,51 +365,507 @@ export default function GeneralPage() {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{(build === "saas") && (
|
||||
<AuthPageSettings ref={authPageSettingsRef} />
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("logRetention")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("logRetentionDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsLogRetentionDaysRequest"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("logRetentionRequestLabel")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(
|
||||
parseInt(value, 10)
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectLogRetention"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOG_RETENTION_OPTIONS.filter(
|
||||
(option) => {
|
||||
if (
|
||||
build ==
|
||||
"saas" &&
|
||||
!subscription?.subscribed &&
|
||||
option.value >
|
||||
30
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
).map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{t(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{build != "oss" && (
|
||||
<>
|
||||
<SecurityFeaturesAlert />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsLogRetentionDaysAccess"
|
||||
render={({ field }) => {
|
||||
const isDisabled =
|
||||
(build == "saas" &&
|
||||
!subscription
|
||||
?.subscribed) ||
|
||||
(build == "enterprise" &&
|
||||
!isUnlocked());
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"logRetentionAccessLabel"
|
||||
)}
|
||||
</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.map(
|
||||
(
|
||||
option
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
option.value
|
||||
}
|
||||
value={
|
||||
option.value.toString()
|
||||
}
|
||||
>
|
||||
{t(
|
||||
option.label
|
||||
)}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsLogRetentionDaysAction"
|
||||
render={({ field }) => {
|
||||
const isDisabled =
|
||||
(build == "saas" &&
|
||||
!subscription
|
||||
?.subscribed) ||
|
||||
(build == "enterprise" &&
|
||||
!isUnlocked());
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"logRetentionActionLabel"
|
||||
)}
|
||||
</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.map(
|
||||
(
|
||||
option
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
option.value
|
||||
}
|
||||
value={
|
||||
option.value.toString()
|
||||
}
|
||||
>
|
||||
{t(
|
||||
option.label
|
||||
)}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{build !== "oss" && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("securitySettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("securitySettingsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SecurityFeaturesAlert />
|
||||
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="security-settings-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="requireTwoFactor"
|
||||
render={({ field }) => {
|
||||
const isDisabled =
|
||||
isSecurityFeatureDisabled();
|
||||
|
||||
return (
|
||||
<FormItem className="col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="require-two-factor"
|
||||
defaultChecked={
|
||||
field.value ||
|
||||
false
|
||||
}
|
||||
label={t(
|
||||
"requireTwoFactorForAllUsers"
|
||||
)}
|
||||
disabled={
|
||||
isDisabled
|
||||
}
|
||||
onCheckedChange={(
|
||||
val
|
||||
) => {
|
||||
if (
|
||||
!isDisabled
|
||||
) {
|
||||
form.setValue(
|
||||
"requireTwoFactor",
|
||||
val
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"requireTwoFactorDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxSessionLengthHours"
|
||||
render={({ field }) => {
|
||||
const isDisabled =
|
||||
isSecurityFeatureDisabled();
|
||||
|
||||
return (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>
|
||||
{t("maxSessionLength")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={
|
||||
field.value?.toString() ||
|
||||
"null"
|
||||
}
|
||||
onValueChange={(
|
||||
value
|
||||
) => {
|
||||
if (
|
||||
!isDisabled
|
||||
) {
|
||||
const numValue =
|
||||
value ===
|
||||
"null"
|
||||
? null
|
||||
: parseInt(
|
||||
value,
|
||||
10
|
||||
);
|
||||
form.setValue(
|
||||
"maxSessionLengthHours",
|
||||
numValue
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
isDisabled
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectSessionLength"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SESSION_LENGTH_OPTIONS.map(
|
||||
(
|
||||
option
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
option.value ===
|
||||
null
|
||||
? "null"
|
||||
: option.value.toString()
|
||||
}
|
||||
value={
|
||||
option.value ===
|
||||
null
|
||||
? "null"
|
||||
: option.value.toString()
|
||||
}
|
||||
>
|
||||
{t(
|
||||
option.labelKey
|
||||
)}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"maxSessionLengthDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="passwordExpiryDays"
|
||||
render={({ field }) => {
|
||||
const isDisabled =
|
||||
isSecurityFeatureDisabled();
|
||||
|
||||
return (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"passwordExpiryDays"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={
|
||||
field.value?.toString() ||
|
||||
"null"
|
||||
}
|
||||
onValueChange={(
|
||||
value
|
||||
) => {
|
||||
if (
|
||||
!isDisabled
|
||||
) {
|
||||
const numValue =
|
||||
value ===
|
||||
"null"
|
||||
? null
|
||||
: parseInt(
|
||||
value,
|
||||
10
|
||||
);
|
||||
form.setValue(
|
||||
"passwordExpiryDays",
|
||||
numValue
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
isDisabled
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectPasswordExpiry"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PASSWORD_EXPIRY_OPTIONS.map(
|
||||
(
|
||||
option
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
option.value ===
|
||||
null
|
||||
? "null"
|
||||
: option.value.toString()
|
||||
}
|
||||
value={
|
||||
option.value ===
|
||||
null
|
||||
? "null"
|
||||
: option.value.toString()
|
||||
}
|
||||
>
|
||||
{t(
|
||||
option.labelKey
|
||||
)}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<FormMessage />
|
||||
{t(
|
||||
"editPasswordExpiryDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
{build === "saas" && <AuthPageSettings ref={authPageSettingsRef} />}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{build !== "saas" && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
loading={loadingDelete}
|
||||
disabled={loadingDelete}
|
||||
>
|
||||
{t("orgDelete")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="org-settings-form"
|
||||
loading={loadingSave}
|
||||
disabled={loadingSave}
|
||||
>
|
||||
{t("saveGeneralSettings")}
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{build !== "saas" && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("orgDangerZone")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("orgDangerZoneDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
loading={loadingDelete}
|
||||
disabled={loadingDelete}
|
||||
>
|
||||
{t("orgDelete")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
662
src/app/[orgId]/settings/logs/access/page.tsx
Normal file
662
src/app/[orgId]/settings/logs/access/page.tsx
Normal file
@@ -0,0 +1,662 @@
|
||||
"use client";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
getStoredPageSize,
|
||||
LogDataTable,
|
||||
setStoredPageSize
|
||||
} from "@app/components/LogDataTable";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { ArrowUpRight, Key, User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ColumnFilter } from "@app/components/ColumnFilter";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { build } from "@server/build";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const { orgId } = useParams();
|
||||
const subscription = useSubscriptionStatusContext();
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [filterAttributes, setFilterAttributes] = useState<{
|
||||
actors: string[];
|
||||
resources: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
}[];
|
||||
locations: string[];
|
||||
}>({
|
||||
actors: [],
|
||||
resources: [],
|
||||
locations: []
|
||||
});
|
||||
|
||||
// Filter states - unified object for all filters
|
||||
const [filters, setFilters] = useState<{
|
||||
action?: string;
|
||||
type?: string;
|
||||
resourceId?: string;
|
||||
location?: string;
|
||||
actor?: string;
|
||||
}>({
|
||||
action: searchParams.get("action") || undefined,
|
||||
type: searchParams.get("type") || undefined,
|
||||
resourceId: searchParams.get("resourceId") || undefined,
|
||||
location: searchParams.get("location") || undefined,
|
||||
actor: searchParams.get("actor") || 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] = useState<number>(() => {
|
||||
return getStoredPageSize("access-audit-logs", 20);
|
||||
});
|
||||
|
||||
// Set default date range to last 24 hours
|
||||
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 yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
return {
|
||||
startDate: {
|
||||
date: yesterday
|
||||
},
|
||||
endDate: {
|
||||
date: now
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const [dateRange, setDateRange] = useState<{
|
||||
startDate: DateTimeValue;
|
||||
endDate: DateTimeValue;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
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);
|
||||
setStoredPageSize(newPageSize, "access-audit-logs");
|
||||
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?: {
|
||||
action?: string;
|
||||
type?: string;
|
||||
resourceId?: string;
|
||||
location?: string;
|
||||
actor?: string;
|
||||
}
|
||||
) => {
|
||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||
if (
|
||||
(build == "saas" && !subscription?.subscribed) ||
|
||||
(build == "enterprise" && !isUnlocked())
|
||||
) {
|
||||
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/access`, { 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 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 {
|
||||
setIsExporting(true);
|
||||
|
||||
// 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/access/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",
|
||||
`access-audit-logs-${orgId}-${epoch}.csv`
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode?.removeChild(link);
|
||||
setIsExporting(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("exportError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: ({ column }) => {
|
||||
return t("timestamp");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="whitespace-nowrap">
|
||||
{new Date(
|
||||
row.original.timestamp * 1000
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("action")}</span>
|
||||
<ColumnFilter
|
||||
options={[
|
||||
{ value: "true", label: "Allowed" },
|
||||
{ value: "false", label: "Denied" }
|
||||
]}
|
||||
selectedValue={filters.action}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("action", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.action ? <>Allowed</> : <>Denied</>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: ({ column }) => {
|
||||
return t("ip");
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "location",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("location")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.locations.map(
|
||||
(location) => ({
|
||||
value: location,
|
||||
label: location
|
||||
})
|
||||
)}
|
||||
selectedValue={filters.location}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("location", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.location ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.original.location}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</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.resourceId}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("resourceId", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/${row.original.orgId}/settings/resources/${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>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("type")}</span>
|
||||
<ColumnFilter
|
||||
options={[
|
||||
{ value: "password", label: "Password" },
|
||||
{ value: "pincode", label: "Pincode" },
|
||||
{ value: "login", label: "Login" },
|
||||
{
|
||||
value: "whitelistedEmail",
|
||||
label: "Whitelisted Email"
|
||||
}
|
||||
]}
|
||||
selectedValue={filters.type}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("type", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
// should be capitalized first letter
|
||||
return (
|
||||
<span>
|
||||
{row.original.type.charAt(0).toUpperCase() +
|
||||
row.original.type.slice(1) || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "actor",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("actor")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.actors.map((actor) => ({
|
||||
value: actor,
|
||||
label: actor
|
||||
}))}
|
||||
selectedValue={filters.actor}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("actor", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actor ? (
|
||||
<>
|
||||
{row.original.actorType == "user" ? (
|
||||
<User className="h-4 w-4" />
|
||||
) : (
|
||||
<Key className="h-4 w-4" />
|
||||
)}
|
||||
{row.original.actor}
|
||||
</>
|
||||
) : (
|
||||
<>-</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "actorId",
|
||||
header: ({ column }) => {
|
||||
return t("actorId");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actorId || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const renderExpandedRow = (row: any) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||
{row.userAgent != "node" && (
|
||||
<div>
|
||||
<strong>User Agent:</strong>
|
||||
<p className="text-muted-foreground mt-1 break-all">
|
||||
{row.userAgent || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<strong>Metadata:</strong>
|
||||
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
|
||||
{row.metadata
|
||||
? JSON.stringify(
|
||||
JSON.parse(row.metadata),
|
||||
null,
|
||||
2
|
||||
)
|
||||
: "N/A"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("accessLogs")}
|
||||
description={t("accessLogsDescription")}
|
||||
/>
|
||||
|
||||
{build == "saas" && !subscription?.subscribed ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("subscriptionRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{build == "enterprise" && !isUnlocked() ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("licenseRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
title={t("accessLogs")}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
onExport={exportData}
|
||||
isExporting={isExporting}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
dateRange={{
|
||||
start: dateRange.startDate,
|
||||
end: dateRange.endDate
|
||||
}}
|
||||
defaultSort={{
|
||||
id: "timestamp",
|
||||
desc: false
|
||||
}}
|
||||
// Server-side pagination props
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
isLoading={isLoading}
|
||||
// Row expansion props
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
disabled={
|
||||
(build == "saas" && !subscription?.subscribed) ||
|
||||
(build == "enterprise" && !isUnlocked())
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
515
src/app/[orgId]/settings/logs/action/page.tsx
Normal file
515
src/app/[orgId]/settings/logs/action/page.tsx
Normal file
@@ -0,0 +1,515 @@
|
||||
"use client";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
getStoredPageSize,
|
||||
LogDataTable,
|
||||
setStoredPageSize
|
||||
} from "@app/components/LogDataTable";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { Key, User } from "lucide-react";
|
||||
import { ColumnFilter } from "@app/components/ColumnFilter";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { build } from "@server/build";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const { orgId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const subscription = useSubscriptionStatusContext();
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [filterAttributes, setFilterAttributes] = useState<{
|
||||
actors: string[];
|
||||
actions: string[];
|
||||
}>({
|
||||
actors: [],
|
||||
actions: []
|
||||
});
|
||||
|
||||
// Filter states - unified object for all filters
|
||||
const [filters, setFilters] = useState<{
|
||||
action?: string;
|
||||
actor?: string;
|
||||
}>({
|
||||
action: searchParams.get("action") || undefined,
|
||||
actor: searchParams.get("actor") || 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] = useState<number>(() => {
|
||||
return getStoredPageSize("action-audit-logs", 20);
|
||||
});
|
||||
|
||||
// Set default date range to last 24 hours
|
||||
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 yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
return {
|
||||
startDate: {
|
||||
date: yesterday
|
||||
},
|
||||
endDate: {
|
||||
date: now
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const [dateRange, setDateRange] = useState<{
|
||||
startDate: DateTimeValue;
|
||||
endDate: DateTimeValue;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
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);
|
||||
setStoredPageSize(newPageSize, "action-audit-logs");
|
||||
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?: {
|
||||
action?: string;
|
||||
actor?: string;
|
||||
}
|
||||
) => {
|
||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||
if (
|
||||
(build == "saas" && !subscription?.subscribed) ||
|
||||
(build == "enterprise" && !isUnlocked())
|
||||
) {
|
||||
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/action`, { 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 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 {
|
||||
setIsExporting(true);
|
||||
|
||||
// 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/action/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",
|
||||
`action-audit-logs-${orgId}-${epoch}.csv`
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode?.removeChild(link);
|
||||
setIsExporting(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("exportError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: ({ column }) => {
|
||||
return t("timestamp");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="whitespace-nowrap">
|
||||
{new Date(
|
||||
row.original.timestamp * 1000
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("action")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.actions.map((action) => ({
|
||||
label:
|
||||
action.charAt(0).toUpperCase() +
|
||||
action.slice(1),
|
||||
value: action
|
||||
}))}
|
||||
selectedValue={filters.action}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("action", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="hitespace-nowrap">
|
||||
{row.original.action.charAt(0).toUpperCase() +
|
||||
row.original.action.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "actor",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("actor")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.actors.map((actor) => ({
|
||||
value: actor,
|
||||
label: actor
|
||||
}))}
|
||||
selectedValue={filters.actor}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("actor", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actorType == "user" ? (
|
||||
<User className="h-4 w-4" />
|
||||
) : (
|
||||
<Key className="h-4 w-4" />
|
||||
)}
|
||||
{row.original.actor}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "actorId",
|
||||
header: ({ column }) => {
|
||||
return t("actorId");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actorId}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const renderExpandedRow = (row: any) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<strong>Metadata:</strong>
|
||||
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
|
||||
{row.metadata
|
||||
? JSON.stringify(
|
||||
JSON.parse(row.metadata),
|
||||
null,
|
||||
2
|
||||
)
|
||||
: "N/A"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("actionLogs")}
|
||||
description={t("actionLogsDescription")}
|
||||
/>
|
||||
|
||||
{build == "saas" && !subscription?.subscribed ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("subscriptionRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{build == "enterprise" && !isUnlocked() ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("licenseRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
title={t("actionLogs")}
|
||||
searchPlaceholder={t("searchLogs")}
|
||||
searchColumn="action"
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
onExport={exportData}
|
||||
isExporting={isExporting}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
dateRange={{
|
||||
start: dateRange.startDate,
|
||||
end: dateRange.endDate
|
||||
}}
|
||||
defaultSort={{
|
||||
id: "timestamp",
|
||||
desc: false
|
||||
}}
|
||||
// Server-side pagination props
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
isLoading={isLoading}
|
||||
// Row expansion props
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
disabled={
|
||||
(build == "saas" && !subscription?.subscribed) ||
|
||||
(build == "enterprise" && !isUnlocked())
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
src/app/[orgId]/settings/logs/layout.tsx
Normal file
22
src/app/[orgId]/settings/logs/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
|
||||
type GeneralSettingsProps = {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export default async function GeneralSettingsPage({
|
||||
children,
|
||||
params
|
||||
}: GeneralSettingsProps) {
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
54
src/app/[orgId]/settings/logs/page.tsx
Normal file
54
src/app/[orgId]/settings/logs/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import AuthPageSettings, {
|
||||
AuthPageSettingsRef
|
||||
} from "@app/components/private/AuthPageSettings";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useState, useRef } from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
return <p>dfas</p>;
|
||||
}
|
||||
796
src/app/[orgId]/settings/logs/request/page.tsx
Normal file
796
src/app/[orgId]/settings/logs/request/page.tsx
Normal file
@@ -0,0 +1,796 @@
|
||||
"use client";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getStoredPageSize, LogDataTable, setStoredPageSize } from "@app/components/LogDataTable";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { Key, RouteOff, User, Lock, Unlock, ArrowUpRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ColumnFilter } from "@app/components/ColumnFilter";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const router = useRouter();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const { orgId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
// 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] = useState<number>(() => {
|
||||
return getStoredPageSize("request-audit-logs", 20);
|
||||
});
|
||||
|
||||
const [filterAttributes, setFilterAttributes] = useState<{
|
||||
actors: string[];
|
||||
resources: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
}[];
|
||||
locations: string[];
|
||||
hosts: string[];
|
||||
paths: string[];
|
||||
}>({
|
||||
actors: [],
|
||||
resources: [],
|
||||
locations: [],
|
||||
hosts: [],
|
||||
paths: []
|
||||
});
|
||||
|
||||
// Filter states - unified object for all filters
|
||||
const [filters, setFilters] = useState<{
|
||||
action?: string;
|
||||
resourceId?: string;
|
||||
host?: string;
|
||||
location?: string;
|
||||
actor?: string;
|
||||
method?: string;
|
||||
reason?: string;
|
||||
path?: string;
|
||||
}>({
|
||||
action: searchParams.get("action") || undefined,
|
||||
host: searchParams.get("host") || undefined,
|
||||
resourceId: searchParams.get("resourceId") || undefined,
|
||||
location: searchParams.get("location") || undefined,
|
||||
actor: searchParams.get("actor") || undefined,
|
||||
method: searchParams.get("method") || undefined,
|
||||
reason: searchParams.get("reason") || undefined,
|
||||
path: searchParams.get("path") || undefined
|
||||
});
|
||||
|
||||
// Set default date range to last 24 hours
|
||||
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 yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
return {
|
||||
startDate: {
|
||||
date: yesterday
|
||||
},
|
||||
endDate: {
|
||||
date: now
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const [dateRange, setDateRange] = useState<{
|
||||
startDate: DateTimeValue;
|
||||
endDate: DateTimeValue;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
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);
|
||||
setStoredPageSize(newPageSize, "request-audit-logs");
|
||||
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
|
||||
) => {
|
||||
console.log(`${filterType} filter changed:`, value);
|
||||
|
||||
// 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?: {
|
||||
action?: string;
|
||||
type?: string;
|
||||
}
|
||||
) => {
|
||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||
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/request`, { 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 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 {
|
||||
setIsExporting(true);
|
||||
|
||||
// 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/request/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",
|
||||
`request-audit-logs-${orgId}-${epoch}.csv`
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode?.removeChild(link);
|
||||
setIsExporting(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("exportError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 100 - Allowed by Rule
|
||||
// 101 - Allowed No Auth
|
||||
// 102 - Valid Access Token
|
||||
// 103 - Valid header auth
|
||||
// 104 - Valid Pincode
|
||||
// 105 - Valid Password
|
||||
// 106 - Valid email
|
||||
// 107 - Valid SSO
|
||||
|
||||
// 201 - Resource Not Found
|
||||
// 202 - Resource Blocked
|
||||
// 203 - Dropped by Rule
|
||||
// 204 - No Sessions
|
||||
// 205 - Temporary Request Token
|
||||
// 299 - No More Auth Methods
|
||||
|
||||
const reasonMap: any = {
|
||||
100: t("allowedByRule"),
|
||||
101: t("allowedNoAuth"),
|
||||
102: t("validAccessToken"),
|
||||
103: t("validHeaderAuth"),
|
||||
104: t("validPincode"),
|
||||
105: t("validPassword"),
|
||||
106: t("validEmail"),
|
||||
107: t("validSSO"),
|
||||
201: t("resourceNotFound"),
|
||||
202: t("resourceBlocked"),
|
||||
203: t("droppedByRule"),
|
||||
204: t("noSessions"),
|
||||
205: t("temporaryRequestToken"),
|
||||
299: t("noMoreAuthMethods")
|
||||
};
|
||||
|
||||
// resourceId: integer("resourceId"),
|
||||
// userAgent: text("userAgent"),
|
||||
// metadata: text("details"),
|
||||
// headers: text("headers"), // JSON blob
|
||||
// query: text("query"), // JSON blob
|
||||
// originalRequestURL: text("originalRequestURL"),
|
||||
// scheme: text("scheme"),
|
||||
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: ({ column }) => {
|
||||
return t("timestamp");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="whitespace-nowrap">
|
||||
{new Date(
|
||||
row.original.timestamp * 1000
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("action")}</span>
|
||||
<ColumnFilter
|
||||
options={[
|
||||
{ value: "true", label: "Allowed" },
|
||||
{ value: "false", label: "Denied" }
|
||||
]}
|
||||
selectedValue={filters.action}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("action", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.action ? <>Allowed</> : <>Denied</>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: ({ column }) => {
|
||||
return t("ip");
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "location",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("location")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.locations.map(
|
||||
(location) => ({
|
||||
value: location,
|
||||
label: location
|
||||
})
|
||||
)}
|
||||
selectedValue={filters.location}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("location", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.location ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.original.location}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</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.resourceId}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("resourceId", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/${row.original.orgId}/settings/resources/${row.original.resourceNiceId}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-6"
|
||||
>
|
||||
{row.original.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "host",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("host")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.hosts.map((host) => ({
|
||||
value: host,
|
||||
label: host
|
||||
}))}
|
||||
selectedValue={filters.host}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("host", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.tls ? (
|
||||
<Lock className="h-4 w-4" />
|
||||
) : (
|
||||
<Unlock className="h-4 w-4" />
|
||||
)}
|
||||
{row.original.host}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "path",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("path")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.paths.map((path) => ({
|
||||
value: path,
|
||||
label: path
|
||||
}))}
|
||||
selectedValue={filters.path}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("path", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// {
|
||||
// accessorKey: "scheme",
|
||||
// header: ({ column }) => {
|
||||
// return t("scheme");
|
||||
// },
|
||||
// },
|
||||
{
|
||||
accessorKey: "method",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("method")}</span>
|
||||
<ColumnFilter
|
||||
options={[
|
||||
{ value: "GET", label: "GET" },
|
||||
{ value: "POST", label: "POST" },
|
||||
{ value: "PUT", label: "PUT" },
|
||||
{ value: "DELETE", label: "DELETE" },
|
||||
{ value: "PATCH", label: "PATCH" },
|
||||
{ value: "HEAD", label: "HEAD" },
|
||||
{ value: "OPTIONS", label: "OPTIONS" }
|
||||
]}
|
||||
selectedValue={filters.method}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("method", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "reason",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("reason")}</span>
|
||||
<ColumnFilter
|
||||
options={[
|
||||
{ value: "100", label: t("allowedByRule") },
|
||||
{ value: "101", label: t("allowedNoAuth") },
|
||||
{ value: "102", label: t("validAccessToken") },
|
||||
{ value: "103", label: t("validHeaderAuth") },
|
||||
{ value: "104", label: t("validPincode") },
|
||||
{ value: "105", label: t("validPassword") },
|
||||
{ value: "106", label: t("validEmail") },
|
||||
{ value: "107", label: t("validSSO") },
|
||||
{ value: "201", label: t("resourceNotFound") },
|
||||
{ value: "202", label: t("resourceBlocked") },
|
||||
{ value: "203", label: t("droppedByRule") },
|
||||
{ value: "204", label: t("noSessions") },
|
||||
{
|
||||
value: "205",
|
||||
label: t("temporaryRequestToken")
|
||||
},
|
||||
{ value: "299", label: t("noMoreAuthMethods") }
|
||||
]}
|
||||
selectedValue={filters.reason}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("reason", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{reasonMap[row.original.reason]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "actor",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("actor")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.actors.map((actor) => ({
|
||||
value: actor,
|
||||
label: actor
|
||||
}))}
|
||||
selectedValue={filters.actor}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("actor", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actor ? (
|
||||
<>
|
||||
{row.original.actorType == "user" ? (
|
||||
<User className="h-4 w-4" />
|
||||
) : (
|
||||
<Key className="h-4 w-4" />
|
||||
)}
|
||||
{row.original.actor}
|
||||
</>
|
||||
) : (
|
||||
<>-</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const renderExpandedRow = (row: any) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<strong>User Agent:</strong>
|
||||
<p className="text-muted-foreground mt-1 break-all">
|
||||
{row.userAgent || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Original URL:</strong>
|
||||
<p className="text-muted-foreground mt-1 break-all">
|
||||
{row.originalRequestURL || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Scheme:</strong>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{row.scheme || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Metadata:</strong>
|
||||
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
|
||||
{row.metadata
|
||||
? JSON.stringify(
|
||||
JSON.parse(row.metadata),
|
||||
null,
|
||||
2
|
||||
)
|
||||
: "N/A"}
|
||||
</pre>
|
||||
</div>
|
||||
{row.headers && (
|
||||
<div className="md:col-span-2">
|
||||
<strong>Headers:</strong>
|
||||
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
|
||||
{JSON.stringify(
|
||||
JSON.parse(row.headers),
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{row.query && (
|
||||
<div className="md:col-span-2">
|
||||
<strong>Query Parameters:</strong>
|
||||
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
|
||||
{JSON.stringify(JSON.parse(row.query), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t('requestLogs')}
|
||||
description={t('requestLogsDescription')}
|
||||
/>
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
title={t("requestLogs")}
|
||||
searchPlaceholder={t("searchLogs")}
|
||||
searchColumn="host"
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
onExport={exportData}
|
||||
isExporting={isExporting}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
dateRange={{
|
||||
start: dateRange.startDate,
|
||||
end: dateRange.endDate
|
||||
}}
|
||||
defaultSort={{
|
||||
id: "timestamp",
|
||||
desc: false
|
||||
}}
|
||||
// Server-side pagination props
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
isLoading={isLoading}
|
||||
pageSize={pageSize}
|
||||
// Row expansion props
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,9 @@ import { build } from "@server/build";
|
||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||
import { DomainRow } from "../../../../../../components/DomainsTable";
|
||||
import { toASCII, toUnicode } from "punycode";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
|
||||
export default function GeneralForm() {
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
@@ -65,6 +68,9 @@ export default function GeneralForm() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const [editDomainOpen, setEditDomainOpen] = useState(false);
|
||||
const {licenseStatus } = useLicenseStatusContext();
|
||||
const subscriptionStatus = useSubscriptionStatusContext();
|
||||
const {user} = useUserContext();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
|
||||
@@ -77,7 +77,8 @@ import {
|
||||
MoveRight,
|
||||
ArrowUp,
|
||||
Info,
|
||||
ArrowDown
|
||||
ArrowDown,
|
||||
AlertTriangle
|
||||
} from "lucide-react";
|
||||
import { ContainersSelector } from "@app/components/ContainersSelector";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -115,6 +116,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@app/components/ui/tooltip";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
|
||||
const addTargetSchema = z
|
||||
.object({
|
||||
@@ -288,7 +290,9 @@ export default function ReverseProxyTargets(props: {
|
||||
),
|
||||
headers: z
|
||||
.array(z.object({ name: z.string(), value: z.string() }))
|
||||
.nullable()
|
||||
.nullable(),
|
||||
proxyProtocol: z.boolean().optional(),
|
||||
proxyProtocolVersion: z.number().int().min(1).max(2).optional()
|
||||
});
|
||||
|
||||
const tlsSettingsSchema = z.object({
|
||||
@@ -325,7 +329,9 @@ export default function ReverseProxyTargets(props: {
|
||||
resolver: zodResolver(proxySettingsSchema),
|
||||
defaultValues: {
|
||||
setHostHeader: resource.setHostHeader || "",
|
||||
headers: resource.headers
|
||||
headers: resource.headers,
|
||||
proxyProtocol: resource.proxyProtocol || false,
|
||||
proxyProtocolVersion: resource.proxyProtocolVersion || 1
|
||||
}
|
||||
});
|
||||
|
||||
@@ -549,11 +555,11 @@ export default function ReverseProxyTargets(props: {
|
||||
prev.map((t) =>
|
||||
t.targetId === target.targetId
|
||||
? {
|
||||
...t,
|
||||
targetId: response.data.data.targetId,
|
||||
new: false,
|
||||
updated: false
|
||||
}
|
||||
...t,
|
||||
targetId: response.data.data.targetId,
|
||||
new: false,
|
||||
updated: false
|
||||
}
|
||||
: t
|
||||
)
|
||||
);
|
||||
@@ -673,11 +679,11 @@ export default function ReverseProxyTargets(props: {
|
||||
targets.map((target) =>
|
||||
target.targetId === targetId
|
||||
? {
|
||||
...target,
|
||||
...data,
|
||||
updated: true,
|
||||
siteType: site ? site.type : target.siteType
|
||||
}
|
||||
...target,
|
||||
...data,
|
||||
updated: true,
|
||||
siteType: site ? site.type : target.siteType
|
||||
}
|
||||
: target
|
||||
)
|
||||
);
|
||||
@@ -688,10 +694,10 @@ export default function ReverseProxyTargets(props: {
|
||||
targets.map((target) =>
|
||||
target.targetId === targetId
|
||||
? {
|
||||
...target,
|
||||
...config,
|
||||
updated: true
|
||||
}
|
||||
...target,
|
||||
...config,
|
||||
updated: true
|
||||
}
|
||||
: target
|
||||
)
|
||||
);
|
||||
@@ -800,6 +806,22 @@ export default function ReverseProxyTargets(props: {
|
||||
setHostHeader: proxyData.setHostHeader || null,
|
||||
headers: proxyData.headers || null
|
||||
});
|
||||
} else {
|
||||
// For TCP/UDP resources, save proxy protocol settings
|
||||
const proxyData = proxySettingsForm.getValues();
|
||||
|
||||
const payload = {
|
||||
proxyProtocol: proxyData.proxyProtocol || false,
|
||||
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
|
||||
};
|
||||
|
||||
await api.post(`/resource/${resource.resourceId}`, payload);
|
||||
|
||||
updateResource({
|
||||
...resource,
|
||||
proxyProtocol: proxyData.proxyProtocol || false,
|
||||
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
|
||||
});
|
||||
}
|
||||
|
||||
toast({
|
||||
@@ -1064,7 +1086,7 @@ export default function ReverseProxyTargets(props: {
|
||||
className={cn(
|
||||
"w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
|
||||
!row.original.siteId &&
|
||||
"text-muted-foreground"
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate max-w-[150px]">
|
||||
@@ -1404,12 +1426,12 @@ export default function ReverseProxyTargets(props: {
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
)}
|
||||
@@ -1675,6 +1697,102 @@ export default function ReverseProxyTargets(props: {
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{!resource.http && resource.protocol && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("proxyProtocol")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("proxyProtocolDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...proxySettingsForm}>
|
||||
<form
|
||||
onSubmit={proxySettingsForm.handleSubmit(
|
||||
saveAllSettings
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="proxy-protocol-settings-form"
|
||||
>
|
||||
<FormField
|
||||
control={proxySettingsForm.control}
|
||||
name="proxyProtocol"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="proxy-protocol-toggle"
|
||||
label={t(
|
||||
"enableProxyProtocol"
|
||||
)}
|
||||
description={t(
|
||||
"proxyProtocolInfo"
|
||||
)}
|
||||
defaultChecked={
|
||||
field.value || false
|
||||
}
|
||||
onCheckedChange={(val) => {
|
||||
field.onChange(val);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{proxySettingsForm.watch("proxyProtocol") && (
|
||||
<>
|
||||
<FormField
|
||||
control={proxySettingsForm.control}
|
||||
name="proxyProtocolVersion"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("proxyProtocolVersion")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={String(field.value || 1)}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(parseInt(value, 10))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select version" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">
|
||||
{t("version1")}
|
||||
</SelectItem>
|
||||
<SelectItem value="2">
|
||||
{t("version2")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("versionDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>{t("warning")}:</strong> {t("proxyProtocolWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<Button
|
||||
onClick={saveAllSettings}
|
||||
|
||||
@@ -130,7 +130,7 @@ export default function ResourceRules(props: {
|
||||
PATH: t('path'),
|
||||
IP: "IP",
|
||||
CIDR: t('ipAddressRange'),
|
||||
GEOIP: t('country')
|
||||
COUNTRY: t('country')
|
||||
} as const;
|
||||
|
||||
const addRuleForm = useForm({
|
||||
@@ -212,7 +212,7 @@ export default function ResourceRules(props: {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (data.match === "GEOIP" && !COUNTRIES.some(c => c.code === data.value)) {
|
||||
if (data.match === "COUNTRY" && !COUNTRIES.some(c => c.code === data.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('rulesErrorInvalidCountry'),
|
||||
@@ -270,7 +270,7 @@ export default function ResourceRules(props: {
|
||||
return t('rulesMatchIpAddress');
|
||||
case "PATH":
|
||||
return t('rulesMatchUrl');
|
||||
case "GEOIP":
|
||||
case "COUNTRY":
|
||||
return t('rulesMatchCountry');
|
||||
}
|
||||
}
|
||||
@@ -492,8 +492,8 @@ export default function ResourceRules(props: {
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.match}
|
||||
onValueChange={(value: "CIDR" | "IP" | "PATH" | "GEOIP") =>
|
||||
updateRule(row.original.ruleId, { match: value, value: value === "GEOIP" ? "US" : row.original.value })
|
||||
onValueChange={(value: "CIDR" | "IP" | "PATH" | "COUNTRY") =>
|
||||
updateRule(row.original.ruleId, { match: value, value: value === "COUNTRY" ? "US" : row.original.value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="min-w-[125px]">
|
||||
@@ -504,7 +504,7 @@ export default function ResourceRules(props: {
|
||||
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
|
||||
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
|
||||
{isMaxmindAvailable && (
|
||||
<SelectItem value="GEOIP">{RuleMatch.GEOIP}</SelectItem>
|
||||
<SelectItem value="COUNTRY">{RuleMatch.COUNTRY}</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -514,7 +514,7 @@ export default function ResourceRules(props: {
|
||||
accessorKey: "value",
|
||||
header: t('value'),
|
||||
cell: ({ row }) => (
|
||||
row.original.match === "GEOIP" ? (
|
||||
row.original.match === "COUNTRY" ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -748,8 +748,8 @@ export default function ResourceRules(props: {
|
||||
{RuleMatch.CIDR}
|
||||
</SelectItem>
|
||||
{isMaxmindAvailable && (
|
||||
<SelectItem value="GEOIP">
|
||||
{RuleMatch.GEOIP}
|
||||
<SelectItem value="COUNTRY">
|
||||
{RuleMatch.COUNTRY}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
@@ -775,7 +775,7 @@ export default function ResourceRules(props: {
|
||||
}
|
||||
/>
|
||||
<FormControl>
|
||||
{addRuleForm.watch("match") === "GEOIP" ? (
|
||||
{addRuleForm.watch("match") === "COUNTRY" ? (
|
||||
<Popover open={openAddRuleCountrySelect} onOpenChange={setOpenAddRuleCountrySelect}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -79,9 +79,7 @@ interface RemoteExitNodeOption {
|
||||
}
|
||||
|
||||
type Commands = {
|
||||
mac: Record<string, string[]>;
|
||||
linux: Record<string, string[]>;
|
||||
freebsd: Record<string, string[]>;
|
||||
unix: Record<string, string[]>;
|
||||
windows: Record<string, string[]>;
|
||||
docker: Record<string, string[]>;
|
||||
kubernetes: Record<string, string[]>;
|
||||
@@ -90,13 +88,11 @@ type Commands = {
|
||||
};
|
||||
|
||||
const platforms = [
|
||||
"linux",
|
||||
"unix",
|
||||
"docker",
|
||||
"kubernetes",
|
||||
"podman",
|
||||
"mac",
|
||||
"windows",
|
||||
"freebsd",
|
||||
"nixos"
|
||||
] as const;
|
||||
|
||||
@@ -190,7 +186,7 @@ export default function Page() {
|
||||
|
||||
const [loadingPage, setLoadingPage] = useState(true);
|
||||
|
||||
const [platform, setPlatform] = useState<Platform>("linux");
|
||||
const [platform, setPlatform] = useState<Platform>("unix");
|
||||
const [architecture, setArchitecture] = useState("amd64");
|
||||
const [commands, setCommands] = useState<Commands | null>(null);
|
||||
|
||||
@@ -250,47 +246,11 @@ PersistentKeepalive = 5`;
|
||||
: "";
|
||||
|
||||
const commands = {
|
||||
mac: {
|
||||
unix: {
|
||||
All: [
|
||||
`curl -fsSL https://pangolin.net/get-newt.sh | bash`,
|
||||
`newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
]
|
||||
// "Intel x64 (amd64)": [
|
||||
// `curl -fsSL https://pangolin.net/get-newt.sh | bash`,
|
||||
// `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
// ]
|
||||
},
|
||||
linux: {
|
||||
All: [
|
||||
`curl -fsSL https://pangolin.net/get-newt.sh | bash`,
|
||||
`newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
]
|
||||
// arm64: [
|
||||
// `curl -fsSL https://pangolin.net/get-newt.sh | bash`,
|
||||
// `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
// ],
|
||||
// arm32: [
|
||||
// `curl -fsSL https://pangolin.net/get-newt.sh | bash`,
|
||||
// `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
// ],
|
||||
// arm32v6: [
|
||||
// `curl -fsSL https://pangolin.net/get-newt.sh | bash`,
|
||||
// `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
// ],
|
||||
// riscv64: [
|
||||
// `curl -fsSL https://pangolin.net/get-newt.sh | bash`,
|
||||
// `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
// ]
|
||||
},
|
||||
freebsd: {
|
||||
All: [
|
||||
`curl -fsSL https://pangolin.net/get-newt.sh | bash`,
|
||||
`newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
]
|
||||
// arm64: [
|
||||
// `curl -fsSL https://pangolin.net/get-newt.sh | bash`,
|
||||
// `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
// ]
|
||||
},
|
||||
windows: {
|
||||
x64: [
|
||||
@@ -353,9 +313,6 @@ WantedBy=default.target`
|
||||
All: [
|
||||
`nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
]
|
||||
// aarch64: [
|
||||
// `nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
// ]
|
||||
}
|
||||
};
|
||||
setCommands(commands);
|
||||
@@ -363,11 +320,7 @@ WantedBy=default.target`
|
||||
|
||||
const getArchitectures = () => {
|
||||
switch (platform) {
|
||||
case "linux":
|
||||
// return ["amd64", "arm64", "arm32", "arm32v6", "riscv64"];
|
||||
return ["All"];
|
||||
case "mac":
|
||||
// return ["Apple Silicon (arm64)", "Intel x64 (amd64)"];
|
||||
case "unix":
|
||||
return ["All"];
|
||||
case "windows":
|
||||
return ["x64"];
|
||||
@@ -377,11 +330,7 @@ WantedBy=default.target`
|
||||
return ["Helm Chart"];
|
||||
case "podman":
|
||||
return ["Podman Quadlet", "Podman Run"];
|
||||
case "freebsd":
|
||||
// return ["amd64", "arm64"];
|
||||
return ["All"];
|
||||
case "nixos":
|
||||
// return ["x86_64", "aarch64"];
|
||||
return ["All"];
|
||||
default:
|
||||
return ["x64"];
|
||||
@@ -392,20 +341,18 @@ WantedBy=default.target`
|
||||
switch (platformName) {
|
||||
case "windows":
|
||||
return "Windows";
|
||||
case "mac":
|
||||
return "macOS";
|
||||
case "unix":
|
||||
return "Unix & macOS";
|
||||
case "docker":
|
||||
return "Docker";
|
||||
case "kubernetes":
|
||||
return "Kubernetes";
|
||||
case "podman":
|
||||
return "Podman";
|
||||
case "freebsd":
|
||||
return "FreeBSD";
|
||||
case "nixos":
|
||||
return "NixOS";
|
||||
default:
|
||||
return "Linux";
|
||||
return "Unix / macOS";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -440,16 +387,14 @@ WantedBy=default.target`
|
||||
switch (platformName) {
|
||||
case "windows":
|
||||
return <FaWindows className="h-4 w-4 mr-2" />;
|
||||
case "mac":
|
||||
return <FaApple className="h-4 w-4 mr-2" />;
|
||||
case "unix":
|
||||
return <Terminal className="h-4 w-4 mr-2" />;
|
||||
case "docker":
|
||||
return <FaDocker className="h-4 w-4 mr-2" />;
|
||||
case "kubernetes":
|
||||
return <SiKubernetes className="h-4 w-4 mr-2" />;
|
||||
case "podman":
|
||||
return <FaCubes className="h-4 w-4 mr-2" />;
|
||||
case "freebsd":
|
||||
return <FaFreebsd className="h-4 w-4 mr-2" />;
|
||||
case "nixos":
|
||||
return <SiNixos className="h-4 w-4 mr-2" />;
|
||||
default:
|
||||
|
||||
@@ -13,6 +13,7 @@ import { AxiosResponse } from "axios";
|
||||
import { ListIdpsResponse } from "@server/routers/idp";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { build } from "@server/build";
|
||||
import { LoadLoginPageResponse } from "@server/routers/loginPage/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -33,6 +34,33 @@ export default async function Page(props: {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
// Check for orgId and redirect to org-specific login page if found
|
||||
const orgId = searchParams.orgId as string | undefined;
|
||||
let loginPageDomain: string | undefined;
|
||||
if (orgId) {
|
||||
try {
|
||||
const res = await priv.get<AxiosResponse<LoadLoginPageResponse>>(
|
||||
`/login-page?orgId=${orgId}`
|
||||
);
|
||||
|
||||
if (res && res.status === 200 && res.data.data.fullDomain) {
|
||||
loginPageDomain = res.data.data.fullDomain;
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug("No custom login page found for org", orgId);
|
||||
}
|
||||
}
|
||||
|
||||
if (loginPageDomain) {
|
||||
const redirectUrl = searchParams.redirect as string | undefined;
|
||||
|
||||
let url = `https://${loginPageDomain}/auth/org`;
|
||||
if (redirectUrl) {
|
||||
url += `?redirect=${redirectUrl}`;
|
||||
}
|
||||
redirect(url);
|
||||
}
|
||||
|
||||
let redirectUrl: string | undefined = undefined;
|
||||
if (searchParams.redirect) {
|
||||
redirectUrl = cleanRedirect(searchParams.redirect as string);
|
||||
|
||||
@@ -22,6 +22,8 @@ import { headers } from "next/headers";
|
||||
import { GetLoginPageResponse } from "@server/routers/loginPage/types";
|
||||
import { GetOrgTierResponse } from "@server/routers/billing/types";
|
||||
import { TierId } from "@server/lib/billing/tiers";
|
||||
import { CheckOrgUserAccessResponse } from "@server/routers/org";
|
||||
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -136,6 +138,34 @@ export default async function ResourceAuthPage(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const cookie = await authCookieHeader();
|
||||
|
||||
// Check org policy compliance before proceeding
|
||||
let orgPolicyCheck: CheckOrgUserAccessResponse | null = null;
|
||||
if (user && authInfo.orgId) {
|
||||
try {
|
||||
const policyRes = await internal.get<
|
||||
AxiosResponse<CheckOrgUserAccessResponse>
|
||||
>(`/org/${authInfo.orgId}/user/${user.userId}/check`, cookie);
|
||||
|
||||
orgPolicyCheck = policyRes.data.data;
|
||||
} catch (e) {
|
||||
console.error(formatAxiosError(e));
|
||||
}
|
||||
}
|
||||
|
||||
// If user is not compliant with org policies, show policy requirements
|
||||
if (orgPolicyCheck && !orgPolicyCheck.allowed && orgPolicyCheck.policies) {
|
||||
return (
|
||||
<div className="w-full max-w-md">
|
||||
<OrgPolicyRequired
|
||||
orgId={authInfo.orgId}
|
||||
policies={orgPolicyCheck.policies}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasAuth) {
|
||||
// no authentication so always go straight to the resource
|
||||
redirect(redirectUrl);
|
||||
@@ -151,7 +181,7 @@ export default async function ResourceAuthPage(props: {
|
||||
>(
|
||||
`/resource/${authInfo.resourceId}/get-exchange-token`,
|
||||
{},
|
||||
await authCookieHeader()
|
||||
cookie
|
||||
);
|
||||
|
||||
if (res.data.data.requestToken) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { getLocale } from "next-intl/server";
|
||||
import { Toaster } from "@app/components/ui/toaster";
|
||||
import { build } from "@server/build";
|
||||
import { TopLoader } from "@app/components/Toploader";
|
||||
import Script from "next/script";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
|
||||
@@ -86,6 +87,13 @@ export default async function RootLayout({
|
||||
<html suppressHydrationWarning lang={locale}>
|
||||
<body className={`${font.className} h-screen overflow-hidden`}>
|
||||
<TopLoader />
|
||||
{build === "saas" && (
|
||||
<Script
|
||||
src="https://rybbit.fossorial.io/api/script.js"
|
||||
data-site-id="fe1ff2a33287"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
)}
|
||||
<NextIntlClientProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
|
||||
@@ -17,6 +17,11 @@ import {
|
||||
Bolt,
|
||||
ScanText,
|
||||
ReceiptText
|
||||
Zap,
|
||||
CreditCard,
|
||||
Logs,
|
||||
SquareMousePointer,
|
||||
ScanEye
|
||||
} from "lucide-react";
|
||||
|
||||
export type SidebarNavSection = {
|
||||
@@ -117,6 +122,30 @@ export const orgNavSections = (
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "Analytics",
|
||||
items: [
|
||||
{
|
||||
title: "sidebarLogsRequest",
|
||||
href: "/{orgId}/settings/logs/request",
|
||||
icon: <SquareMousePointer className="h-4 w-4" />
|
||||
},
|
||||
...(build != "oss"
|
||||
? [
|
||||
{
|
||||
title: "sidebarLogsAccess",
|
||||
href: "/{orgId}/settings/logs/access",
|
||||
icon: <ScanEye className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: "sidebarLogsAction",
|
||||
href: "/{orgId}/settings/logs/action",
|
||||
icon: <Logs className="h-4 w-4" />
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "Organization",
|
||||
items: [
|
||||
|
||||
Reference in New Issue
Block a user