Skip to content

Commit d2dbb82

Browse files
authored
feat(console): lock the remaining role-gated actions instead of hiding them (#197)
Extends #194's lock pattern to the actions it didn't cover: profile-form's Save, node-chain-validate's Validate, node-image-lifecycle's Stage/Activate/ Roll back, operators' request/record/complete/cancel/deny, adopt's Preview (the wizard's one entry point), root-detail's Rename, node-detail-panel's Rename/Export key/Import key/Decommission/Reboot, and the SCEP challenge and enrolment mint/revoke/approve/reject actions. Every one of these was either hidden entirely for a role that lacks it, or replaced by a disabled control or a "read-only"/"requires X level" note. They now show the kit's Button lockedReason, or RoleGate for the few that are plain table-row buttons rather than the kit's Button (operators' Deny, Complete and Cancel) -- RoleGate needed a TooltipProvider the console had never wired up for this purpose, so this adds one locally around the Operators page, plus the ResizeObserver stub its Radix Tooltip content needs under jsdom, mirroring the one the UI kit's own test setup already has. Adopt's admin gate used to replace the whole page with a one-line notice; it now renders the real wizard with its endpoint field disabled and its Preview button locked, since nothing past Preview is reachable without it anyway -- the same "no route or server behaviour changes" rule as the rest of this pass. Every test that asserted a hidden or disabled control for one of these now asserts the locked aria-disabled/lockedReason state instead, named in each with a short comment on why it moved, same as #194. No route, permission check or server-side behaviour changes here either; this is presentation only. Refs #196 Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Co-authored-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
1 parent 4bb05e5 commit d2dbb82

19 files changed

Lines changed: 482 additions & 267 deletions

‎apps/console/src/components/node-chain-validate.test.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,16 @@ describe("NodeChainValidate", () => {
5757
).toBeInTheDocument();
5858
});
5959

60-
it("disables the form for a viewer", () => {
60+
// Was toBeDisabled() plus a "Read-only" note; #196 moves it to the kit's
61+
// lock pattern so a viewer still sees and can reach the control.
62+
it("locks Validate for a viewer instead of disabling it", () => {
6163
mockOperator.mockReturnValue({ level: "viewer" });
6264
render(<NodeChainValidate nodeName="acme-issuing-01" />);
63-
expect(screen.getByRole("button", { name: "Validate" })).toBeDisabled();
64-
expect(screen.getByText(/read-only/i)).toBeInTheDocument();
65+
const button = screen.getByRole("button", { name: /validate/i });
66+
expect(button).not.toBeDisabled();
67+
expect(button).toHaveAttribute("aria-disabled", "true");
68+
expect(button).toHaveAccessibleDescription(/needs the operator role/i);
69+
fireEvent.click(button);
70+
expect(screen.queryByText(/chains to/i)).not.toBeInTheDocument();
6571
});
6672
});

‎apps/console/src/components/node-chain-validate.tsx‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ import {
2626

2727
// NodeChainValidate lets an operator paste a certificate and check whether it
2828
// chains to a managed node's CA. It parses operator-supplied certificate
29-
// material, so submitting it needs operator level or above; viewers see the
30-
// form disabled, the same read-only pattern the enrollment page uses.
29+
// material, so submitting it needs operator level or above; a viewer sees
30+
// Validate locked with the kit's lock pattern instead of disabled or hidden.
3131
export const NodeChainValidate = ({ nodeName }: { nodeName: string }) => {
3232
const operator = useOptionalAuth()?.operator;
3333
const canValidate = operator ? operator.level !== "viewer" : false;
@@ -61,13 +61,9 @@ export const NodeChainValidate = ({ nodeName }: { nodeName: string }) => {
6161
value={certificatePem}
6262
/>
6363
</Field>
64-
{canValidate ? null : (
65-
<p className="font-mono text-xs text-muted-foreground">
66-
Read-only — your operator level cannot validate a certificate chain.
67-
</p>
68-
)}
6964
<Button
70-
disabled={!canValidate || pending || certificatePem.trim() === ""}
65+
disabled={canValidate && (pending || certificatePem.trim() === "")}
66+
lockedReason={canValidate ? undefined : "Needs the operator role"}
7167
onClick={() => void submit()}
7268
size="sm"
7369
type="button"

‎apps/console/src/components/node-detail-panel.test.tsx‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,26 @@ describe("NodeDetailPanel escrow actions", () => {
5555
expect(screen.getByRole("button", { name: /^reboot/i })).toBeInTheDocument();
5656
});
5757

58-
it("hides rename, escrow, decommission and reboot actions from a non-admin operator", () => {
58+
// Was hidden entirely for a non-admin; #196 moves all five to the kit's
59+
// lock pattern so a non-admin operator still sees them and why they're
60+
// unavailable.
61+
it("locks rename, escrow, decommission and reboot actions for a non-admin operator instead of hiding them", () => {
5962
level = "operator";
6063
renderPanel();
61-
expect(screen.queryByRole("button", { name: /^rename/i })).not.toBeInTheDocument();
62-
expect(screen.queryByRole("button", { name: /export key/i })).not.toBeInTheDocument();
63-
expect(screen.queryByRole("button", { name: /import key/i })).not.toBeInTheDocument();
64-
expect(screen.queryByRole("button", { name: /decommission/i })).not.toBeInTheDocument();
65-
expect(screen.queryByRole("button", { name: /^reboot/i })).not.toBeInTheDocument();
64+
const locked = [
65+
screen.getByRole("button", { name: /^rename/i }),
66+
screen.getByRole("button", { name: /export key/i }),
67+
screen.getByRole("button", { name: /import key/i }),
68+
screen.getByRole("button", { name: /decommission/i }),
69+
screen.getByRole("button", { name: /^reboot/i }),
70+
];
71+
for (const button of locked) {
72+
expect(button).not.toBeDisabled();
73+
expect(button).toHaveAttribute("aria-disabled", "true");
74+
expect(button).toHaveAccessibleDescription(/needs the admin role/i);
75+
fireEvent.click(button);
76+
}
77+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
6678
});
6779

6880
it("labels the reboot action plainly when no reboot is pending", () => {

‎apps/console/src/components/node-detail-panel.tsx‎

Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -110,35 +110,46 @@ export const NodeDetailPanel = ({
110110
<Link to={`/nodes/${node.name}/rekey`}>{"Re-key\u2026"}</Link>
111111
</Button>
112112
)}
113-
{isAdmin ? (
114-
<Button onClick={() => setDialog("rename")} size="sm" variant="outline">
115-
{"Rename\u2026"}
116-
</Button>
117-
) : null}
118-
{isAdmin ? (
119-
<Button onClick={() => setDialog("export")} size="sm" variant="outline">
120-
{"Export key\u2026"}
121-
</Button>
122-
) : null}
123-
{isAdmin ? (
124-
<Button onClick={() => setDialog("import")} size="sm" variant="outline">
125-
{"Import key\u2026"}
126-
</Button>
127-
) : null}
128-
{isAdmin ? (
129-
<Button onClick={() => setDialog("decommission")} size="sm" variant="destructive">
130-
{"Decommission\u2026"}
131-
</Button>
132-
) : null}
133-
{isAdmin ? (
134-
<Button
135-
onClick={() => setDialog("reboot")}
136-
size="sm"
137-
variant={node.rebootRequired ? "default" : "outline"}
138-
>
139-
{node.rebootRequired ? "Reboot needed\u2026" : "Reboot\u2026"}
140-
</Button>
141-
) : null}
113+
<Button
114+
lockedReason={isAdmin ? undefined : "Needs the admin role"}
115+
onClick={() => setDialog("rename")}
116+
size="sm"
117+
variant="outline"
118+
>
119+
{"Rename\u2026"}
120+
</Button>
121+
<Button
122+
lockedReason={isAdmin ? undefined : "Needs the admin role"}
123+
onClick={() => setDialog("export")}
124+
size="sm"
125+
variant="outline"
126+
>
127+
{"Export key\u2026"}
128+
</Button>
129+
<Button
130+
lockedReason={isAdmin ? undefined : "Needs the admin role"}
131+
onClick={() => setDialog("import")}
132+
size="sm"
133+
variant="outline"
134+
>
135+
{"Import key\u2026"}
136+
</Button>
137+
<Button
138+
lockedReason={isAdmin ? undefined : "Needs the admin role"}
139+
onClick={() => setDialog("decommission")}
140+
size="sm"
141+
variant="destructive"
142+
>
143+
{"Decommission\u2026"}
144+
</Button>
145+
<Button
146+
lockedReason={isAdmin ? undefined : "Needs the admin role"}
147+
onClick={() => setDialog("reboot")}
148+
size="sm"
149+
variant={node.rebootRequired ? "default" : "outline"}
150+
>
151+
{node.rebootRequired ? "Reboot needed\u2026" : "Reboot\u2026"}
152+
</Button>
142153
</div>
143154

144155
{dialog === "rename" ? (

‎apps/console/src/components/node-image-lifecycle.test.tsx‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { render, screen } from "@testing-library/react";
17+
import { fireEvent, render, screen } from "@testing-library/react";
1818
import { beforeEach, describe, expect, it, vi } from "vitest";
1919

2020
import { NodeImageLifecycle } from "@/components/node-image-lifecycle";
@@ -54,12 +54,22 @@ describe("NodeImageLifecycle", () => {
5454
expect(screen.getByRole("button", { name: /roll back/i })).toBeInTheDocument();
5555
});
5656

57-
it("hides every action from a non-admin operator", async () => {
57+
// Was hidden entirely for a non-admin; #196 moves it to the kit's lock
58+
// pattern so a non-admin operator still sees the actions and why they're
59+
// unavailable.
60+
it("locks every action for a non-admin operator instead of hiding it", async () => {
5861
level = "operator";
5962
render(<NodeImageLifecycle caCn="ACME Issuing CA G1" nodeName="acme-issuing-01" />);
6063
await screen.findByText(/running/i);
61-
expect(screen.queryByRole("button", { name: /stage image/i })).not.toBeInTheDocument();
62-
expect(screen.queryByRole("button", { name: /activate image/i })).not.toBeInTheDocument();
63-
expect(screen.queryByRole("button", { name: /roll back/i })).not.toBeInTheDocument();
64+
const stage = screen.getByRole("button", { name: /stage image/i });
65+
const activate = screen.getByRole("button", { name: /activate image/i });
66+
const rollback = screen.getByRole("button", { name: /roll back/i });
67+
for (const button of [stage, activate, rollback]) {
68+
expect(button).not.toBeDisabled();
69+
expect(button).toHaveAttribute("aria-disabled", "true");
70+
expect(button).toHaveAccessibleDescription(/needs the admin role/i);
71+
fireEvent.click(button);
72+
}
73+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
6474
});
6575
});

‎apps/console/src/components/node-image-lifecycle.tsx‎

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -110,29 +110,34 @@ export const NodeImageLifecycle = ({ caCn, nodeName }: { caCn: string; nodeName:
110110
/>
111111
</FieldTiles>
112112

113-
{isAdmin ? (
114-
<div className="flex flex-wrap gap-2">
115-
<Button onClick={() => setDialog("stage")} size="sm" variant="outline">
116-
{"Stage image…"}
117-
</Button>
118-
<Button
119-
disabled={!status.rebootPending}
120-
onClick={() => setDialog("activate")}
121-
size="sm"
122-
variant={status.rebootPending ? "default" : "outline"}
123-
>
124-
{"Activate image…"}
125-
</Button>
126-
<Button
127-
disabled={!status.previousSha256}
128-
onClick={() => setDialog("rollback")}
129-
size="sm"
130-
variant="outline"
131-
>
132-
{"Roll back…"}
133-
</Button>
134-
</div>
135-
) : null}
113+
<div className="flex flex-wrap gap-2">
114+
<Button
115+
lockedReason={isAdmin ? undefined : "Needs the admin role"}
116+
onClick={() => setDialog("stage")}
117+
size="sm"
118+
variant="outline"
119+
>
120+
{"Stage image…"}
121+
</Button>
122+
<Button
123+
disabled={isAdmin && !status.rebootPending}
124+
lockedReason={isAdmin ? undefined : "Needs the admin role"}
125+
onClick={() => setDialog("activate")}
126+
size="sm"
127+
variant={status.rebootPending ? "default" : "outline"}
128+
>
129+
{"Activate image…"}
130+
</Button>
131+
<Button
132+
disabled={isAdmin && !status.previousSha256}
133+
lockedReason={isAdmin ? undefined : "Needs the admin role"}
134+
onClick={() => setDialog("rollback")}
135+
size="sm"
136+
variant="outline"
137+
>
138+
{"Roll back…"}
139+
</Button>
140+
</div>
136141

137142
{dialog === "stage" ? (
138143
<StageNodeImageDialog

‎apps/console/src/components/node-scep-challenges.test.tsx‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { render, screen } from "@testing-library/react";
17+
import { fireEvent, render, screen } from "@testing-library/react";
1818
import { beforeEach, describe, expect, it, vi } from "vitest";
1919

2020
import { NodeScepChallenges } from "@/components/node-scep-challenges";
@@ -51,11 +51,20 @@ describe("NodeScepChallenges", () => {
5151
expect(screen.getByRole("button", { name: /^revoke$/i })).toBeInTheDocument();
5252
});
5353

54-
it("hides Mint and Revoke from a viewer", async () => {
54+
// Was hidden entirely for a viewer; #196 moves both to the kit's lock
55+
// pattern so a viewer still sees them and why they're unavailable.
56+
it("locks Mint and Revoke for a viewer instead of hiding them", async () => {
5557
level = "viewer";
5658
render(<NodeScepChallenges nodeName="acme-issuing-01" />);
5759
await screen.findByText("ch-mock-1");
58-
expect(screen.queryByRole("button", { name: /mint challenge/i })).not.toBeInTheDocument();
59-
expect(screen.queryByRole("button", { name: /^revoke$/i })).not.toBeInTheDocument();
60+
const mint = screen.getByRole("button", { name: /mint challenge/i });
61+
const revoke = screen.getByRole("button", { name: /^revoke/i });
62+
for (const button of [mint, revoke]) {
63+
expect(button).not.toBeDisabled();
64+
expect(button).toHaveAttribute("aria-disabled", "true");
65+
expect(button).toHaveAccessibleDescription(/needs the operator role/i);
66+
fireEvent.click(button);
67+
}
68+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
6069
});
6170
});

‎apps/console/src/components/node-scep-challenges.tsx‎

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,16 @@ export const NodeScepChallenges = ({ nodeName }: { nodeName: string }) => {
7474
{ cell: (c) => c.expiresAt.slice(0, 16).replace("T", " "), header: "Expires", id: "expiresAt" },
7575
{ cell: (c) => c.createdByCn, header: "Minted by", id: "createdByCn" },
7676
{
77-
cell: (c) =>
78-
isOperator ? (
79-
<Button onClick={() => setRevokeTarget(c)} size="sm" variant="outline">
80-
Revoke
81-
</Button>
82-
) : null,
77+
cell: (c) => (
78+
<Button
79+
lockedReason={isOperator ? undefined : "Needs the operator role"}
80+
onClick={() => setRevokeTarget(c)}
81+
size="sm"
82+
variant="outline"
83+
>
84+
Revoke
85+
</Button>
86+
),
8387
header: <span className="sr-only">Actions</span>,
8488
id: "actions",
8589
mobile: "action",
@@ -88,13 +92,15 @@ export const NodeScepChallenges = ({ nodeName }: { nodeName: string }) => {
8892

8993
return (
9094
<div className="space-y-3">
91-
{isOperator ? (
92-
<div className="flex justify-end">
93-
<Button onClick={() => setShowMint(true)} size="sm">
94-
{"Mint challenge…"}
95-
</Button>
96-
</div>
97-
) : null}
95+
<div className="flex justify-end">
96+
<Button
97+
lockedReason={isOperator ? undefined : "Needs the operator role"}
98+
onClick={() => setShowMint(true)}
99+
size="sm"
100+
>
101+
{"Mint challenge…"}
102+
</Button>
103+
</div>
98104

99105
<DataTable
100106
columns={columns}

‎apps/console/src/components/node-scep-enrollments.test.tsx‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { render, screen } from "@testing-library/react";
17+
import { fireEvent, render, screen } from "@testing-library/react";
1818
import { beforeEach, describe, expect, it, vi } from "vitest";
1919

2020
import { NodeScepEnrollments } from "@/components/node-scep-enrollments";
@@ -51,11 +51,20 @@ describe("NodeScepEnrollments", () => {
5151
expect(screen.getByRole("button", { name: /^reject$/i })).toBeInTheDocument();
5252
});
5353

54-
it("hides Approve and Reject from a viewer", async () => {
54+
// Was hidden entirely for a viewer; #196 moves both to the kit's lock
55+
// pattern so a viewer still sees them and why they're unavailable.
56+
it("locks Approve and Reject for a viewer instead of hiding them", async () => {
5557
level = "viewer";
5658
render(<NodeScepEnrollments nodeName="acme-issuing-01" />);
5759
await screen.findByText("CN=switch-1.example.org");
58-
expect(screen.queryByRole("button", { name: /^approve$/i })).not.toBeInTheDocument();
59-
expect(screen.queryByRole("button", { name: /^reject$/i })).not.toBeInTheDocument();
60+
const approve = screen.getByRole("button", { name: /^approve/i });
61+
const reject = screen.getByRole("button", { name: /^reject/i });
62+
for (const button of [approve, reject]) {
63+
expect(button).not.toBeDisabled();
64+
expect(button).toHaveAttribute("aria-disabled", "true");
65+
expect(button).toHaveAccessibleDescription(/needs the operator role/i);
66+
fireEvent.click(button);
67+
}
68+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
6069
});
6170
});

‎apps/console/src/components/node-scep-enrollments.tsx‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,17 +76,25 @@ export const NodeScepEnrollments = ({ nodeName }: { nodeName: string }) => {
7676
id: "receivedAt",
7777
},
7878
{
79-
cell: (e) =>
80-
isOperator ? (
81-
<div className="flex gap-2">
82-
<Button onClick={() => setApproveTarget(e)} size="sm">
83-
Approve
84-
</Button>
85-
<Button onClick={() => setRejectTarget(e)} size="sm" variant="outline">
86-
Reject
87-
</Button>
88-
</div>
89-
) : null,
79+
cell: (e) => (
80+
<div className="flex gap-2">
81+
<Button
82+
lockedReason={isOperator ? undefined : "Needs the operator role"}
83+
onClick={() => setApproveTarget(e)}
84+
size="sm"
85+
>
86+
Approve
87+
</Button>
88+
<Button
89+
lockedReason={isOperator ? undefined : "Needs the operator role"}
90+
onClick={() => setRejectTarget(e)}
91+
size="sm"
92+
variant="outline"
93+
>
94+
Reject
95+
</Button>
96+
</div>
97+
),
9098
header: <span className="sr-only">Actions</span>,
9199
id: "actions",
92100
mobile: "action",

0 commit comments

Comments
 (0)