more cosmetic improvememnts

This commit is contained in:
miloschwartz
2026-09-11 12:17:29 -04:00
parent fe8aae7839
commit 8db205352f
6 changed files with 124 additions and 173 deletions
+5 -4
View File
@@ -80,7 +80,7 @@
"siteManageSites": "Manage Sites",
"siteDescription": "Create and manage sites to enable connectivity to private networks",
"sitesBannerTitle": "Connect Any Network",
"sitesBannerDescription": "A site is a connection to a remote network that allows Pangolin to provide access to resources, whether public or private, to users anywhere. Install the site network connector (Newt) anywhere you can run a binary or container to establish the connection.",
"sitesBannerDescription": "A site is a connection to a remote network that allows Pangolin to provide access to resources, whether public or private, to users anywhere. Install the site network connector anywhere you can run a binary or container to establish the connection.",
"sitesBannerButtonText": "Install Site Connector",
"approvalsBannerTitle": "Approve or Deny Device Access",
"approvalsBannerDescription": "Review and approve or deny device access requests from users. When device approvals are required, users must get admin approval before their devices can connect to your organization's resources.",
@@ -596,7 +596,7 @@
"licensePricingPage": "For the most up-to-date pricing and discounts, please visit the ",
"invite": "Invitations",
"inviteRegenerate": "Regenerate Invitation",
"inviteRegenerateDescription": "Revoke previous invitation and create a new one",
"inviteRegenerateDescription": "Create a new invite link for this user. The previous invitation will be revoked.",
"inviteRemove": "Remove Invitation",
"inviteRemoveError": "Failed to remove invitation",
"inviteRemoveErrorDescription": "An error occurred while removing the invitation.",
@@ -1356,7 +1356,7 @@
"siteLabelsDescription": "Manage labels associated with this site.",
"labelsNotFound": "No labels found.",
"labelsEmptyCreateHint": "Start typing above to create a label.",
"labelSearch": "Search labels",
"labelSearch": "Search labels...",
"labelSearchOrCreate": "Search or create a label",
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
@@ -3996,11 +3996,12 @@
"disconnected": "Disconnected",
"approvalsEmptyStateTitle": "Device Approvals Not Enabled",
"approvalsEmptyStateDescription": "Enable device approvals for roles to require admin approval before users can connect new devices.",
"approvalsEmptyStateHowToTitle": "How to Enable",
"approvalsEmptyStateStep1Title": "Go to Roles",
"approvalsEmptyStateStep1Description": "Navigate to your organization's roles settings to configure device approvals.",
"approvalsEmptyStateStep2Title": "Enable Device Approvals",
"approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.",
"approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review",
"approvalsEmptyStatePreviewDescription": "When enabled, pending device requests will appear here for review.",
"approvalsEmptyStateButtonText": "Manage Roles",
"domainErrorTitle": "We are having trouble verifying your domain",
"idpAdminAutoProvisionPoliciesTabHint": "Configure role mapping and organization policies on the <policiesTabLink>Auto Provision Settings</policiesTabLink> tab.",
@@ -954,7 +954,6 @@ export const ProxyResourceTargetsForm = forwardRef<
colSpan={columns.length}
message={emptyMessage ?? t("targetNoOne")}
action={addTargetButton}
compact
/>
)}
</TableBody>
+48 -103
View File
@@ -1,15 +1,12 @@
"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";
SettingsSection,
SettingsSectionBody,
SettingsSectionFooter
} from "@app/components/Settings";
import { ArrowRight, Settings, ShieldCheck, User } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
@@ -21,102 +18,50 @@ 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>
<SettingsSection>
<SettingsSectionBody>
<div className="flex flex-col items-center text-center py-6 md:py-10 px-2">
<ShieldCheck className="h-8 w-8 text-primary" />
<h2 className="mt-4 text-2xl font-semibold tracking-tight max-w-xl">
{t("approvalsEmptyStateTitle")}
</h2>
<p className="mt-3 text-sm text-muted-foreground max-w-lg">
{t("approvalsEmptyStateDescription")}
</p>
<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 className="mt-8 w-full max-w-lg text-left space-y-3">
<p className="text-sm font-medium text-center">
{t("approvalsEmptyStateHowToTitle")}
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-start gap-2">
<Settings className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<span>
{t("approvalsEmptyStateStep1Description")}
</span>
</li>
<li className="flex items-start gap-2">
<User className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<span>
{t("approvalsEmptyStateStep2Description")}
</span>
</li>
</ul>
</div>
</CardContent>
</Card>
</div>
<p className="mt-8 text-sm text-muted-foreground max-w-lg">
{t("approvalsEmptyStatePreviewDescription")}
</p>
</div>
</SettingsSectionBody>
<SettingsSectionFooter className="justify-center md:justify-center">
<Button asChild>
<Link href={`/${orgId}/settings/access/roles`}>
{t("approvalsEmptyStateButtonText")}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</SettingsSectionFooter>
</SettingsSection>
);
}
+55 -37
View File
@@ -52,9 +52,11 @@ export default function RegenerateInvitationForm({
}: RegenerateInvitationFormProps) {
const [loading, setLoading] = useState(false);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [sendEmail, setSendEmail] = useState(true);
const [expiresInDays, setExpiresInDays] = useState(3);
const { env } = useEnvContext();
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const [validHours, setValidHours] = useState(72);
const api = createApiClient(useEnvContext());
const api = createApiClient({ env });
const { org } = useOrgContext();
const t = useTranslations();
@@ -71,10 +73,11 @@ export default function RegenerateInvitationForm({
useEffect(() => {
if (open) {
setSendEmail(true);
setSendEmail(env.email.emailEnabled);
setValidHours(72);
setExpiresInDays(3);
}
}, [open]);
}, [open, env.email.emailEnabled]);
async function handleRegenerate() {
if (!invitation) return;
@@ -96,15 +99,16 @@ export default function RegenerateInvitationForm({
email: invitation.email,
roleIds: invitation.roleIds,
validHours,
sendEmail,
sendEmail: env.email.emailEnabled && sendEmail,
regenerate: true
});
if (res.status === 200) {
const link = res.data.data.inviteLink;
setInviteLink(link);
setExpiresInDays(validHours / 24);
if (sendEmail) {
if (sendEmail && env.email.emailEnabled) {
toast({
variant: "default",
title: t("inviteRegenerated"),
@@ -127,9 +131,7 @@ export default function RegenerateInvitationForm({
onRegenerate({
id: invitation.id,
email: invitation.email,
expiresAt: new Date(
res.data.data.expiresAt
).toISOString(),
expiresAt: new Date(res.data.data.expiresAt).toISOString(),
roleLabels: invitation.roleLabels,
roleIds: invitation.roleIds
});
@@ -174,33 +176,47 @@ export default function RegenerateInvitationForm({
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("inviteRegenerate")}</CredenzaTitle>
<CredenzaTitle>
{inviteLink
? t("inviteRegenerated")
: t("inviteRegenerate")}
</CredenzaTitle>
<CredenzaDescription>
{t("inviteRegenerateDescription")}
{inviteLink
? sendEmail && env.email.emailEnabled
? t("inviteEmailSentDescription")
: t("inviteSentDescription")
: t("inviteRegenerateDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{!inviteLink ? (
<div>
<p>
{t("inviteQuestionRegenerate", {
email: invitation?.email || ""
})}
</p>
<div className="flex items-center space-x-2 mt-4">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(e) =>
setSendEmail(e as boolean)
}
/>
<label htmlFor="send-email">
{t("inviteSentEmail")}
</label>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("email")}</Label>
<p className="text-sm">{invitation?.email}</p>
</div>
<div className="mt-4 space-y-2">
<Label>{t("inviteValidityPeriod")}</Label>
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(e) =>
setSendEmail(e as boolean)
}
/>
<label
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("inviteEmailSent")}
</label>
</div>
)}
<div className="space-y-2">
<Label>{t("inviteValid")}</Label>
<Select
value={validHours.toString()}
onValueChange={(value) =>
@@ -209,9 +225,7 @@ export default function RegenerateInvitationForm({
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"inviteValidityPeriodSelect"
)}
placeholder={t("selectDuration")}
/>
</SelectTrigger>
<SelectContent>
@@ -228,9 +242,13 @@ export default function RegenerateInvitationForm({
</div>
</div>
) : (
<div className="space-y-4 max-w-md">
<p>{t("inviteRegenerateMessage")}</p>
<CopyTextBox text={inviteLink} wrapText={false} />
<div className="space-y-4">
<p>
{t("inviteExpiresIn", {
days: expiresInDays
})}
</p>
{inviteLink && <CopyTextBox text={inviteLink} />}
</div>
)}
</CredenzaBody>
@@ -249,7 +267,7 @@ export default function RegenerateInvitationForm({
</>
) : (
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
<Button>{t("done")}</Button>
</CredenzaClose>
)}
</CredenzaFooter>
@@ -961,7 +961,6 @@ export function PolicyAccessRulesTable({
colSpan={columns.length}
message={t("rulesNoOne")}
action={emptyStateAction}
compact
/>
)}
</TableBody>
+16 -27
View File
@@ -5,57 +5,46 @@ import { useTranslations } from "next-intl";
import { type ReactNode } from "react";
const PLACEHOLDER_ROW_COUNT = 5;
const COMPACT_PLACEHOLDER_ROW_COUNT = 2;
type DataTableEmptyStateProps = {
colSpan: number;
action?: ReactNode;
message?: string;
compact?: boolean;
};
export function DataTableEmptyState({
colSpan,
action,
message,
compact = false
message
}: DataTableEmptyStateProps) {
const t = useTranslations();
const placeholderRows = compact
? COMPACT_PLACEHOLDER_ROW_COUNT
: PLACEHOLDER_ROW_COUNT;
const minHeightClass = compact ? "min-h-20" : "min-h-[12.5rem]";
return (
<TableRow className="hidden sm:table-row hover:bg-transparent data-[state=selected]:bg-transparent">
<TableCell colSpan={colSpan} className="p-0">
<div
className={`relative w-full overflow-hidden ${minHeightClass}`}
>
<div className="relative w-full overflow-hidden min-h-[12.5rem]">
<div
className="pointer-events-none absolute inset-0 flex flex-col justify-start opacity-50"
aria-hidden
>
{Array.from({ length: placeholderRows }).map((_, i) => (
<div
key={i}
className="flex h-10 shrink-0 items-center border-b border-border/60 px-4 last:border-b-0"
>
<div className="h-3.5 w-full rounded bg-muted/60" />
</div>
))}
{Array.from({ length: PLACEHOLDER_ROW_COUNT }).map(
(_, i) => (
<div
key={i}
className="flex h-10 shrink-0 items-center border-b border-border/60 px-4 last:border-b-0"
>
<div className="h-3.5 w-full rounded bg-muted/60" />
</div>
)
)}
</div>
<div
className={`relative flex w-full flex-col items-center justify-center px-4 ${
compact
? "min-h-20 gap-3 py-4"
: "min-h-[12.5rem] gap-4 py-8"
}`}
>
<div className="relative flex w-full flex-col items-center justify-center px-4 min-h-[12.5rem] gap-4 py-8">
<p className="text-sm text-muted-foreground">
{message ?? t("noResults")}
</p>
{action}
{action ? (
<div className="bg-background">{action}</div>
) : null}
</div>
</div>
</TableCell>