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