fix(portal): show each action's error on its own card

One shared actionError string was passed only to the migration card, so a
failed revoke or re-wrap appeared under "Encrypt existing files" — and the
rotation card's own error banner was unreachable code. Each action area now
carries its own error, including the fingerprint copy fallback, which was
reported on a card that had nothing to do with it.

Passing the shared string to all three would have rendered the same banner
three times, so the split is the fix rather than the extra props.

Two related corrections in the same handlers:

- A failed revoke, restore or re-wrap now re-reads status. A 409 means the row
  is not what is drawn, and a rotation can fail partway, leaving the pending
  count too high.
- The S3 streaming note followed the write flag, but the backend suppresses
  presigned URLs once encryption is on or any key row exists. A decrypt-only
  install on S3 was streaming every download through the app with nothing on
  screen saying why.

Both bugs now have stories that fail without the fix.
This commit is contained in:
Connor Yoh
2026-09-02 13:33:00 +01:00
parent d6b20fcc2d
commit 6db8a15c22
3 changed files with 99 additions and 12 deletions
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Banner,
Button,
Card,
column,
@@ -23,6 +24,8 @@ const STATUS_TONE: Record<EncryptionKeyStatus, StatusTone> = {
};
export interface EncryptionKeyTableProps {
/** Why the last revoke or restore failed. Shown here, beside the rows it concerns. */
actionError?: string | null;
keys: EncryptionKeyInfo[];
/** Shows the cross-node propagation delay in the revoke dialog. */
clusterEnabled?: boolean;
@@ -39,6 +42,7 @@ export interface EncryptionKeyTableProps {
* dialog exists at all.
*/
export function EncryptionKeyTable({
actionError = null,
keys,
clusterEnabled = false,
busyKeyId = null,
@@ -133,6 +137,10 @@ export function EncryptionKeyTable({
hint={t("portal.infrastructure.encryption.keys.subheading")}
hintLabel={t("portal.infrastructure.encryption.hintLabel")}
/>
{actionError ? (
<Banner tone="warning" description={actionError} />
) : null}
{/* Column headers over an empty body are chrome around nothing. */}
{keys.length === 0 ? (
<EmptyState
@@ -284,6 +284,70 @@ export const MigrationFailed: Story = {
},
};
/**
* A revoke that fails. The error belongs to the key card: one shared error
* string previously rendered it under "Encrypt existing files" instead.
*/
export const KeyActionFailed: Story = {
globals: { tier: "enterprise" },
parameters: {
msw: {
// First match wins in MSW, so the failing handler has to precede the
// healthy set rather than follow it.
handlers: [
http.post(`${BASE}/keys/:keyId/disable`, () =>
HttpResponse.json({ detail: "boom" }, { status: 500 }),
),
...handlers(ACTIVE_STATUS),
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const rowButtons = await canvas.findAllByRole("button", {
name: /^revoke$/i,
});
await userEvent.click(rowButtons[0]);
await userEvent.click(
await within(document.body).findByRole("button", {
name: /revoke access/i,
}),
);
const message = await canvas.findByText(/could not be applied/i);
const keysCard = canvas.getByText(/scope keys/i).closest(".sui-card");
await expect(keysCard).not.toBeNull();
await expect(keysCard?.contains(message)).toBe(true);
},
};
/**
* Decrypt-only on S3. The backend still streams downloads through the app once
* any key exists, so the bandwidth note has to show here too — it used to be
* tied to the write flag and vanished in exactly this state.
*/
export const DecryptOnlyOnS3: Story = {
globals: { tier: "enterprise" },
parameters: {
msw: {
handlers: handlers({
...ACTIVE_STATUS,
writeEnabled: false,
active: true,
provider: "s3",
encryptedFiles: 4128,
plaintextFiles: 12,
}),
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
await canvas.findByText(/streamed through this server/i),
).toBeInTheDocument();
},
};
/** 09. Mid-rotation: rows still on the old key, so the old key must be kept. */
export const RotationPending: Story = {
globals: { tier: "enterprise" },
@@ -71,7 +71,12 @@ export function EncryptionPanel({
const [starting, setStarting] = useState(false);
const [rotating, setRotating] = useState(false);
const [lastRewrapped, setLastRewrapped] = useState<number | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
// One per action area: a single shared string renders wherever it is passed,
// so a failed revoke surfaced under "Encrypt existing files".
const [keyActionError, setKeyActionError] = useState<string | null>(null);
const [rotateError, setRotateError] = useState<string | null>(null);
const [migrationError, setMigrationError] = useState<string | null>(null);
const [copyError, setCopyError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const mounted = useRef(true);
const fingerprintRef = useRef<HTMLElement | null>(null);
@@ -129,18 +134,19 @@ export function EncryptionPanel({
action: (keyId: string) => Promise<EncryptionKeyInfo>,
) => {
setBusyKeyId(key.keyId);
setActionError(null);
setKeyActionError(null);
try {
await action(key.keyId);
await load();
} catch (error) {
setActionError(
setKeyActionError(
isConflict(error)
? errorMessage(error)
: isMissing(error)
? t("portal.infrastructure.encryption.error.keyMissing")
: t("portal.infrastructure.encryption.error.action"),
);
await load();
} finally {
if (mounted.current) setBusyKeyId(null);
}
@@ -148,11 +154,11 @@ export function EncryptionPanel({
const onStartMigration = async () => {
setStarting(true);
setActionError(null);
setMigrationError(null);
try {
setMigration(await startEncryptionMigration());
} catch (error) {
setActionError(
setMigrationError(
isConflict(error)
? errorMessage(error)
: t("portal.infrastructure.encryption.error.migrationStart"),
@@ -172,6 +178,7 @@ export function EncryptionPanel({
try {
await navigator.clipboard.writeText(fingerprint);
setCopied(true);
setCopyError(null);
} catch {
const node = fingerprintRef.current;
if (node) {
@@ -182,25 +189,25 @@ export function EncryptionPanel({
selection?.addRange(range);
}
setCopied(false);
setActionError(
t("portal.infrastructure.encryption.masterKey.copyFailed"),
);
setCopyError(t("portal.infrastructure.encryption.masterKey.copyFailed"));
}
};
const onRotate = async () => {
setRotating(true);
setActionError(null);
setRotateError(null);
try {
const result = await rotateMasterKey();
setLastRewrapped(result.rewrapped);
await load();
} catch (error) {
setActionError(
setRotateError(
isConflict(error)
? errorMessage(error)
: t("portal.infrastructure.encryption.error.rotate"),
);
// A rotation can fail partway, so the pending count on screen is stale.
await load();
} finally {
if (mounted.current) setRotating(false);
}
@@ -432,13 +439,19 @@ export function EncryptionPanel({
</div>
{/* Presigned URLs are suppressed once anything is encrypted, so
object-store downloads start streaming through the app. */}
{status.provider === "s3" && status.writeEnabled ? (
{status.provider === "s3" &&
(status.writeEnabled ||
status.active ||
status.keys.length > 0) ? (
<p className="portal-enc__note">
{t(
"portal.infrastructure.encryption.masterKey.s3StreamingNote",
)}
</p>
) : null}
{copyError ? (
<Banner tone="warning" description={copyError} />
) : null}
</>
) : (
<p className="portal-enc__note">
@@ -450,6 +463,7 @@ export function EncryptionPanel({
</Card>
<EncryptionRotationCard
actionError={rotateError}
masterKeyVersion={status.masterKeyVersion}
pendingRows={pendingRotationCount(status)}
rotating={rotating}
@@ -459,6 +473,7 @@ export function EncryptionPanel({
</section>
<EncryptionKeyTable
actionError={keyActionError}
keys={status.keys}
clusterEnabled={clusterEnabled}
busyKeyId={busyKeyId}
@@ -473,7 +488,7 @@ export function EncryptionPanel({
plaintextFiles={status.plaintextFiles}
writeEnabled={status.writeEnabled}
starting={starting}
actionError={actionError}
actionError={migrationError}
onStart={() => void onStartMigration()}
/>
</div>