Merge branch 'dev' into feat/show-newt-install-command

This commit is contained in:
Fred KISSIE
2026-01-20 03:36:38 +01:00
63 changed files with 2461 additions and 1069 deletions

View File

@@ -1,4 +1,5 @@
import { ApprovalFeed } from "@app/components/ApprovalFeed";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
@@ -42,6 +43,9 @@ export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) {
title={t("accessApprovalsManage")}
description={t("accessApprovalsDescription")}
/>
<PaidFeaturesAlert />
<OrgProvider org={org}>
<div className="container mx-auto max-w-12xl">
<ApprovalFeed orgId={params.orgId} />

View File

@@ -361,7 +361,7 @@ export default function Page() {
const res = await api
.put(`/org/${orgId}/user`, {
username: values.email, // Use email as username for Google/Azure
email: values.email,
email: values.email || undefined,
name: values.name,
type: "oidc",
idpId: selectedUserOption.idpId,
@@ -403,7 +403,7 @@ export default function Page() {
const res = await api
.put(`/org/${orgId}/user`, {
username: values.username,
email: values.email,
email: values.email || undefined,
name: values.name,
type: "oidc",
idpId: selectedUserOption.idpId,

View File

@@ -29,9 +29,11 @@ import { ListSitesResponse } from "@server/routers/site";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useState, useTransition } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import ActionBanner from "@app/components/ActionBanner";
import { Shield, ShieldOff } from "lucide-react";
const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"),
@@ -45,7 +47,9 @@ export default function GeneralPage() {
const { client, updateClient } = useClientContext();
const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const router = useRouter();
const [, startTransition] = useTransition();
const form = useForm({
resolver: zodResolver(GeneralFormSchema),
@@ -109,8 +113,54 @@ export default function GeneralPage() {
}
}
const handleUnblock = async () => {
if (!client?.clientId) return;
setIsRefreshing(true);
try {
await api.post(`/client/${client.clientId}/unblock`);
// Optimistically update the client context
updateClient({ blocked: false, approvalState: null });
toast({
title: t("unblockClient"),
description: t("unblockClientDescription")
});
startTransition(() => {
router.refresh();
});
} catch (e) {
toast({
variant: "destructive",
title: t("error"),
description: formatAxiosError(e, t("error"))
});
} finally {
setIsRefreshing(false);
}
};
return (
<SettingsContainer>
{/* Blocked Device Banner */}
{client?.blocked && (
<ActionBanner
variant="destructive"
title={t("blocked")}
titleIcon={<Shield className="w-5 h-5" />}
description={t("deviceBlockedDescription")}
actions={
<Button
onClick={handleUnblock}
disabled={isRefreshing}
loading={isRefreshing}
variant="outline"
className="gap-2"
>
<ShieldOff className="size-4" />
{t("unblock")}
</Button>
}
/>
)}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>

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

@@ -0,0 +1,553 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { useClientContext } from "@app/hooks/useClientContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import ActionBanner from "@app/components/ActionBanner";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import { useState, useEffect, useTransition } from "react";
import { Check, Ban, Shield, ShieldOff, Clock } from "lucide-react";
import { useParams } from "next/navigation";
import { FaApple, FaWindows, FaLinux } from "react-icons/fa";
import { SiAndroid } from "react-icons/si";
function formatTimestamp(timestamp: number | null | undefined): string {
if (!timestamp) return "-";
return new Date(timestamp * 1000).toLocaleString();
}
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;
}
function getPlatformIcon(platform: string | null | undefined) {
if (!platform) return null;
const normalizedPlatform = platform.toLowerCase();
switch (normalizedPlatform) {
case "macos":
case "ios":
return <FaApple className="h-4 w-4" />;
case "windows":
return <FaWindows className="h-4 w-4" />;
case "linux":
return <FaLinux className="h-4 w-4" />;
case "android":
return <SiAndroid className="h-4 w-4" />;
default:
return null;
}
}
type FieldConfig = {
show: boolean;
labelKey: string;
};
function getPlatformFieldConfig(
platform: string | null | undefined
): Record<string, FieldConfig> {
const normalizedPlatform = platform?.toLowerCase() || "unknown";
const configs: Record<string, Record<string, FieldConfig>> = {
macos: {
osVersion: { show: true, labelKey: "macosVersion" },
kernelVersion: { show: false, labelKey: "kernelVersion" },
arch: { show: true, labelKey: "architecture" },
deviceModel: { show: true, labelKey: "deviceModel" },
serialNumber: { show: true, labelKey: "serialNumber" },
username: { show: true, labelKey: "username" },
hostname: { show: true, labelKey: "hostname" }
},
windows: {
osVersion: { show: true, labelKey: "windowsVersion" },
kernelVersion: { show: true, labelKey: "kernelVersion" },
arch: { show: true, labelKey: "architecture" },
deviceModel: { show: true, labelKey: "deviceModel" },
serialNumber: { show: true, labelKey: "serialNumber" },
username: { show: true, labelKey: "username" },
hostname: { show: true, labelKey: "hostname" }
},
linux: {
osVersion: { show: true, labelKey: "osVersion" },
kernelVersion: { show: true, labelKey: "kernelVersion" },
arch: { show: true, labelKey: "architecture" },
deviceModel: { show: true, labelKey: "deviceModel" },
serialNumber: { show: true, labelKey: "serialNumber" },
username: { show: true, labelKey: "username" },
hostname: { show: true, labelKey: "hostname" }
},
ios: {
osVersion: { show: true, labelKey: "iosVersion" },
kernelVersion: { show: false, labelKey: "kernelVersion" },
arch: { show: true, labelKey: "architecture" },
deviceModel: { show: true, labelKey: "deviceModel" },
serialNumber: { show: true, labelKey: "serialNumber" },
username: { show: true, labelKey: "username" },
hostname: { show: true, labelKey: "hostname" }
},
android: {
osVersion: { show: true, labelKey: "androidVersion" },
kernelVersion: { show: true, labelKey: "kernelVersion" },
arch: { show: true, labelKey: "architecture" },
deviceModel: { show: true, labelKey: "deviceModel" },
serialNumber: { show: true, labelKey: "serialNumber" },
username: { show: true, labelKey: "username" },
hostname: { show: true, labelKey: "hostname" }
},
unknown: {
osVersion: { show: true, labelKey: "osVersion" },
kernelVersion: { show: true, labelKey: "kernelVersion" },
arch: { show: true, labelKey: "architecture" },
deviceModel: { show: true, labelKey: "deviceModel" },
serialNumber: { show: true, labelKey: "serialNumber" },
username: { show: true, labelKey: "username" },
hostname: { show: true, labelKey: "hostname" }
}
};
return configs[normalizedPlatform] || configs.unknown;
}
export default function GeneralPage() {
const { client, updateClient } = useClientContext();
const { isPaidUser } = usePaidStatus();
const t = useTranslations();
const api = createApiClient(useEnvContext());
const router = useRouter();
const params = useParams();
const orgId = params.orgId as string;
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
) {
api.get(`/org/${orgId}/approvals?approvalState=pending`)
.then((res) => {
const approval = res.data.data.approvals.find(
(a: any) => a.clientId === client.clientId
);
if (approval) {
setApprovalId(approval.approvalId);
}
})
.catch(() => {
// Silently fail - approval might not exist
});
}
}, [
showApprovalFeatures,
client.approvalState,
client.clientId,
orgId,
api
]);
const handleApprove = async () => {
if (!approvalId) return;
setIsRefreshing(true);
try {
await api.put(`/org/${orgId}/approvals/${approvalId}`, {
decision: "approved"
});
// Optimistically update the client context
updateClient({ approvalState: "approved" });
toast({
title: t("accessApprovalUpdated"),
description: t("accessApprovalApprovedDescription")
});
startTransition(() => {
router.refresh();
});
} catch (e) {
toast({
variant: "destructive",
title: t("accessApprovalErrorUpdate"),
description: formatAxiosError(
e,
t("accessApprovalErrorUpdateDescription")
)
});
} finally {
setIsRefreshing(false);
}
};
const handleDeny = async () => {
if (!approvalId) return;
setIsRefreshing(true);
try {
await api.put(`/org/${orgId}/approvals/${approvalId}`, {
decision: "denied"
});
// Optimistically update the client context
updateClient({ approvalState: "denied", blocked: true });
toast({
title: t("accessApprovalUpdated"),
description: t("accessApprovalDeniedDescription")
});
startTransition(() => {
router.refresh();
});
} catch (e) {
toast({
variant: "destructive",
title: t("accessApprovalErrorUpdate"),
description: formatAxiosError(
e,
t("accessApprovalErrorUpdateDescription")
)
});
} finally {
setIsRefreshing(false);
}
};
const handleBlock = async () => {
if (!client.clientId) return;
setIsRefreshing(true);
try {
await api.post(`/client/${client.clientId}/block`);
// Optimistically update the client context
updateClient({ blocked: true, approvalState: "denied" });
toast({
title: t("blockClient"),
description: t("blockClientMessage")
});
startTransition(() => {
router.refresh();
});
} catch (e) {
toast({
variant: "destructive",
title: t("error"),
description: formatAxiosError(e, t("error"))
});
} finally {
setIsRefreshing(false);
}
};
const handleUnblock = async () => {
if (!client.clientId) return;
setIsRefreshing(true);
try {
await api.post(`/client/${client.clientId}/unblock`);
// Optimistically update the client context
updateClient({ blocked: false, approvalState: null });
toast({
title: t("unblockClient"),
description: t("unblockClientDescription")
});
startTransition(() => {
router.refresh();
});
} catch (e) {
toast({
variant: "destructive",
title: t("error"),
description: formatAxiosError(e, t("error"))
});
} finally {
setIsRefreshing(false);
}
};
return (
<SettingsContainer>
{/* Pending Approval Banner */}
{showApprovalFeatures && client.approvalState === "pending" && (
<ActionBanner
variant="warning"
title={t("pendingApproval")}
titleIcon={<Clock className="w-5 h-5" />}
description={t("devicePendingApprovalBannerDescription")}
actions={
<>
<Button
onClick={handleApprove}
disabled={isRefreshing || !approvalId}
loading={isRefreshing}
variant="outline"
className="gap-2"
>
<Check className="size-4" />
{t("approve")}
</Button>
<Button
onClick={handleDeny}
disabled={isRefreshing || !approvalId}
loading={isRefreshing}
variant="outline"
className="gap-2"
>
<Ban className="size-4" />
{t("deny")}
</Button>
</>
}
/>
)}
{/* Blocked Device Banner */}
{client.blocked && client.approvalState !== "pending" && (
<ActionBanner
variant="destructive"
title={t("blocked")}
titleIcon={<Shield className="w-5 h-5" />}
description={t("deviceBlockedDescription")}
actions={
<Button
onClick={handleUnblock}
disabled={isRefreshing}
loading={isRefreshing}
variant="outline"
className="gap-2"
>
<ShieldOff className="size-4" />
{t("unblock")}
</Button>
}
/>
)}
{/* Device Information Section */}
{(client.fingerprint || (client.agent && client.olmVersion)) && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("deviceInformation")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("deviceInformationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{client.agent && client.olmVersion && (
<div className="mb-6">
<InfoSection>
<InfoSectionTitle>
{t("agent")}
</InfoSectionTitle>
<InfoSectionContent>
<Badge variant="secondary">
{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.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("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>
);
})()}
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
);
}

View File

@@ -0,0 +1,57 @@
import ClientInfoCard from "@app/components/ClientInfoCard";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import ClientProvider from "@app/providers/ClientProvider";
import { GetClientResponse } from "@server/routers/client";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
type SettingsLayoutProps = {
children: React.ReactNode;
params: Promise<{ niceId: number | string; orgId: string }>;
};
export default async function SettingsLayout(props: SettingsLayoutProps) {
const params = await props.params;
const { children } = props;
let client = null;
try {
const res = await internal.get<AxiosResponse<GetClientResponse>>(
`/org/${params.orgId}/client/${params.niceId}`,
await authCookieHeader()
);
client = res.data.data;
} catch (error) {
redirect(`/${params.orgId}/settings/clients/user`);
}
const t = await getTranslations();
const navItems = [
{
title: t("general"),
href: `/${params.orgId}/settings/clients/user/${params.niceId}/general`
}
];
return (
<>
<SettingsSectionTitle
title={`${client?.name} Settings`}
description={t("deviceSettingsDescription")}
/>
<ClientProvider client={client}>
<div className="space-y-6">
<ClientInfoCard />
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</div>
</ClientProvider>
</>
);
}

View File

@@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
export default async function ClientPage(props: {
params: Promise<{ orgId: string; niceId: number | string }>;
}) {
const params = await props.params;
redirect(
`/${params.orgId}/settings/clients/user/${params.niceId}/general`
);
}

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

@@ -728,26 +728,28 @@ WantedBy=default.target`
</FormItem>
)}
/>
<div className="flex items-center justify-end md:col-start-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setShowAdvancedSettings(
!showAdvancedSettings
)
}
className="flex items-center gap-2"
>
{showAdvancedSettings ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{t("advancedSettings")}
</Button>
</div>
{form.watch("method") === "newt" && (
<div className="flex items-center justify-end md:col-start-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setShowAdvancedSettings(
!showAdvancedSettings
)
}
className="flex items-center gap-2"
>
{showAdvancedSettings ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{t("advancedSettings")}
</Button>
</div>
)}
{form.watch("method") === "newt" &&
showAdvancedSettings && (
<FormField

View File

@@ -18,8 +18,8 @@ export default function DeviceAuthSuccessPage() {
? env.branding.logo?.authPage?.width || 175
: 175;
const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 58
: 58;
? env.branding.logo?.authPage?.height || 44
: 44;
useEffect(() => {
// Detect if we're on iOS or Android
@@ -82,4 +82,4 @@ export default function DeviceAuthSuccessPage() {
</p>
</>
);
}
}