fix(portal): correct the licence source and the spacing bugs behind it

Four of these look like polish and are not.

The audit notice used the portal tier, which on self-hosted is derived from the
SaaS account link and can never report Enterprise — so the notice showed on
every install regardless of licence. It now takes the backend's runningEE flag,
passed from the view because Storybook mounts no AppConfigProvider.

The skipped tooltip rendered in caps because the bubble inherits from its
trigger, and the trigger sits inside an uppercased stat label. Reset on the
bubble so no consumer can hit it.

The skipped stat sat below its neighbours because ActionIcon is sized to a
control, not to the text it annotates. Section sub-lines become the same icon
hint, so both now go through one component.

Consecutive banners sat 1.75rem apart: a margin meant for banners inside cards
was also applying to banners that are rows of the flex stack.
This commit is contained in:
Connor Yoh
2026-08-27 11:19:52 +01:00
parent f7fbdb22c1
commit 1f1c3d28dc
13 changed files with 142 additions and 36 deletions
@@ -7763,6 +7763,7 @@ titleCreated = "Key created"
[portal.infrastructure.encryption]
heading = "Encryption at rest"
hintLabel = "More about this section"
subheading = "Stored files are encrypted before they reach disk, the database or object storage."
unknown = "Unknown"
+6
View File
@@ -22,6 +22,12 @@
text-align: left;
white-space: normal;
pointer-events: none;
/* The trigger often sits inside styled text (an uppercased stat label, a
heading), and the bubble is a child of it. Reset what would otherwise be
inherited so the explanation always reads as prose. */
text-transform: none;
letter-spacing: normal;
font-style: normal;
}
.sui-tip__bubble--top {
@@ -0,0 +1,18 @@
/* ActionIcon is sized to match a control's height, which is far taller than the
label or heading this sits beside — enough to push a stat's value off the row
its siblings sit on. Shrink it to the text it annotates. */
.portal-hint .mantine-ActionIcon-root {
--ai-size: 1.1rem;
min-width: 1.1rem;
min-height: 1.1rem;
color: var(--c-text-subtle);
}
.portal-hint .mantine-ActionIcon-root:hover {
color: var(--c-text);
}
/* Keep the trigger on the text baseline rather than the line box's centre. */
.portal-hint {
vertical-align: middle;
}
@@ -0,0 +1,29 @@
import { ActionIcon, Tooltip, type TooltipPlacement } from "@app/ui";
import LocalIcon from "@app/components/shared/LocalIcon";
import "@portal/components/InfoHint.css";
export interface InfoHintProps {
/** The explanation. Shown on hover, keyboard focus and tap. */
content: string;
/** Accessible name for the trigger, e.g. "What does skipped mean?". */
label: string;
placement?: TooltipPlacement;
}
/**
* The small circled "i" that carries a line of explanation next to a heading or
* a label, so the copy is there when someone wants it and out of the way when
* they do not.
*
* Sized to the text rather than to a control, because the usual home for one of
* these is inside a label whose line box it must not inflate.
*/
export function InfoHint({ content, label, placement = "top" }: InfoHintProps) {
return (
<Tooltip content={content} placement={placement} className="portal-hint">
<ActionIcon variant="quiet" size="sm" aria-label={label}>
<LocalIcon icon="info-rounded" width="0.9rem" />
</ActionIcon>
</Tooltip>
);
}
@@ -130,7 +130,8 @@ export function EncryptionKeyTable({
<Card padding="loose">
<SectionHeader
title={t("portal.infrastructure.encryption.keys.heading")}
sub={t("portal.infrastructure.encryption.keys.subheading")}
hint={t("portal.infrastructure.encryption.keys.subheading")}
hintLabel={t("portal.infrastructure.encryption.hintLabel")}
/>
{/* Column headers over an empty body are chrome around nothing. */}
{keys.length === 0 ? (
@@ -1,7 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActionIcon,
Banner,
Button,
Card,
@@ -9,9 +8,8 @@ import {
StatTile,
Modal,
StatusBadge,
Tooltip,
} from "@app/ui";
import LocalIcon from "@app/components/shared/LocalIcon";
import { InfoHint } from "@portal/components/InfoHint";
import type { StatusTone } from "@app/ui";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
import type {
@@ -67,7 +65,8 @@ export function EncryptionMigrationCard({
<div className="portal-enc__head">
<SectionHeader
title={t("portal.infrastructure.encryption.migration.heading")}
sub={t("portal.infrastructure.encryption.migration.subheading")}
hint={t("portal.infrastructure.encryption.migration.subheading")}
hintLabel={t("portal.infrastructure.encryption.hintLabel")}
/>
<StatusBadge tone={STATE_TONE[state]} size="sm">
{t(`portal.infrastructure.encryption.migration.state.${state}`)}
@@ -122,24 +121,17 @@ export function EncryptionMigrationCard({
/>
<StatTile
label={
<span className="portal-enc__stat-label">
<>
{t("portal.infrastructure.encryption.migration.skipped")}
<Tooltip
<InfoHint
content={t(
"portal.infrastructure.encryption.migration.skippedNote",
)}
>
<ActionIcon
variant="quiet"
size="sm"
aria-label={t(
"portal.infrastructure.encryption.migration.skippedHelp",
)}
>
<LocalIcon icon="info-rounded" width="0.875rem" />
</ActionIcon>
</Tooltip>
</span>
label={t(
"portal.infrastructure.encryption.migration.skippedHelp",
)}
/>
</>
}
value={skipped.toLocaleString()}
/>
@@ -110,6 +110,13 @@
margin: 0.75rem 0 0;
}
/* ...but a banner that is itself a row of the stack is spaced by the stack's
gap. Leaving the margin on stacks the two, so consecutive banners sat 1.75rem
apart while every other row sat at 1rem. */
.portal-enc__stack > .sui-banner {
margin: 0;
}
.portal-enc__fingerprint code {
font-family: var(--font-mono);
font-size: 0.8125rem;
@@ -154,13 +161,17 @@
.portal-enc__migration-stats {
display: flex;
align-items: start;
gap: 1.5rem;
flex-wrap: wrap;
margin-top: 0.75rem;
}
.portal-enc__stat-label {
/* One label carries a hint icon and the others do not. Without a shared line
box its value drops below its neighbours'. */
.portal-enc__migration-stats .sui-stat__label {
display: inline-flex;
align-items: center;
gap: 0.25rem;
min-height: 1.25rem;
}
@@ -117,9 +117,10 @@ export const Active: Story = {
globals: { tier: "enterprise" },
};
/** 03. Pro licence: encryption runs, audit does not. */
/** 03. Non-Enterprise licence: encryption runs, audit does not. */
export const ProLicenceAuditNotice: Story = {
globals: { tier: "pro" },
globals: { tier: "enterprise" },
args: { auditAvailable: false },
};
/** 04. The revoke dialog, which is where the surprising behaviour is explained. */
@@ -10,7 +10,6 @@ import {
StatTile,
StatusBadge,
} from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { errorMessage } from "@portal/api/http";
import {
disableEncryptionKey,
@@ -40,6 +39,13 @@ const MIGRATION_POLL_MS = 2000;
export interface EncryptionPanelProps {
/** Shows the cross-node propagation note when revoking. */
clusterEnabled?: boolean;
/**
* Whether the licence records audit events (Enterprise). Supplied by the view
* from the backend's `runningEE` flag rather than read here, because the
* portal tier is derived from the SaaS account link and never reports
* Enterprise on a self-hosted install.
*/
auditAvailable?: boolean;
}
/**
@@ -52,9 +58,9 @@ export interface EncryptionPanelProps {
*/
export function EncryptionPanel({
clusterEnabled = false,
auditAvailable = true,
}: EncryptionPanelProps) {
const { t } = useTranslation();
const { tier } = useTier();
const [status, setStatus] = useState<StorageEncryptionStatus | null>(null);
const [loadError, setLoadError] = useState<unknown>(null);
@@ -216,7 +222,8 @@ export function EncryptionPanel({
<section className="portal-enc__stack">
<SectionHeader
title={t("portal.infrastructure.encryption.heading")}
sub={t("portal.infrastructure.encryption.subheading")}
hint={t("portal.infrastructure.encryption.subheading")}
hintLabel={t("portal.infrastructure.encryption.hintLabel")}
/>
<Card padding="loose">
<EmptyState
@@ -262,14 +269,15 @@ export function EncryptionPanel({
<section className="portal-enc__head">
<SectionHeader
title={t("portal.infrastructure.encryption.heading")}
sub={t("portal.infrastructure.encryption.subheading")}
hint={t("portal.infrastructure.encryption.subheading")}
hintLabel={t("portal.infrastructure.encryption.hintLabel")}
/>
<StatusBadge tone={writeStateTone} size="sm">
{t(`portal.infrastructure.encryption.writeState.${writeStateKey}`)}
</StatusBadge>
</section>
{tier !== "enterprise" ? (
{!auditAvailable ? (
<Banner
tone="info"
title={t("portal.infrastructure.encryption.auditNotice.title")}
@@ -305,7 +313,8 @@ export function EncryptionPanel({
<Card padding="loose">
<SectionHeader
title={t("portal.infrastructure.encryption.coverage.heading")}
sub={t("portal.infrastructure.encryption.coverage.subheading")}
hint={t("portal.infrastructure.encryption.coverage.subheading")}
hintLabel={t("portal.infrastructure.encryption.hintLabel")}
/>
<div className="portal-enc__coverage">
<div className="portal-enc__coverage-tiles">
@@ -356,7 +365,10 @@ export function EncryptionPanel({
<Card padding="loose">
<SectionHeader
title={t("portal.infrastructure.encryption.masterKey.heading")}
sub={t("portal.infrastructure.encryption.masterKey.subheading")}
hint={t(
"portal.infrastructure.encryption.masterKey.subheading",
)}
hintLabel={t("portal.infrastructure.encryption.hintLabel")}
/>
{status.masterKeyFingerprint ? (
<>
@@ -36,7 +36,8 @@ export function EncryptionRotationCard({
<Card padding="loose">
<SectionHeader
title={t("portal.infrastructure.encryption.rotation.heading")}
sub={t("portal.infrastructure.encryption.rotation.subheading")}
hint={t("portal.infrastructure.encryption.rotation.subheading")}
hintLabel={t("portal.infrastructure.encryption.hintLabel")}
/>
<div className="portal-enc__kv">
@@ -1,9 +1,29 @@
/** Title + sub-line heading shared by every Infrastructure section. */
export function SectionHeader({ title, sub }: { title: string; sub: string }) {
import { InfoHint } from "@portal/components/InfoHint";
export interface SectionHeaderProps {
title: string;
/** Sub-line under the title. Prefer `hint` for anything explanatory. */
sub?: string;
/** Explanation, carried by an info icon beside the title instead of a sub-line. */
hint?: string;
/** Accessible name for the hint trigger. Required whenever `hint` is set. */
hintLabel?: string;
}
/** Title + explanation heading shared by every Infrastructure section. */
export function SectionHeader({
title,
sub,
hint,
hintLabel,
}: SectionHeaderProps) {
return (
<header className="portal-infra__section-head">
<h2 className="portal-infra__section-title">{title}</h2>
<p className="portal-infra__section-sub">{sub}</p>
<div className="portal-infra__section-title-row">
<h2 className="portal-infra__section-title">{title}</h2>
{hint ? <InfoHint content={hint} label={hintLabel ?? title} /> : null}
</div>
{sub ? <p className="portal-infra__section-sub">{sub}</p> : null}
</header>
);
}
@@ -58,6 +58,12 @@
margin-bottom: 0.625rem;
}
.portal-infra__section-title-row {
display: flex;
align-items: center;
gap: 0.375rem;
}
.portal-infra__section-title {
margin: 0;
font-size: 0.9375rem;
@@ -20,7 +20,8 @@ export function Infrastructure() {
const { setActiveView } = useView();
const [searchParams, setSearchParams] = useSearchParams();
// Audit is Enterprise-only; disabled (greyed, inert) on non-enterprise instances.
const auditEnabled = useEnterpriseEnabled().enabled;
const enterprise = useEnterpriseEnabled();
const auditEnabled = enterprise.enabled;
const canOpenTab = useCallback(
(key: string) =>
@@ -99,7 +100,14 @@ export function Infrastructure() {
<div className="portal-infra__panel">
{tab === "api-keys" && <ApiKeysTab />}
{tab === "audit" && <AuditTab />}
{tab === "storage" && <EncryptionPanel />}
{tab === "storage" && (
// Treat "still resolving" as available: flashing "your licence records
// no audit trail" at an Enterprise operator is worse than the notice
// arriving a beat late.
<EncryptionPanel
auditAvailable={enterprise.loading || enterprise.enabled}
/>
)}
</div>
</div>
);