mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-16 09:56:36 +00:00
Merge branch 'dev' into feat/show-newt-install-command
This commit is contained in:
@@ -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")}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
{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>
|
||||
);
|
||||
|
||||
39
src/components/ApprovalsBanner.tsx
Normal file
39
src/components/ApprovalsBanner.tsx
Normal 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;
|
||||
122
src/components/ApprovalsEmptyState.tsx
Normal file
122
src/components/ApprovalsEmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -88,7 +88,7 @@ type ResourceAuthPortalProps = {
|
||||
idps?: LoginFormIDP[];
|
||||
orgId?: string;
|
||||
branding?: {
|
||||
logoUrl: string;
|
||||
logoUrl?: string | null;
|
||||
logoWidth: number;
|
||||
logoHeight: number;
|
||||
primaryColor: string | null;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user