Compare commits

...

7 Commits

Author SHA1 Message Date
Owen
c03a61f613 Delete each of the site resources and rebuild 2026-01-18 15:00:08 -08:00
miloschwartz
89928c753c add server info endpoint 2026-01-18 12:19:07 -08:00
miloschwartz
a56fcc0fba add olm container install commands 2026-01-18 12:11:58 -08:00
miloschwartz
43c60bcdbc spacing and phrase improvement 2026-01-18 12:08:29 -08:00
miloschwartz
a3fa12f0e4 split org security settings to new tab 2026-01-18 12:03:01 -08:00
Owen
d696556097 Handle disconnecting message when stoppng 2026-01-18 11:55:41 -08:00
miloschwartz
6a45151741 show fingerprint popup and fix policy check errors 2026-01-18 11:55:24 -08:00
21 changed files with 1263 additions and 882 deletions

View File

@@ -1581,7 +1581,7 @@
"timeoutSeconds": "Timeout (sec)",
"timeIsInSeconds": "Time is in seconds",
"requireDeviceApproval": "Require Device Approvals",
"requireDeviceApprovalDescription": "Users with this role need their devices approved by an admin before they can access resources",
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
"retryAttempts": "Retry Attempts",
"expectedResponseCodes": "Expected Response Codes",
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",

View File

@@ -143,7 +143,14 @@ function queryClients(
olmArchived: olms.archived,
archived: clients.archived,
blocked: clients.blocked,
deviceModel: fingerprints.deviceModel
deviceModel: fingerprints.deviceModel,
fingerprintPlatform: fingerprints.platform,
fingerprintOsVersion: fingerprints.osVersion,
fingerprintKernelVersion: fingerprints.kernelVersion,
fingerprintArch: fingerprints.arch,
fingerprintSerialNumber: fingerprints.serialNumber,
fingerprintUsername: fingerprints.username,
fingerprintHostname: fingerprints.hostname
})
.from(clients)
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))

View File

@@ -18,6 +18,7 @@ import * as apiKeys from "./apiKeys";
import * as logs from "./auditLogs";
import * as newt from "./newt";
import * as olm from "./olm";
import * as serverInfo from "./serverInfo";
import HttpCode from "@server/types/HttpCode";
import {
verifyAccessTokenAccess,
@@ -712,6 +713,8 @@ authenticated.get(
authenticated.get(`/org/:orgId/overview`, verifyOrgAccess, org.getOrgOverview);
authenticated.get(`/server-info`, serverInfo.getServerInfo);
authenticated.post(
`/supporter-key/validate`,
supporterKey.validateSupporterKey

View File

@@ -0,0 +1,34 @@
import { MessageHandler } from "@server/routers/ws";
import { clients, db, Olm } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
/**
* Handles disconnecting messages from clients to show disconnected in the ui
*/
export const handleOlmDisconnecingMessage: MessageHandler = async (context) => {
const { message, client: c, sendToClient } = context;
const olm = c as Olm;
if (!olm) {
logger.warn("Olm not found");
return;
}
if (!olm.clientId) {
logger.warn("Olm has no client ID!");
return;
}
try {
// Update the client's last ping timestamp
await db
.update(clients)
.set({
online: false
})
.where(eq(clients.clientId, olm.clientId));
} catch (error) {
logger.error("Error handling disconnecting message", { error });
}
};

View File

@@ -115,6 +115,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
sessionId // this is the user token passed in the message
});
logger.debug("Policy check result:", policyCheck);
if (policyCheck?.error) {
logger.error(
`Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}`
@@ -123,7 +125,10 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return;
}
if (policyCheck?.policies?.passwordAge?.compliant) {
if (
policyCheck?.policies?.passwordAge &&
!policyCheck.policies.passwordAge.compliant
) {
logger.warn(
`Olm user ${olm.userId} has non-compliant password age for org ${orgId}`
);
@@ -132,7 +137,10 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
olm.olmId
);
return;
} else if (policyCheck?.policies?.maxSessionLength?.compliant) {
} else if (
policyCheck?.policies?.maxSessionLength &&
!policyCheck.policies.maxSessionLength.compliant
) {
logger.warn(
`Olm user ${olm.userId} has non-compliant session length for org ${orgId}`
);
@@ -141,7 +149,10 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
olm.olmId
);
return;
} else if (policyCheck?.policies?.requiredTwoFactor) {
} else if (
policyCheck?.policies &&
!policyCheck.policies.requiredTwoFactor
) {
logger.warn(
`Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}`
);

View File

@@ -10,3 +10,4 @@ export * from "./getUserOlm";
export * from "./handleOlmServerPeerAddMessage";
export * from "./handleOlmUnRelayMessage";
export * from "./recoverOlmWithFingerprint";
export * from "./handleOlmDisconnectingMessage";

View File

@@ -0,0 +1,60 @@
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response";
import config from "@server/lib/config";
import { build } from "@server/build";
import { APP_VERSION } from "@server/lib/consts";
import license from "#dynamic/license/license";
export type GetServerInfoResponse = {
version: string;
supporterStatusValid: boolean;
build: "oss" | "enterprise" | "saas";
enterpriseLicenseValid: boolean;
enterpriseLicenseType: string | null;
};
export async function getServerInfo(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const supporterData = config.getSupporterData();
const supporterStatusValid = supporterData?.valid || false;
let enterpriseLicenseValid = false;
let enterpriseLicenseType: string | null = null;
if (build === "enterprise") {
try {
const licenseStatus = await license.check();
enterpriseLicenseValid = licenseStatus.isLicenseValid;
enterpriseLicenseType = licenseStatus.tier || null;
} catch (error) {
logger.warn("Failed to check enterprise license status:", error);
}
}
return sendResponse<GetServerInfoResponse>(res, {
data: {
version: APP_VERSION,
supporterStatusValid,
build,
enterpriseLicenseValid,
enterpriseLicenseType
},
success: true,
error: false,
message: "Server info retrieved",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1 @@
export * from "./getServerInfo";

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, siteResources } from "@server/db";
import { newts, newtSessions, sites } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
@@ -11,6 +11,7 @@ import { deletePeer } from "../gerbil/peers";
import { fromError } from "zod-validation-error";
import { sendToClient } from "#dynamic/routers/ws";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
const deleteSiteSchema = z.strictObject({
siteId: z.string().transform(Number).pipe(z.int().positive())
@@ -63,23 +64,37 @@ export async function deleteSite(
let deletedNewtId: string | null = null;
await db.transaction(async (trx) => {
if (site.pubKey) {
if (site.type == "wireguard") {
if (site.type == "wireguard") {
if (site.pubKey) {
await deletePeer(site.exitNodeId!, site.pubKey);
} else if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [deletedNewt] = await trx
.delete(newts)
.where(eq(newts.siteId, siteId))
.returning();
if (deletedNewt) {
deletedNewtId = deletedNewt.newtId;
}
} else if (site.type == "newt") {
// delete all of the site resources on this site
const siteResourcesOnSite = trx
.delete(siteResources)
.where(eq(siteResources.siteId, siteId))
.returning();
// delete all of the sessions for the newt
await trx
.delete(newtSessions)
.where(eq(newtSessions.newtId, deletedNewt.newtId));
}
// loop through them
for (const removedSiteResource of await siteResourcesOnSite) {
await rebuildClientAssociationsFromSiteResource(
removedSiteResource,
trx
);
}
// get the newt on the site by querying the newt table for siteId
const [deletedNewt] = await trx
.delete(newts)
.where(eq(newts.siteId, siteId))
.returning();
if (deletedNewt) {
deletedNewtId = deletedNewt.newtId;
// delete all of the sessions for the newt
await trx
.delete(newtSessions)
.where(eq(newtSessions.newtId, deletedNewt.newtId));
}
}

View File

@@ -14,7 +14,8 @@ import {
handleOlmPingMessage,
startOlmOfflineChecker,
handleOlmServerPeerAddMessage,
handleOlmUnRelayMessage
handleOlmUnRelayMessage,
handleOlmDisconnecingMessage
} from "../olm";
import { handleHealthcheckStatusMessage } from "../target";
import { MessageHandler } from "./types";
@@ -25,6 +26,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
"olm/wg/relay": handleOlmRelayMessage,
"olm/wg/unrelay": handleOlmUnRelayMessage,
"olm/ping": handleOlmPingMessage,
"olm/disconnecting": handleOlmDisconnecingMessage,
"newt/ping": handleNewtPingMessage,
"newt/wg/register": handleNewtRegisterMessage,
"newt/wg/get-config": handleGetConfigMessage,

View File

@@ -73,9 +73,10 @@ type CommandItem = string | { title: string; command: string };
type Commands = {
unix: Record<string, CommandItem[]>;
windows: Record<string, CommandItem[]>;
docker: Record<string, CommandItem[]>;
};
const platforms = ["unix", "windows"] as const;
const platforms = ["unix", "docker", "windows"] as const;
type Platform = (typeof platforms)[number];
@@ -156,6 +157,27 @@ export default function Page() {
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
}
]
},
docker: {
"Docker Compose": [
`services:
olm:
image: fosrl/olm
container_name: olm
restart: unless-stopped
network_mode: host
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
- PANGOLIN_ENDPOINT=${endpoint}
- OLM_ID=${id}
- OLM_SECRET=${secret}`
],
"Docker Run": [
`docker run -dit --network host --cap-add NET_ADMIN --device /dev/net/tun:/dev/net/tun fosrl/olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
]
}
};
setCommands(commands);
@@ -167,6 +189,8 @@ export default function Page() {
return ["All"];
case "windows":
return ["x64"];
case "docker":
return ["Docker Compose", "Docker Run"];
default:
return ["x64"];
}

View File

@@ -149,12 +149,16 @@ export default function GeneralPage() {
const [approvalId, setApprovalId] = useState<number | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [, startTransition] = useTransition();
const showApprovalFeatures = build !== "oss" && isPaidUser;
// Fetch approval ID for this client if pending
useEffect(() => {
if (showApprovalFeatures && client.approvalState === "pending" && client.clientId) {
if (
showApprovalFeatures &&
client.approvalState === "pending" &&
client.clientId
) {
api.get(`/org/${orgId}/approvals?approvalState=pending`)
.then((res) => {
const approval = res.data.data.approvals.find(
@@ -168,7 +172,13 @@ export default function GeneralPage() {
// Silently fail - approval might not exist
});
}
}, [showApprovalFeatures, client.approvalState, client.clientId, orgId, api]);
}, [
showApprovalFeatures,
client.approvalState,
client.clientId,
orgId,
api
]);
const handleApprove = async () => {
if (!approvalId) return;
@@ -280,7 +290,6 @@ export default function GeneralPage() {
}
};
return (
<SettingsContainer>
{/* Pending Approval Banner */}
@@ -296,6 +305,7 @@ export default function GeneralPage() {
onClick={handleApprove}
disabled={isRefreshing || !approvalId}
loading={isRefreshing}
variant="outline"
className="gap-2"
>
<Check className="size-4" />
@@ -305,7 +315,7 @@ export default function GeneralPage() {
onClick={handleDeny}
disabled={isRefreshing || !approvalId}
loading={isRefreshing}
variant="destructive"
variant="outline"
className="gap-2"
>
<Ban className="size-4" />
@@ -339,8 +349,7 @@ export default function GeneralPage() {
)}
{/* Device Information Section */}
{(client.fingerprint ||
(client.agent && client.olmVersion)) && (
{(client.fingerprint || (client.agent && client.olmVersion)) && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -360,145 +369,182 @@ export default function GeneralPage() {
</InfoSectionTitle>
<InfoSectionContent>
<Badge variant="secondary">
{client.agent + " v" + client.olmVersion}
{client.agent +
" v" +
client.olmVersion}
</Badge>
</InfoSectionContent>
</InfoSection>
</div>
)}
{client.fingerprint && (() => {
const platform = client.fingerprint.platform;
const fieldConfig = getPlatformFieldConfig(platform);
return (
<InfoSections cols={3}>
{platform && (
<InfoSection>
<InfoSectionTitle>
{t("platform")}
</InfoSectionTitle>
<InfoSectionContent>
<div className="flex items-center gap-2">
{getPlatformIcon(platform)}
<span>{formatPlatform(platform)}</span>
</div>
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint &&
(() => {
const platform = client.fingerprint.platform;
const fieldConfig =
getPlatformFieldConfig(platform);
{client.fingerprint.osVersion &&
fieldConfig.osVersion.show && (
return (
<InfoSections cols={3}>
{platform && (
<InfoSection>
<InfoSectionTitle>
{t(fieldConfig.osVersion.labelKey)}
{t("platform")}
</InfoSectionTitle>
<InfoSectionContent>
{client.fingerprint.osVersion}
<div className="flex items-center gap-2">
{getPlatformIcon(
platform
)}
<span>
{formatPlatform(
platform
)}
</span>
</div>
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.kernelVersion &&
fieldConfig.kernelVersion.show && (
{client.fingerprint.osVersion &&
fieldConfig.osVersion.show && (
<InfoSection>
<InfoSectionTitle>
{t(
fieldConfig
.osVersion
.labelKey
)}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.osVersion
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.kernelVersion &&
fieldConfig.kernelVersion.show && (
<InfoSection>
<InfoSectionTitle>
{t("kernelVersion")}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.kernelVersion
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.arch &&
fieldConfig.arch.show && (
<InfoSection>
<InfoSectionTitle>
{t("architecture")}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.arch
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.deviceModel &&
fieldConfig.deviceModel.show && (
<InfoSection>
<InfoSectionTitle>
{t("deviceModel")}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.deviceModel
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.serialNumber &&
fieldConfig.serialNumber.show && (
<InfoSection>
<InfoSectionTitle>
{t("serialNumber")}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.serialNumber
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.username &&
fieldConfig.username.show && (
<InfoSection>
<InfoSectionTitle>
{t("username")}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.username
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.hostname &&
fieldConfig.hostname.show && (
<InfoSection>
<InfoSectionTitle>
{t("hostname")}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.hostname
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.firstSeen && (
<InfoSection>
<InfoSectionTitle>
{t("kernelVersion")}
{t("firstSeen")}
</InfoSectionTitle>
<InfoSectionContent>
{client.fingerprint.kernelVersion}
{formatTimestamp(
client.fingerprint
.firstSeen
)}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.arch &&
fieldConfig.arch.show && (
{client.fingerprint.lastSeen && (
<InfoSection>
<InfoSectionTitle>
{t("architecture")}
{t("lastSeen")}
</InfoSectionTitle>
<InfoSectionContent>
{client.fingerprint.arch}
{formatTimestamp(
client.fingerprint
.lastSeen
)}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.deviceModel &&
fieldConfig.deviceModel.show && (
<InfoSection>
<InfoSectionTitle>
{t("deviceModel")}
</InfoSectionTitle>
<InfoSectionContent>
{client.fingerprint.deviceModel}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.serialNumber &&
fieldConfig.serialNumber.show && (
<InfoSection>
<InfoSectionTitle>
{t("serialNumber")}
</InfoSectionTitle>
<InfoSectionContent>
{client.fingerprint.serialNumber}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.username &&
fieldConfig.username.show && (
<InfoSection>
<InfoSectionTitle>
{t("username")}
</InfoSectionTitle>
<InfoSectionContent>
{client.fingerprint.username}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.hostname &&
fieldConfig.hostname.show && (
<InfoSection>
<InfoSectionTitle>
{t("hostname")}
</InfoSectionTitle>
<InfoSectionContent>
{client.fingerprint.hostname}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.firstSeen && (
<InfoSection>
<InfoSectionTitle>
{t("firstSeen")}
</InfoSectionTitle>
<InfoSectionContent>
{formatTimestamp(
client.fingerprint.firstSeen
)}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.lastSeen && (
<InfoSection>
<InfoSectionTitle>
{t("lastSeen")}
</InfoSectionTitle>
<InfoSectionContent>
{formatTimestamp(
client.fingerprint.lastSeen
)}
</InfoSectionContent>
</InfoSection>
)}
</InfoSections>
);
})()}
</InfoSections>
);
})()}
</SettingsSectionBody>
</SettingsSection>
)}

View File

@@ -41,6 +41,32 @@ export default async function ClientsPage(props: ClientsPageProps) {
const mapClientToRow = (
client: ListClientsResponse["clients"][0]
): ClientRow => {
// Build fingerprint object if any fingerprint data exists
const hasFingerprintData =
(client as any).fingerprintPlatform ||
(client as any).fingerprintOsVersion ||
(client as any).fingerprintKernelVersion ||
(client as any).fingerprintArch ||
(client as any).fingerprintSerialNumber ||
(client as any).fingerprintUsername ||
(client as any).fingerprintHostname ||
(client as any).deviceModel;
const fingerprint = hasFingerprintData
? {
platform: (client as any).fingerprintPlatform || null,
osVersion: (client as any).fingerprintOsVersion || null,
kernelVersion:
(client as any).fingerprintKernelVersion || null,
arch: (client as any).fingerprintArch || null,
deviceModel: (client as any).deviceModel || null,
serialNumber:
(client as any).fingerprintSerialNumber || null,
username: (client as any).fingerprintUsername || null,
hostname: (client as any).fingerprintHostname || null
}
: null;
return {
name: client.name,
id: client.clientId,
@@ -58,7 +84,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
agent: client.agent,
archived: client.archived || false,
blocked: client.blocked || false,
approvalState: client.approvalState
approvalState: client.approvalState,
fingerprint
};
};

View File

@@ -51,6 +51,10 @@ export default async function GeneralSettingsPage({
title: t("general"),
href: `/{orgId}/settings/general`,
exact: true
},
{
title: t("security"),
href: `/{orgId}/settings/general/security`
}
];
if (build !== "oss") {

View File

@@ -1,19 +1,12 @@
"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,
useTransition,
useActionState,
type ComponentRef
useActionState
} from "react";
import {
Form,
@@ -25,13 +18,6 @@ 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";
@@ -55,79 +41,19 @@ import {
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import { SwitchInput } from "@app/components/SwitchInput";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import type { t } from "@faker-js/faker/dist/airline-DF6RqYmq";
import type { OrgContextType } from "@app/contexts/orgContext";
// 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(),
requireTwoFactor: z.boolean().optional(),
maxSessionLengthHours: z.number().nullable().optional(),
passwordExpiryDays: z.number().nullable().optional(),
settingsLogRetentionDaysRequest: z.number(),
settingsLogRetentionDaysAccess: z.number(),
settingsLogRetentionDaysAction: z.number()
subnet: z.string().optional()
});
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 },
{ label: "logRetentionEndOfFollowingYear", value: 9001 }
]
: [])
];
export default function GeneralPage() {
const { org } = useOrgContext();
return (
<SettingsContainer>
<GeneralSectionForm org={org.org} />
<LogRetentionSectionForm org={org.org} />
{build !== "oss" && <SecuritySettingsSectionForm org={org.org} />}
{build !== "saas" && <DeleteForm org={org.org} />}
</SettingsContainer>
);
@@ -340,637 +266,3 @@ function GeneralSectionForm({ org }: SectionFormProps) {
);
}
function LogRetentionSectionForm({ org }: SectionFormProps) {
const form = useForm({
resolver: zodResolver(
GeneralFormSchema.pick({
settingsLogRetentionDaysRequest: true,
settingsLogRetentionDaysAccess: true,
settingsLogRetentionDaysAction: true
})
),
defaultValues: {
settingsLogRetentionDaysRequest:
org.settingsLogRetentionDaysRequest ?? 15,
settingsLogRetentionDaysAccess:
org.settingsLogRetentionDaysAccess ?? 15,
settingsLogRetentionDaysAction:
org.settingsLogRetentionDaysAction ?? 15
},
mode: "onChange"
});
const router = useRouter();
const t = useTranslations();
const { isPaidUser, hasSaasSubscription } = usePaidStatus();
const [, formAction, loadingSave] = useActionState(performSave, null);
const api = createApiClient(useEnvContext());
async function performSave() {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
try {
const reqData = {
settingsLogRetentionDaysRequest:
data.settingsLogRetentionDaysRequest,
settingsLogRetentionDaysAccess:
data.settingsLogRetentionDaysAccess,
settingsLogRetentionDaysAction:
data.settingsLogRetentionDaysAction
} as any;
// Update organization
await api.post(`/org/${org.orgId}`, reqData);
toast({
title: t("orgUpdated"),
description: t("orgUpdatedDescription")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("orgErrorUpdate"),
description: formatAxiosError(e, t("orgErrorUpdateMessage"))
});
}
}
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("logRetention")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("logRetentionDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
action={formAction}
className="grid gap-4"
id="org-log-retention-settings-form"
>
<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 (
hasSaasSubscription &&
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" && (
<>
<PaidFeaturesAlert />
<FormField
control={form.control}
name="settingsLogRetentionDaysAccess"
render={({ field }) => {
const isDisabled = !isPaidUser;
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 = !isPaidUser;
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>
);
}}
/>
</>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<div className="flex justify-end gap-2 mt-4">
<Button
type="submit"
form="org-log-retention-settings-form"
loading={loadingSave}
disabled={loadingSave}
>
{t("saveSettings")}
</Button>
</div>
</SettingsSection>
);
}
function SecuritySettingsSectionForm({ org }: SectionFormProps) {
const router = useRouter();
const form = useForm({
resolver: zodResolver(
GeneralFormSchema.pick({
requireTwoFactor: true,
maxSessionLengthHours: true,
passwordExpiryDays: true
})
),
defaultValues: {
requireTwoFactor: org.requireTwoFactor || false,
maxSessionLengthHours: org.maxSessionLengthHours || null,
passwordExpiryDays: org.passwordExpiryDays || null
},
mode: "onChange"
});
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
// Track initial security policy values
const initialSecurityValues = {
requireTwoFactor: org.requireTwoFactor || false,
maxSessionLengthHours: org.maxSessionLengthHours || null,
passwordExpiryDays: org.passwordExpiryDays || null
};
const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] =
useState(false);
// 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
);
};
const [, formAction, loadingSave] = useActionState(onSubmit, null);
const api = createApiClient(useEnvContext());
const formRef = useRef<ComponentRef<"form">>(null);
async function onSubmit() {
// Check if security policies have changed
if (hasSecurityPolicyChanged()) {
setIsSecurityPolicyConfirmOpen(true);
return;
}
await performSave();
}
async function performSave() {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
try {
const reqData = {
requireTwoFactor: data.requireTwoFactor || false,
maxSessionLengthHours: data.maxSessionLengthHours,
passwordExpiryDays: data.passwordExpiryDays
} as any;
// Update organization
await api.post(`/org/${org.orgId}`, reqData);
toast({
title: t("orgUpdated"),
description: t("orgUpdatedDescription")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("orgErrorUpdate"),
description: formatAxiosError(e, t("orgErrorUpdateMessage"))
});
}
}
return (
<>
<ConfirmDeleteDialog
open={isSecurityPolicyConfirmOpen}
setOpen={setIsSecurityPolicyConfirmOpen}
dialog={
<div className="space-y-2">
<p>{t("securityPolicyChangeDescription")}</p>
</div>
}
buttonText={t("saveSettings")}
onConfirm={performSave}
string={t("securityPolicyChangeConfirmMessage")}
title={t("securityPolicyChangeWarning")}
warningText={t("securityPolicyChangeWarningText")}
/>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("securitySettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("securitySettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
action={formAction}
ref={formRef}
id="security-settings-section-form"
className="space-y-4"
>
<PaidFeaturesAlert />
<FormField
control={form.control}
name="requireTwoFactor"
render={({ field }) => {
const isDisabled = !isPaidUser;
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 = !isPaidUser;
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 = !isPaidUser;
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>
<div className="flex justify-end gap-2 mt-4">
<Button
type="submit"
form="security-settings-section-form"
loading={loadingSave}
disabled={loadingSave}
>
{t("saveSettings")}
</Button>
</div>
</SettingsSection>
</>
);
}

View File

@@ -0,0 +1,751 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import {
useState,
useRef,
useActionState,
type ComponentRef
} from "react";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
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 { useRouter } from "next/navigation";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm
} from "@app/components/Settings";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import { SwitchInput } from "@app/components/SwitchInput";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import type { OrgContextType } from "@app/contexts/orgContext";
// 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 security organization settings
const SecurityFormSchema = z.object({
requireTwoFactor: z.boolean().optional(),
maxSessionLengthHours: z.number().nullable().optional(),
passwordExpiryDays: z.number().nullable().optional(),
settingsLogRetentionDaysRequest: z.number(),
settingsLogRetentionDaysAccess: z.number(),
settingsLogRetentionDaysAction: z.number()
});
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 },
{ label: "logRetentionEndOfFollowingYear", value: 9001 }
]
: [])
];
type SectionFormProps = {
org: OrgContextType["org"]["org"];
};
export default function SecurityPage() {
const { org } = useOrgContext();
return (
<SettingsContainer>
<LogRetentionSectionForm org={org.org} />
{build !== "oss" && <SecuritySettingsSectionForm org={org.org} />}
</SettingsContainer>
);
}
function LogRetentionSectionForm({ org }: SectionFormProps) {
const form = useForm({
resolver: zodResolver(
SecurityFormSchema.pick({
settingsLogRetentionDaysRequest: true,
settingsLogRetentionDaysAccess: true,
settingsLogRetentionDaysAction: true
})
),
defaultValues: {
settingsLogRetentionDaysRequest:
org.settingsLogRetentionDaysRequest ?? 15,
settingsLogRetentionDaysAccess:
org.settingsLogRetentionDaysAccess ?? 15,
settingsLogRetentionDaysAction:
org.settingsLogRetentionDaysAction ?? 15
},
mode: "onChange"
});
const router = useRouter();
const t = useTranslations();
const { isPaidUser, hasSaasSubscription } = usePaidStatus();
const [, formAction, loadingSave] = useActionState(performSave, null);
const api = createApiClient(useEnvContext());
async function performSave() {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
try {
const reqData = {
settingsLogRetentionDaysRequest:
data.settingsLogRetentionDaysRequest,
settingsLogRetentionDaysAccess:
data.settingsLogRetentionDaysAccess,
settingsLogRetentionDaysAction:
data.settingsLogRetentionDaysAction
} as any;
// Update organization
await api.post(`/org/${org.orgId}`, reqData);
toast({
title: t("orgUpdated"),
description: t("orgUpdatedDescription")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("orgErrorUpdate"),
description: formatAxiosError(e, t("orgErrorUpdateMessage"))
});
}
}
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("logRetention")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("logRetentionDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
action={formAction}
className="grid gap-4"
id="org-log-retention-settings-form"
>
<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 (
hasSaasSubscription &&
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" && (
<>
<PaidFeaturesAlert />
<FormField
control={form.control}
name="settingsLogRetentionDaysAccess"
render={({ field }) => {
const isDisabled = !isPaidUser;
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 = !isPaidUser;
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>
);
}}
/>
</>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<div className="flex justify-end gap-2 mt-4">
<Button
type="submit"
form="org-log-retention-settings-form"
loading={loadingSave}
disabled={loadingSave}
>
{t("saveSettings")}
</Button>
</div>
</SettingsSection>
);
}
function SecuritySettingsSectionForm({ org }: SectionFormProps) {
const router = useRouter();
const form = useForm({
resolver: zodResolver(
SecurityFormSchema.pick({
requireTwoFactor: true,
maxSessionLengthHours: true,
passwordExpiryDays: true
})
),
defaultValues: {
requireTwoFactor: org.requireTwoFactor || false,
maxSessionLengthHours: org.maxSessionLengthHours || null,
passwordExpiryDays: org.passwordExpiryDays || null
},
mode: "onChange"
});
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
// Track initial security policy values
const initialSecurityValues = {
requireTwoFactor: org.requireTwoFactor || false,
maxSessionLengthHours: org.maxSessionLengthHours || null,
passwordExpiryDays: org.passwordExpiryDays || null
};
const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] =
useState(false);
// 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
);
};
const [, formAction, loadingSave] = useActionState(onSubmit, null);
const api = createApiClient(useEnvContext());
const formRef = useRef<ComponentRef<"form">>(null);
async function onSubmit() {
// Check if security policies have changed
if (hasSecurityPolicyChanged()) {
setIsSecurityPolicyConfirmOpen(true);
return;
}
await performSave();
}
async function performSave() {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
try {
const reqData = {
requireTwoFactor: data.requireTwoFactor || false,
maxSessionLengthHours: data.maxSessionLengthHours,
passwordExpiryDays: data.passwordExpiryDays
} as any;
// Update organization
await api.post(`/org/${org.orgId}`, reqData);
toast({
title: t("orgUpdated"),
description: t("orgUpdatedDescription")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("orgErrorUpdate"),
description: formatAxiosError(e, t("orgErrorUpdateMessage"))
});
}
}
return (
<>
<ConfirmDeleteDialog
open={isSecurityPolicyConfirmOpen}
setOpen={setIsSecurityPolicyConfirmOpen}
dialog={
<div className="space-y-2">
<p>{t("securityPolicyChangeDescription")}</p>
</div>
}
buttonText={t("saveSettings")}
onConfirm={performSave}
string={t("securityPolicyChangeConfirmMessage")}
title={t("securityPolicyChangeWarning")}
warningText={t("securityPolicyChangeWarningText")}
/>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("securitySettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("securitySettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
action={formAction}
ref={formRef}
id="security-settings-section-form"
className="space-y-4"
>
<PaidFeaturesAlert />
<FormField
control={form.control}
name="requireTwoFactor"
render={({ field }) => {
const isDisabled = !isPaidUser;
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 = !isPaidUser;
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 = !isPaidUser;
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>
<div className="flex justify-end gap-2 mt-4">
<Button
type="submit"
form="security-settings-section-form"
loading={loadingSave}
disabled={loadingSave}
>
{t("saveSettings")}
</Button>
</div>
</SettingsSection>
</>
);
}

View File

@@ -161,7 +161,7 @@ export default function CreateRoleForm({
)}
/>
{build !== "oss" && (
<div className="pt-3">
<div>
<PaidFeaturesAlert />
<FormField

View File

@@ -166,7 +166,7 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
return (
<CredenzaFooter
className={cn(
"mt-8 md:mt-0 -mx-6 px-6 py-4 border-t border-border",
"mt-8 md:mt-0 -mx-6 -mb-4 px-6 py-4 border-t border-border",
className
)}
{...props}

View File

@@ -169,7 +169,7 @@ export default function EditRoleForm({
)}
/>
{build !== "oss" && (
<div className="pt-3">
<div>
<PaidFeaturesAlert />
<FormField

View File

@@ -27,7 +27,7 @@ import ClientDownloadBanner from "./ClientDownloadBanner";
import { Badge } from "./ui/badge";
import { build } from "@server/build";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { t } from "@faker-js/faker/dist/airline-DF6RqYmq";
import { InfoPopup } from "@app/components/ui/info-popup";
export type ClientRow = {
id: number;
@@ -48,6 +48,16 @@ export type ClientRow = {
approvalState: "approved" | "pending" | "denied" | null;
archived?: boolean;
blocked?: boolean;
fingerprint?: {
platform: string | null;
osVersion: string | null;
kernelVersion: string | null;
arch: string | null;
deviceModel: string | null;
serialNumber: string | null;
username: string | null;
hostname: string | null;
} | null;
};
type ClientTableProps = {
@@ -55,10 +65,52 @@ type ClientTableProps = {
orgId: string;
};
function formatPlatform(platform: string | null | undefined): string {
if (!platform) return "-";
const platformMap: Record<string, string> = {
macos: "macOS",
windows: "Windows",
linux: "Linux",
ios: "iOS",
android: "Android",
unknown: "Unknown"
};
return platformMap[platform.toLowerCase()] || platform;
}
export default function UserDevicesTable({ userClients }: ClientTableProps) {
const router = useRouter();
const t = useTranslations();
const formatFingerprintInfo = (fingerprint: ClientRow["fingerprint"]): string => {
if (!fingerprint) return "";
const parts: string[] = [];
if (fingerprint.platform) {
parts.push(`${t("platform")}: ${formatPlatform(fingerprint.platform)}`);
}
if (fingerprint.deviceModel) {
parts.push(`${t("deviceModel")}: ${fingerprint.deviceModel}`);
}
if (fingerprint.osVersion) {
parts.push(`${t("osVersion")}: ${fingerprint.osVersion}`);
}
if (fingerprint.arch) {
parts.push(`${t("architecture")}: ${fingerprint.arch}`);
}
if (fingerprint.hostname) {
parts.push(`${t("hostname")}: ${fingerprint.hostname}`);
}
if (fingerprint.username) {
parts.push(`${t("username")}: ${fingerprint.username}`);
}
if (fingerprint.serialNumber) {
parts.push(`${t("serialNumber")}: ${fingerprint.serialNumber}`);
}
return parts.join("\n");
};
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null
@@ -182,7 +234,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
{
accessorKey: "name",
enableHiding: false,
friendlyName: "Name",
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -193,16 +245,31 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
)
}
>
Name
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const r = row.original;
const fingerprintInfo = r.fingerprint
? formatFingerprintInfo(r.fingerprint)
: null;
return (
<div className="flex items-center gap-2">
<span>{r.name}</span>
{fingerprintInfo && (
<InfoPopup>
<div className="space-y-1 text-sm">
<div className="font-semibold mb-2">
{t("deviceInformation")}
</div>
<div className="text-muted-foreground whitespace-pre-line">
{fingerprintInfo}
</div>
</div>
</InfoPopup>
)}
{r.archived && (
<Badge variant="secondary">
{t("archived")}
@@ -250,7 +317,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
},
{
accessorKey: "userEmail",
friendlyName: "User",
friendlyName: t("users"),
header: ({ column }) => {
return (
<Button
@@ -261,7 +328,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
)
}
>
User
{t("users")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -284,7 +351,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
},
{
accessorKey: "online",
friendlyName: "Connectivity",
friendlyName: t("online"),
header: ({ column }) => {
return (
<Button
@@ -295,7 +362,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
)
}
>
Connectivity
{t("online")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -306,14 +373,14 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected</span>
<span>{t("online")}</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Disconnected</span>
<span>{t("offline")}</span>
</span>
);
}
@@ -321,7 +388,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
},
{
accessorKey: "mbIn",
friendlyName: "Data In",
friendlyName: t("dataIn"),
header: ({ column }) => {
return (
<Button
@@ -332,7 +399,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
)
}
>
Data In
{t("dataIn")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -340,7 +407,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
},
{
accessorKey: "mbOut",
friendlyName: "Data Out",
friendlyName: t("dataOut"),
header: ({ column }) => {
return (
<Button
@@ -351,7 +418,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
)
}
>
Data Out
{t("dataOut")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -399,7 +466,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
},
{
accessorKey: "subnet",
friendlyName: "Address",
friendlyName: t("address"),
header: ({ column }) => {
return (
<Button
@@ -410,7 +477,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
)
}
>
Address
{t("address")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -445,8 +512,8 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
>
<span>
{clientRow.archived
? "Unarchive"
: "Archive"}
? t("actionUnarchiveClient")
: t("actionArchiveClient")}
</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -460,8 +527,8 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
>
<span>
{clientRow.blocked
? "Unblock"
: "Block"}
? t("actionUnblockClient")
: t("actionBlockClient")}
</span>
</DropdownMenuItem>
{!clientRow.userId && (
@@ -473,7 +540,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}}
>
<span className="text-red-500">
Delete
{t("delete")}
</span>
</DropdownMenuItem>
)}
@@ -483,7 +550,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
href={`/${clientRow.orgId}/settings/clients/user/${clientRow.niceId}`}
>
<Button variant={"outline"}>
View
{t("viewDetails")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
@@ -510,10 +577,10 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
<p>{t("clientMessageRemove")}</p>
</div>
}
buttonText="Confirm Delete Client"
buttonText={t("actionDeleteClient")}
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title="Delete Client"
title={t("actionDeleteClient")}
/>
)}
<ClientDownloadBanner />

View File

@@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { useState, useRef, useEffect } from "react";
import { Info } from "lucide-react";
import {
Popover,
@@ -17,25 +17,61 @@ interface InfoPopupProps {
}
export function InfoPopup({ text, info, trigger, children }: InfoPopupProps) {
const [open, setOpen] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleMouseEnter = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setOpen(true);
};
const handleMouseLeave = () => {
// Add a small delay to prevent flickering when moving between trigger and content
timeoutRef.current = setTimeout(() => {
setOpen(false);
}, 100);
};
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const defaultTrigger = (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-full p-0"
className="h-6 w-6 rounded-full p-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<Info className="h-4 w-4" />
<span className="sr-only">Show info</span>
</Button>
);
const triggerElement = trigger ?? defaultTrigger;
return (
<div className="flex items-center space-x-2">
{text && <span>{text}</span>}
<Popover>
<PopoverTrigger asChild>
{trigger ?? defaultTrigger}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
asChild
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{triggerElement}
</PopoverTrigger>
<PopoverContent className="w-80">
<PopoverContent
className="w-80"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children ||
(info && (
<p className="text-sm text-muted-foreground">