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

This commit is contained in:
Fred KISSIE
2026-01-21 03:26:52 +01:00
71 changed files with 4144 additions and 628 deletions

View File

@@ -1,12 +1,14 @@
import { ApprovalFeed } from "@app/components/ApprovalFeed";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import ApprovalsBanner from "@app/components/ApprovalsBanner";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import type { ApprovalItem } from "@app/lib/queries";
import OrgProvider from "@app/providers/OrgProvider";
import type { GetOrgResponse } from "@server/routers/org";
import type { ListRolesResponse } from "@server/routers/role";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
@@ -35,6 +37,21 @@ export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) {
org = orgRes.data.data;
}
// Fetch roles to check if approvals are enabled
let hasApprovalsEnabled = false;
const rolesRes = await internal
.get<AxiosResponse<ListRolesResponse>>(
`/org/${params.orgId}/roles`,
await authCookieHeader()
)
.catch((e) => {});
if (rolesRes && rolesRes.status === 200) {
hasApprovalsEnabled = rolesRes.data.data.roles.some(
(role) => role.requireDeviceApproval === true
);
}
const t = await getTranslations();
return (
@@ -44,11 +61,16 @@ export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) {
description={t("accessApprovalsDescription")}
/>
<ApprovalsBanner />
<PaidFeaturesAlert />
<OrgProvider org={org}>
<div className="container mx-auto max-w-12xl">
<ApprovalFeed orgId={params.orgId} />
<ApprovalFeed
orgId={params.orgId}
hasApprovalsEnabled={hasApprovalsEnabled}
/>
</div>
</OrgProvider>
</>

View File

@@ -1,5 +1,6 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { ListUsersResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import UsersTable, { UserRow } from "../../../../../components/UsersTable";
@@ -73,7 +74,11 @@ export default async function UsersPage(props: UsersPageProps) {
return {
id: user.id,
username: user.username,
displayUsername: user.email || user.name || user.username,
displayUsername: getUserDisplayName({
email: user.email,
name: user.name,
username: user.username
}),
name: user.name,
email: user.email,
type: user.type,

View File

@@ -22,12 +22,13 @@ import {
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import ActionBanner from "@app/components/ActionBanner";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
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 { Check, Ban, Shield, ShieldOff, Clock, CheckCircle2, XCircle } from "lucide-react";
import { useParams } from "next/navigation";
import { FaApple, FaWindows, FaLinux } from "react-icons/fa";
import { SiAndroid } from "react-icons/si";
@@ -111,18 +112,12 @@ function getPlatformFieldConfig(
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" },
@@ -138,6 +133,7 @@ function getPlatformFieldConfig(
return configs[normalizedPlatform] || configs.unknown;
}
export default function GeneralPage() {
const { client, updateClient } = useClientContext();
const { isPaidUser } = usePaidStatus();
@@ -152,6 +148,20 @@ export default function GeneralPage() {
const showApprovalFeatures = build !== "oss" && isPaidUser;
const formatPostureValue = (value: boolean | null | undefined) => {
if (value === null || value === undefined) return "-";
return (
<div className="flex items-center gap-2">
{value ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)}
<span>{value ? t("enabled") : t("disabled")}</span>
</div>
);
};
// Fetch approval ID for this client if pending
useEffect(() => {
if (
@@ -407,13 +417,13 @@ export default function GeneralPage() {
)}
{client.fingerprint.osVersion &&
fieldConfig.osVersion.show && (
fieldConfig.osVersion?.show && (
<InfoSection>
<InfoSectionTitle>
{t(
fieldConfig
.osVersion
.labelKey
?.labelKey || "osVersion"
)}
</InfoSectionTitle>
<InfoSectionContent>
@@ -426,7 +436,7 @@ export default function GeneralPage() {
)}
{client.fingerprint.kernelVersion &&
fieldConfig.kernelVersion.show && (
fieldConfig.kernelVersion?.show && (
<InfoSection>
<InfoSectionTitle>
{t("kernelVersion")}
@@ -456,7 +466,7 @@ export default function GeneralPage() {
)}
{client.fingerprint.deviceModel &&
fieldConfig.deviceModel.show && (
fieldConfig.deviceModel?.show && (
<InfoSection>
<InfoSectionTitle>
{t("deviceModel")}
@@ -486,7 +496,7 @@ export default function GeneralPage() {
)}
{client.fingerprint.username &&
fieldConfig.username.show && (
fieldConfig.username?.show && (
<InfoSection>
<InfoSectionTitle>
{t("username")}
@@ -501,7 +511,7 @@ export default function GeneralPage() {
)}
{client.fingerprint.hostname &&
fieldConfig.hostname.show && (
fieldConfig.hostname?.show && (
<InfoSection>
<InfoSectionTitle>
{t("hostname")}
@@ -548,6 +558,218 @@ export default function GeneralPage() {
</SettingsSectionBody>
</SettingsSection>
)}
{/* Device Security Section */}
{build !== "oss" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("deviceSecurity")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("deviceSecurityDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{client.posture && Object.keys(client.posture).length > 0 ? (
<>
{!isPaidUser && <PaidFeaturesAlert />}
<InfoSections cols={3}>
{client.posture.biometricsEnabled !== null &&
client.posture.biometricsEnabled !== undefined && (
<InfoSection>
<InfoSectionTitle>
{t("biometricsEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture.biometricsEnabled
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.diskEncrypted !== null &&
client.posture.diskEncrypted !== undefined && (
<InfoSection>
<InfoSectionTitle>
{t("diskEncrypted")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture.diskEncrypted
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.firewallEnabled !== null &&
client.posture.firewallEnabled !== undefined && (
<InfoSection>
<InfoSectionTitle>
{t("firewallEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture.firewallEnabled
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.autoUpdatesEnabled !== null &&
client.posture.autoUpdatesEnabled !== undefined && (
<InfoSection>
<InfoSectionTitle>
{t("autoUpdatesEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture.autoUpdatesEnabled
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.tpmAvailable !== null &&
client.posture.tpmAvailable !== undefined && (
<InfoSection>
<InfoSectionTitle>
{t("tpmAvailable")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture.tpmAvailable
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.windowsDefenderEnabled !== null &&
client.posture.windowsDefenderEnabled !== undefined && (
<InfoSection>
<InfoSectionTitle>
{t("windowsDefenderEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.windowsDefenderEnabled
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.macosSipEnabled !== null &&
client.posture.macosSipEnabled !== undefined && (
<InfoSection>
<InfoSectionTitle>
{t("macosSipEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture.macosSipEnabled
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.macosGatekeeperEnabled !== null &&
client.posture.macosGatekeeperEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("macosGatekeeperEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.macosGatekeeperEnabled
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.macosFirewallStealthMode !== null &&
client.posture.macosFirewallStealthMode !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("macosFirewallStealthMode")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.macosFirewallStealthMode
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.linuxAppArmorEnabled !== null &&
client.posture.linuxAppArmorEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("linuxAppArmorEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.linuxAppArmorEnabled
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.linuxSELinuxEnabled !== null &&
client.posture.linuxSELinuxEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("linuxSELinuxEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.linuxSELinuxEnabled
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
</InfoSections>
</>
) : (
<div className="text-muted-foreground">
{t("noData")}
</div>
)}
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
);
}

View File

@@ -11,6 +11,7 @@ import {
GetLoginPageResponse
} from "@server/routers/loginPage/types";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
export interface AuthPageProps {
params: Promise<{ orgId: string }>;
@@ -18,6 +19,12 @@ export interface AuthPageProps {
export default async function AuthPage(props: AuthPageProps) {
const orgId = (await props.params).orgId;
// custom auth branding is only available in enterprise and saas
if (build === "oss") {
redirect(`/${orgId}/settings/general/`);
}
let subscriptionStatus: GetOrgTierResponse | null = null;
try {
const subRes = await getCachedSubscription(orgId);

View File

@@ -40,6 +40,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { orgQueries, resourceQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build";
@@ -154,7 +155,10 @@ export default function ResourceAuthenticationPage() {
const allUsers = useMemo(() => {
return orgUsers.map((user) => ({
id: user.id.toString(),
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
text: `${getUserDisplayName({
email: user.email,
username: user.username
})}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
}));
}, [orgUsers]);
@@ -229,7 +233,10 @@ export default function ResourceAuthenticationPage() {
"users",
resourceUsers.map((i) => ({
id: i.userId.toString(),
text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
text: `${getUserDisplayName({
email: i.email,
username: i.username
})}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
}))
);

View File

@@ -2,6 +2,7 @@ import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { AdminGetUserResponse } from "@server/routers/user/adminGetUser";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { cache } from "react";
@@ -44,7 +45,15 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
return (
<>
<SettingsSectionTitle
title={`${user?.email || user?.name || user?.username}`}
title={
user
? getUserDisplayName({
email: user.email,
name: user.name,
username: user.username
})
: ""
}
description={t("userDescription2")}
/>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>

View File

@@ -1,6 +1,7 @@
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import DeviceLoginForm from "@/components/DeviceLoginForm";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { cache } from "react";
export const dynamic = "force-dynamic";
@@ -24,7 +25,12 @@ export default async function DeviceLoginPage({ searchParams }: Props) {
);
}
const userName = user?.name || user?.username || "";
const userName = user
? getUserDisplayName({
name: user.name,
username: user.username
})
: "";
return (
<DeviceLoginForm

View File

@@ -61,15 +61,13 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
{
title: "sidebarClientResources",
href: "/{orgId}/settings/resources/client",
icon: <GlobeLock className="size-4 flex-none" />,
isBeta: true
icon: <GlobeLock className="size-4 flex-none" />
}
]
},
{
title: "sidebarClients",
icon: <MonitorUp className="size-4 flex-none" />,
isBeta: true,
items: [
{
href: "/{orgId}/settings/clients/user",

View File

@@ -11,6 +11,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import {
@@ -321,10 +322,13 @@ export default function UsersTable({ users }: Props) {
<div className="space-y-2">
<p>
{t("userQuestionRemove", {
selectedUser:
selected?.email ||
selected?.name ||
selected?.username
selectedUser: selected
? getUserDisplayName({
email: selected.email,
name: selected.name,
username: selected.username
})
: ""
})}
</p>
@@ -337,9 +341,11 @@ export default function UsersTable({ users }: Props) {
}
buttonText={t("userDeleteConfirm")}
onConfirm={async () => deleteUser(selected!.id)}
string={
selected.email || selected.name || selected.username
}
string={getUserDisplayName({
email: selected.email,
name: selected.name,
username: selected.username
})}
title={t("userDeleteServer")}
/>
)}

View File

@@ -2,7 +2,9 @@
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { cn } from "@app/lib/cn";
import { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint";
import {
approvalFiltersSchema,
approvalQueries,
@@ -26,12 +28,18 @@ import {
SelectValue
} from "./ui/select";
import { Separator } from "./ui/separator";
import { InfoPopup } from "./ui/info-popup";
import { ApprovalsEmptyState } from "./ApprovalsEmptyState";
export type ApprovalFeedProps = {
orgId: string;
hasApprovalsEnabled: boolean;
};
export function ApprovalFeed({ orgId }: ApprovalFeedProps) {
export function ApprovalFeed({
orgId,
hasApprovalsEnabled
}: ApprovalFeedProps) {
const searchParams = useSearchParams();
const path = usePathname();
const t = useTranslations();
@@ -48,6 +56,11 @@ export function ApprovalFeed({ orgId }: ApprovalFeedProps) {
const approvals = data?.approvals ?? [];
// Show empty state if no approvals are enabled for any role
if (!hasApprovalsEnabled) {
return <ApprovalsEmptyState orgId={orgId} />;
}
return (
<div className="flex flex-col gap-5">
<Card className="">
@@ -67,7 +80,7 @@ export function ApprovalFeed({ orgId }: ApprovalFeedProps) {
`${path}?${newSearch.toString()}`
);
}}
value={filters.approvalState ?? "all"}
value={filters.approvalState ?? "pending"}
>
<SelectTrigger
id="approvalState"
@@ -182,14 +195,50 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
return (
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="inline-flex items-start md:items-center gap-2">
<LaptopMinimal className="size-4 text-muted-foreground flex-none relative top-2 sm:top-0" />
<span>
<span className="text-primary">
{approval.user.username}
</span>
<Link
href={`/${orgId}/settings/access/users/${approval.user.userId}/access-controls`}
className="text-primary hover:underline cursor-pointer"
>
{getUserDisplayName({
email: approval.user.email,
name: approval.user.name,
username: approval.user.username
})}
</Link>
&nbsp;
{approval.type === "user_device" && (
<span>{t("requestingNewDeviceApproval")}</span>
<span className="inline-flex items-center gap-1">
{approval.deviceName ? (
<>
{t("requestingNewDeviceApproval")}:{" "}
{approval.niceId ? (
<Link
href={`/${orgId}/settings/clients/user/${approval.niceId}/general`}
className="text-primary hover:underline cursor-pointer"
>
{approval.deviceName}
</Link>
) : (
<span>{approval.deviceName}</span>
)}
{approval.fingerprint && (
<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">
{formatFingerprintInfo(approval.fingerprint, t)}
</div>
</div>
</InfoPopup>
)}
</>
) : (
<span>{t("requestingNewDeviceApproval")}</span>
)}
</span>
)}
</span>
</div>
@@ -225,18 +274,6 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
{approval.decision === "denied" && (
<Badge variant="red">{t("denied")}</Badge>
)}
<Button
variant="outline"
onClick={() => {}}
className="gap-2"
asChild
>
<Link href={"#"}>
{t("viewDetails")}
<ArrowRight className="size-4 flex-none" />
</Link>
</Button>
</div>
</div>
);

View File

@@ -0,0 +1,39 @@
"use client";
import React from "react";
import { Button } from "@app/components/ui/button";
import { ShieldCheck, ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import DismissableBanner from "./DismissableBanner";
export const ApprovalsBanner = () => {
const t = useTranslations();
return (
<DismissableBanner
storageKey="approvals-banner-dismissed"
version={1}
title={t("approvalsBannerTitle")}
titleIcon={<ShieldCheck className="w-5 h-5 text-primary" />}
description={t("approvalsBannerDescription")}
>
<Link
href="https://docs.pangolin.net/manage/access-control/approvals"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
{t("approvalsBannerButtonText")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
);
};
export default ApprovalsBanner;

View File

@@ -0,0 +1,122 @@
"use client";
import { Button } from "@app/components/ui/button";
import { Card, CardContent } from "@app/components/ui/card";
import {
ShieldCheck,
Check,
Ban,
User,
Settings,
ArrowRight
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
type ApprovalsEmptyStateProps = {
orgId: string;
};
export function ApprovalsEmptyState({ orgId }: ApprovalsEmptyStateProps) {
const t = useTranslations();
return (
<div className="flex flex-col gap-6">
<Card>
<CardContent className="p-6 md:p-12">
<div className="flex flex-col items-center text-center gap-4 md:gap-6 max-w-2xl mx-auto">
<div className="space-y-2">
<h3 className="text-xl md:text-2xl font-semibold">
{t("approvalsEmptyStateTitle")}
</h3>
<p className="text-muted-foreground text-sm md:text-lg">
{t("approvalsEmptyStateDescription")}
</p>
</div>
<div className="w-full space-y-3 md:space-y-4 mt-2 md:mt-4">
<div className="bg-muted/50 rounded-lg p-4 md:p-6 space-y-3 md:space-y-4 border">
<div className="flex items-start gap-3 md:gap-4">
<div className="rounded-lg bg-background p-2 md:p-3 border shrink-0">
<Settings className="w-4 h-4 md:w-5 md:h-5 text-primary" />
</div>
<div className="flex-1 text-left min-w-0">
<h4 className="font-semibold mb-1 text-sm md:text-base">
{t("approvalsEmptyStateStep1Title")}
</h4>
<p className="text-xs md:text-sm text-muted-foreground">
{t(
"approvalsEmptyStateStep1Description"
)}
</p>
</div>
</div>
<div className="flex items-start gap-3 md:gap-4">
<div className="rounded-lg bg-background p-2 md:p-3 border shrink-0">
<User className="w-4 h-4 md:w-5 md:h-5 text-primary" />
</div>
<div className="flex-1 text-left min-w-0">
<h4 className="font-semibold mb-1 text-sm md:text-base">
{t("approvalsEmptyStateStep2Title")}
</h4>
<p className="text-xs md:text-sm text-muted-foreground">
{t(
"approvalsEmptyStateStep2Description"
)}
</p>
</div>
</div>
</div>
{/* Abstract UI Preview - Hidden on mobile */}
<div className="hidden md:block bg-muted/50 rounded-lg p-6 border">
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-background rounded border">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<User className="w-4 h-4 text-primary" />
</div>
<div>
<div className="h-3 w-24 bg-muted-foreground/20 rounded mb-1"></div>
<div className="h-2 w-32 bg-muted-foreground/10 rounded"></div>
</div>
</div>
<div className="flex gap-2">
<div className="h-6 w-16 bg-muted-foreground/10 rounded"></div>
<div className="h-6 w-16 bg-muted-foreground/10 rounded"></div>
</div>
</div>
<div className="flex items-center justify-between p-3 bg-background rounded border">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<User className="w-4 h-4 text-primary" />
</div>
<div>
<div className="h-3 w-24 bg-muted-foreground/20 rounded mb-1"></div>
<div className="h-2 w-32 bg-muted-foreground/10 rounded"></div>
</div>
</div>
<div className="flex gap-2">
<div className="h-6 w-16 bg-green-500/20 rounded flex items-center justify-center">
<Check className="w-3 h-3 text-green-600" />
</div>
<div className="h-6 w-16 bg-muted-foreground/10 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<Link href={`/${orgId}/settings/access/roles`} className="w-full md:w-auto">
<Button className="gap-2 mt-2 w-full md:w-auto">
{t("approvalsEmptyStateButtonText")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useActionState, useState } from "react";
import { startTransition, useActionState, useState } from "react";
import { useForm } from "react-hook-form";
import z from "zod";
import {
@@ -42,24 +42,27 @@ export type AuthPageCustomizationProps = {
};
const AuthPageFormSchema = z.object({
logoUrl: z.url().refine(
async (url) => {
try {
const response = await fetch(url);
return (
response.status === 200 &&
(response.headers.get("content-type") ?? "").startsWith(
"image/"
)
);
} catch (error) {
return false;
logoUrl: z.union([
z.string().length(0),
z.url().refine(
async (url) => {
try {
const response = await fetch(url);
return (
response.status === 200 &&
(response.headers.get("content-type") ?? "").startsWith(
"image/"
)
);
} catch (error) {
return false;
}
},
{
error: "Invalid logo URL, must be a valid image URL"
}
},
{
error: "Invalid logo URL, must be a valid image URL"
}
),
)
]),
logoWidth: z.coerce.number<number>().min(1),
logoHeight: z.coerce.number<number>().min(1),
orgTitle: z.string().optional(),
@@ -90,7 +93,6 @@ export default function AuthPageBrandingForm({
deleteBranding,
null
);
const [setIsDeleteModalOpen] = useState(false);
const t = useTranslations();
@@ -164,6 +166,7 @@ export default function AuthPageBrandingForm({
title: t("success"),
description: t("authPageBrandingRemoved")
});
form.reset();
}
} catch (error) {
toast({
@@ -398,22 +401,23 @@ export default function AuthPageBrandingForm({
<div className="flex justify-end gap-2 mt-6 items-center">
{branding && (
<Button
variant="destructive"
type="button"
loading={isUpdatingBranding || isDeletingBranding}
disabled={
isUpdatingBranding ||
isDeletingBranding ||
!isPaidUser
}
onClick={() => {
deleteFormAction();
}}
className="gap-1"
>
{t("removeAuthPageBranding")}
</Button>
<form action={deleteFormAction}>
<Button
variant="destructive"
type="submit"
loading={
isUpdatingBranding || isDeletingBranding
}
disabled={
isUpdatingBranding ||
isDeletingBranding ||
!isPaidUser
}
className="gap-1"
>
{t("removeAuthPageBranding")}
</Button>
</form>
)}
<Button
type="submit"

View File

@@ -7,7 +7,7 @@ import Image from "next/image";
import { useEffect, useState } from "react";
type BrandingLogoProps = {
logoPath?: string;
logoPath?: string | null;
width: number;
height: number;
};

View File

@@ -46,6 +46,7 @@ import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { cn } from "@app/lib/cn";
import { orgQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -270,7 +271,10 @@ export default function CreateInternalResourceDialog({
const allUsers = usersResponse.map((user) => ({
id: user.id.toString(),
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
text: `${getUserDisplayName({
email: user.email,
username: user.username
})}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
}));
const allClients = clientsResponse

View File

@@ -83,7 +83,10 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
return (
<CredenzaContent
className={cn("overflow-y-auto max-h-screen", className)}
className={cn(
"overflow-y-auto max-h-[100dvh] md:max-h-screen",
className
)}
{...props}
side={"bottom"}
onOpenAutoFocus={(e) => e.preventDefault()}
@@ -166,7 +169,7 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
return (
<CredenzaFooter
className={cn(
"mt-8 md:mt-0 -mx-6 -mb-4 px-6 py-4 border-t border-border",
"mt-8 md:mt-0 -mx-6 md:-mb-4 px-6 py-4 border-t border-border gap-2 md:gap-0",
className
)}
{...props}

View File

@@ -36,6 +36,7 @@ import {
import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { UserType } from "@server/types/UserTypes";
@@ -304,7 +305,10 @@ export default function EditInternalResourceDialog({
const allUsers = (usersQuery.data ?? []).map((user) => ({
id: user.id.toString(),
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
text: `${getUserDisplayName({
email: user.email,
username: user.username
})}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
}));
const machineClients = (clientsQuery.data ?? [])
@@ -330,7 +334,10 @@ export default function EditInternalResourceDialog({
const formUsers = (resourceUsersQuery.data ?? []).map((i) => ({
id: i.userId.toString(),
text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
text: `${getUserDisplayName({
email: i.email,
username: i.username
})}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
}));
return {

View File

@@ -86,7 +86,7 @@ export default function EditRoleForm({
const res = await api
.post<
AxiosResponse<UpdateRoleResponse>
>(`/org/${org?.org.orgId}/role/${role.roleId}`, values satisfies UpdateRoleBody)
>(`/role/${role.roleId}`, values satisfies UpdateRoleBody)
.catch((e) => {
toast({
variant: "destructive",

View File

@@ -14,7 +14,9 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useUserContext } from "@app/hooks/useUserContext";
import { cn } from "@app/lib/cn";
import { approvalQueries } from "@app/lib/queries";
import { build } from "@server/build";
import { useQuery } from "@tanstack/react-query";
import { ListUserOrgsResponse } from "@server/routers/org";
import { ExternalLink, Server } from "lucide-react";
import { useTranslations } from "next-intl";
@@ -57,6 +59,26 @@ export function LayoutSidebar({
const { env } = useEnvContext();
const t = useTranslations();
// Fetch pending approval count if we have an orgId and it's not an admin page
const shouldFetchApprovalCount =
Boolean(orgId) && !isAdminPage && build !== "oss";
const approvalCountQuery = orgId
? approvalQueries.pendingCount(orgId)
: {
queryKey: ["APPROVALS", "", "COUNT", "pending"] as const,
queryFn: async () => 0
};
const { data: pendingApprovalCount } = useQuery({
...approvalCountQuery,
enabled: shouldFetchApprovalCount
});
// Map notification counts by navigation item title
const notificationCounts: Record<string, number | undefined> = {};
if (pendingApprovalCount !== undefined && pendingApprovalCount > 0) {
notificationCounts["sidebarApprovals"] = pendingApprovalCount;
}
const setSidebarStateCookie = (collapsed: boolean) => {
if (typeof window !== "undefined") {
const isSecure = window.location.protocol === "https:";
@@ -157,6 +179,7 @@ export function LayoutSidebar({
<SidebarNav
sections={navItems}
isCollapsed={isSidebarCollapsed}
notificationCounts={notificationCounts}
/>
</div>
{/* Fade gradient at bottom to indicate scrollable content */}

View File

@@ -235,7 +235,7 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
variant="outline"
onClick={() => refreshAnalytics()}
disabled={isFetchingAnalytics}
className=" relative top-6 lg:static gap-2"
className="relative sm:top-6 lg:static gap-2"
>
<RefreshCw
className={cn(

View File

@@ -264,14 +264,14 @@ export default function MachineClientsTable({
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("connected")}</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("disconnected")}</span>
</span>
);
}

View File

@@ -14,6 +14,7 @@ import {
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { Laptop, LogOut, Moon, Sun, Smartphone } from "lucide-react";
import { useTheme } from "next-themes";
import { useRouter } from "next/navigation";
@@ -49,9 +50,8 @@ export default function ProfileIcon() {
const t = useTranslations();
function getInitials() {
return (user.email || user.name || user.username)
.substring(0, 1)
.toUpperCase();
const displayName = getUserDisplayName({ user });
return displayName.substring(0, 1).toUpperCase();
}
function handleThemeChange(theme: "light" | "dark" | "system") {
@@ -109,7 +109,7 @@ export default function ProfileIcon() {
{t("signingAs")}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email || user.name || user.username}
{getUserDisplayName({ user })}
</p>
</div>
{user.serverAdmin ? (

View File

@@ -88,7 +88,7 @@ type ResourceAuthPortalProps = {
idps?: LoginFormIDP[];
orgId?: string;
branding?: {
logoUrl: string;
logoUrl?: string | null;
logoWidth: number;
logoHeight: number;
primaryColor: string | null;

View File

@@ -46,6 +46,7 @@ export interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
disabled?: boolean;
onItemClick?: () => void;
isCollapsed?: boolean;
notificationCounts?: Record<string, number | undefined>;
}
type CollapsibleNavItemProps = {
@@ -59,6 +60,7 @@ type CollapsibleNavItemProps = {
t: (key: string) => string;
build: string;
isUnlocked: () => boolean;
getNotificationCount: (item: SidebarNavItem) => number | undefined;
};
function CollapsibleNavItem({
@@ -71,8 +73,10 @@ function CollapsibleNavItem({
renderNavItem,
t,
build,
isUnlocked
isUnlocked,
getNotificationCount
}: CollapsibleNavItemProps) {
const notificationCount = getNotificationCount(item);
const storageKey = `pangolin-sidebar-expanded-${item.title}`;
// Get initial state from localStorage or use isChildActive
@@ -139,6 +143,14 @@ function CollapsibleNavItem({
)}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0 ml-2">
{notificationCount !== undefined &&
notificationCount > 0 && (
<Badge variant="secondary">
{notificationCount > 99
? "99+"
: notificationCount}
</Badge>
)}
{build === "enterprise" &&
item.showEE &&
!isUnlocked() && (
@@ -177,6 +189,7 @@ export function SidebarNav({
disabled = false,
onItemClick,
isCollapsed = false,
notificationCounts,
...props
}: SidebarNavProps) {
const pathname = usePathname();
@@ -191,6 +204,11 @@ export function SidebarNav({
const { user } = useUserContext();
const t = useTranslations();
function getNotificationCount(item: SidebarNavItem): number | undefined {
if (!notificationCounts) return undefined;
return notificationCounts[item.title];
}
function hydrateHref(val?: string): string | undefined {
if (!val) return undefined;
return val
@@ -247,16 +265,19 @@ export function SidebarNav({
t={t}
build={build}
isUnlocked={isUnlocked}
getNotificationCount={getNotificationCount}
/>
);
}
const notificationCount = getNotificationCount(item);
// Regular item without nested items
const itemContent = hydratedHref ? (
<Link
href={isDisabled ? "#" : hydratedHref}
className={cn(
"flex items-center rounded-md transition-colors",
"flex items-center rounded-md transition-colors relative",
isCollapsed
? "px-2 py-2 justify-center"
: level === 0
@@ -297,18 +318,38 @@ export function SidebarNav({
</span>
)}
</div>
{build === "enterprise" &&
item.showEE &&
!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="flex-shrink-0"
>
{t("licenseBadge")}
</Badge>
)}
<div className="flex items-center gap-1.5 flex-shrink-0">
{notificationCount !== undefined &&
notificationCount > 0 && (
<Badge variant="secondary">
{notificationCount > 99
? "99+"
: notificationCount}
</Badge>
)}
{build === "enterprise" &&
item.showEE &&
!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="flex-shrink-0"
>
{t("licenseBadge")}
</Badge>
)}
</div>
</>
)}
{isCollapsed &&
notificationCount !== undefined &&
notificationCount > 0 && (
<Badge
variant="secondary"
className="absolute -top-1 -right-1 h-5 min-w-5 px-1.5 flex items-center justify-center text-xs"
>
{notificationCount > 99 ? "99+" : notificationCount}
</Badge>
)}
</Link>
) : (
<div
@@ -332,14 +373,27 @@ export function SidebarNav({
</span>
)}
</div>
{build === "enterprise" && item.showEE && !isUnlocked() && (
<Badge
variant="outlinePrimary"
className="flex-shrink-0 ml-2"
>
{t("licenseBadge")}
</Badge>
)}
<div className="flex items-center gap-1.5 flex-shrink-0 ml-2">
{notificationCount !== undefined &&
notificationCount > 0 && (
<Badge
variant="secondary"
className="flex-shrink-0 bg-primary text-primary-foreground"
>
{notificationCount > 99
? "99+"
: notificationCount}
</Badge>
)}
{build === "enterprise" && item.showEE && !isUnlocked() && (
<Badge
variant="outlinePrimary"
className="flex-shrink-0"
>
{t("licenseBadge")}
</Badge>
)}
</div>
</div>
);

View File

@@ -12,6 +12,8 @@ import {
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { formatFingerprintInfo, formatPlatform } from "@app/lib/formatDeviceFingerprint";
import {
ArrowRight,
ArrowUpDown,
@@ -65,56 +67,10 @@ 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
@@ -228,6 +184,90 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
});
};
const approveDevice = async (clientRow: ClientRow) => {
try {
// Fetch approvalId for this client using clientId query parameter
const approvalsRes = await api.get<{
data: { approvals: Array<{ approvalId: number; clientId: number }> };
}>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`);
const approval = approvalsRes.data.data.approvals[0];
if (!approval) {
toast({
variant: "destructive",
title: t("error"),
description: t("accessApprovalErrorUpdateDescription")
});
return;
}
await api.put(`/org/${clientRow.orgId}/approvals/${approval.approvalId}`, {
decision: "approved"
});
toast({
title: t("accessApprovalUpdated"),
description: t("accessApprovalApprovedDescription")
});
startTransition(() => {
router.refresh();
});
} catch (e) {
toast({
variant: "destructive",
title: t("accessApprovalErrorUpdate"),
description: formatAxiosError(
e,
t("accessApprovalErrorUpdateDescription")
)
});
}
};
const denyDevice = async (clientRow: ClientRow) => {
try {
// Fetch approvalId for this client using clientId query parameter
const approvalsRes = await api.get<{
data: { approvals: Array<{ approvalId: number; clientId: number }> };
}>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`);
const approval = approvalsRes.data.data.approvals[0];
if (!approval) {
toast({
variant: "destructive",
title: t("error"),
description: t("accessApprovalErrorUpdateDescription")
});
return;
}
await api.put(`/org/${clientRow.orgId}/approvals/${approval.approvalId}`, {
decision: "denied"
});
toast({
title: t("accessApprovalUpdated"),
description: t("accessApprovalDeniedDescription")
});
startTransition(() => {
router.refresh();
});
} catch (e) {
toast({
variant: "destructive",
title: t("accessApprovalErrorUpdate"),
description: formatAxiosError(
e,
t("accessApprovalErrorUpdateDescription")
)
});
}
};
// Check if there are any rows without userIds in the current view's data
const hasRowsWithoutUserId = useMemo(() => {
return userClients.some((client) => !client.userId);
@@ -257,7 +297,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
cell: ({ row }) => {
const r = row.original;
const fingerprintInfo = r.fingerprint
? formatFingerprintInfo(r.fingerprint)
? formatFingerprintInfo(r.fingerprint, t)
: null;
return (
<div className="flex items-center gap-2">
@@ -344,7 +384,10 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
href={`/${r.orgId}/settings/access/users/${r.userId}`}
>
<Button variant="outline">
{r.userEmail || r.username || r.userId}
{getUserDisplayName({
email: r.userEmail,
username: r.username
}) || r.userId}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
@@ -355,7 +398,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
},
{
accessorKey: "online",
friendlyName: t("online"),
friendlyName: t("connectivity"),
header: ({ column }) => {
return (
<Button
@@ -377,14 +420,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>{t("online")}</span>
<span>{t("connected")}</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>{t("offline")}</span>
<span>{t("disconnected")}</span>
</span>
);
}
@@ -505,6 +548,20 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{clientRow.approvalState === "pending" && (
<>
<DropdownMenuItem
onClick={() => approveDevice(clientRow)}
>
<span>{t("approve")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => denyDevice(clientRow)}
>
<span>{t("deny")}</span>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem
onClick={() => {
if (clientRow.archived) {

View File

@@ -19,6 +19,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
@@ -271,10 +272,13 @@ export default function UsersTable({ users: u }: UsersTableProps) {
buttonText={t("userRemoveOrgConfirm")}
onConfirm={removeUser}
string={
selectedUser?.email ||
selectedUser?.name ||
selectedUser?.username ||
""
selectedUser
? getUserDisplayName({
email: selectedUser.email,
name: selectedUser.name,
username: selectedUser.username
})
: ""
}
title={t("userRemoveOrg")}
/>

View File

@@ -0,0 +1,71 @@
type DeviceFingerprint = {
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;
/**
* Formats a platform string to a human-readable format
*/
export 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;
}
/**
* Formats device fingerprint information into a human-readable string
*
* @param fingerprint - The device fingerprint object
* @param t - Translation function from next-intl
* @returns Formatted string with device information
*/
export function formatFingerprintInfo(
fingerprint: DeviceFingerprint,
t: (key: string) => string
): string {
if (!fingerprint) return "";
const parts: string[] = [];
const normalizedPlatform = fingerprint.platform?.toLowerCase() || "unknown";
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 (normalizedPlatform !== "ios" && normalizedPlatform !== "android") {
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");
}

View File

@@ -0,0 +1,36 @@
import { GetUserResponse } from "@server/routers/user";
type UserDisplayNameInput =
| {
user: GetUserResponse;
}
| {
email?: string | null;
name?: string | null;
username?: string | null;
};
/**
* Gets the display name for a user.
* Priority: email > name > username
*
* @param input - Either a user object or individual email, name, username properties
* @returns The display name string
*/
export function getUserDisplayName(input: UserDisplayNameInput): string {
let email: string | null | undefined;
let name: string | null | undefined;
let username: string | null | undefined;
if ("user" in input) {
email = input.user.email;
name = input.user.name;
username = input.user.username;
} else {
email = input.email;
name = input.name;
username = input.username;
}
return email || name || username || "";
}

View File

@@ -326,21 +326,34 @@ export const resourceQueries = {
export const approvalFiltersSchema = z.object({
approvalState: z
.enum(["pending", "approved", "denied", "all"])
.optional()
.catch("all")
.default("pending")
.catch("pending")
});
export type ApprovalItem = {
approvalId: number;
orgId: string;
clientId: number | null;
niceId: string | null;
decision: "pending" | "approved" | "denied";
type: "user_device";
user: {
name: string | null;
userId: string;
username: string;
email: string | null;
};
deviceName: string | null;
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;
};
export const approvalQueries = {
@@ -364,5 +377,17 @@ export const approvalQueries = {
});
return res.data.data;
}
}),
pendingCount: (orgId: string) =>
queryOptions({
queryKey: ["APPROVALS", orgId, "COUNT", "pending"] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<{ count: number }>
>(`/org/${orgId}/approvals/count?approvalState=pending`, {
signal
});
return res.data.data.count;
}
})
};