diff --git a/src/components/PermissionPicker.test.ts b/src/components/PermissionPicker.test.ts
new file mode 100644
index 0000000..3b56086
--- /dev/null
+++ b/src/components/PermissionPicker.test.ts
@@ -0,0 +1,169 @@
+import { describe, it, expect } from "vitest";
+import { mount } from "@vue/test-utils";
+import PermissionPicker from "./PermissionPicker.vue";
+
+describe("PermissionPicker", () => {
+ const mountPicker = (props: { modelValue: string[]; readonly?: boolean }) => {
+ return mount(PermissionPicker, { props });
+ };
+
+ describe("Rendering", () => {
+ it("renders all permission groups", () => {
+ const wrapper = mountPicker({ modelValue: [] });
+ const groups = wrapper.findAll(".permission-group");
+ expect(groups.length).toBe(20);
+ });
+
+ it("displays group labels", () => {
+ const wrapper = mountPicker({ modelValue: [] });
+ const text = wrapper.text();
+ expect(text).toContain("Deployments");
+ expect(text).toContain("Containers");
+ expect(text).toContain("Databases");
+ expect(text).toContain("Infrastructure");
+ expect(text).toContain("DNS");
+ expect(text).toContain("Audit");
+ });
+
+ it("displays read/write/delete labels within groups", () => {
+ const wrapper = mountPicker({ modelValue: [] });
+ const text = wrapper.text();
+ expect(text).toContain("Read");
+ expect(text).toContain("Write");
+ expect(text).toContain("Delete");
+ });
+ });
+
+ describe("Selection state", () => {
+ it("checks selected permissions", () => {
+ const wrapper = mountPicker({
+ modelValue: ["deployments:read", "containers:write"],
+ });
+ const checkboxes = wrapper.findAll("input[type='checkbox']");
+ const checked = checkboxes.filter((cb) => (cb.element as HTMLInputElement).checked);
+ // 2 individual + their group toggles may or may not be checked
+ expect(checked.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("shows group as fully selected when all permissions are selected", () => {
+ const wrapper = mountPicker({
+ modelValue: ["deployments:read", "deployments:write", "deployments:delete"],
+ });
+ // First group toggle checkbox should be checked
+ const groupToggle = wrapper.find(".group-toggle input[type='checkbox']");
+ expect((groupToggle.element as HTMLInputElement).checked).toBe(true);
+ });
+
+ it("shows group as indeterminate when partially selected", () => {
+ const wrapper = mountPicker({
+ modelValue: ["deployments:read"],
+ });
+ const groupToggle = wrapper.find(".group-toggle input[type='checkbox']");
+ expect((groupToggle.element as HTMLInputElement).indeterminate).toBe(true);
+ });
+
+ it("shows group toggle unchecked when no permissions selected", () => {
+ const wrapper = mountPicker({ modelValue: [] });
+ const groupToggle = wrapper.find(".group-toggle input[type='checkbox']");
+ expect((groupToggle.element as HTMLInputElement).checked).toBe(false);
+ expect((groupToggle.element as HTMLInputElement).indeterminate).toBe(false);
+ });
+ });
+
+ describe("Toggling permissions", () => {
+ it("emits update when toggling a permission on", async () => {
+ const wrapper = mountPicker({ modelValue: [] });
+ const permCheckboxes = wrapper.findAll(".permission-item input[type='checkbox']");
+ await permCheckboxes[0].trigger("change");
+ const emitted = wrapper.emitted("update:modelValue");
+ expect(emitted).toBeTruthy();
+ expect(emitted![0][0]).toContain("deployments:read");
+ });
+
+ it("emits update when toggling a permission off", async () => {
+ const wrapper = mountPicker({
+ modelValue: ["deployments:read", "deployments:write"],
+ });
+ const permCheckboxes = wrapper.findAll(".permission-item input[type='checkbox']");
+ await permCheckboxes[0].trigger("change");
+ const emitted = wrapper.emitted("update:modelValue");
+ expect(emitted).toBeTruthy();
+ expect(emitted![0][0]).not.toContain("deployments:read");
+ expect(emitted![0][0]).toContain("deployments:write");
+ });
+
+ it("selects all group permissions when toggling group on", async () => {
+ const wrapper = mountPicker({ modelValue: [] });
+ const groupToggle = wrapper.find(".group-toggle input[type='checkbox']");
+ await groupToggle.trigger("change");
+ const emitted = wrapper.emitted("update:modelValue");
+ expect(emitted).toBeTruthy();
+ const value = emitted![0][0] as string[];
+ expect(value).toContain("deployments:read");
+ expect(value).toContain("deployments:write");
+ expect(value).toContain("deployments:delete");
+ });
+
+ it("deselects all group permissions when toggling fully selected group off", async () => {
+ const wrapper = mountPicker({
+ modelValue: ["deployments:read", "deployments:write", "deployments:delete"],
+ });
+ const groupToggle = wrapper.find(".group-toggle input[type='checkbox']");
+ await groupToggle.trigger("change");
+ const emitted = wrapper.emitted("update:modelValue");
+ expect(emitted).toBeTruthy();
+ const value = emitted![0][0] as string[];
+ expect(value).not.toContain("deployments:read");
+ expect(value).not.toContain("deployments:write");
+ expect(value).not.toContain("deployments:delete");
+ });
+
+ it("preserves other groups when toggling one group", async () => {
+ const wrapper = mountPicker({
+ modelValue: ["containers:read"],
+ });
+ const groupToggle = wrapper.find(".group-toggle input[type='checkbox']");
+ await groupToggle.trigger("change");
+ const emitted = wrapper.emitted("update:modelValue");
+ const value = emitted![0][0] as string[];
+ expect(value).toContain("containers:read");
+ expect(value).toContain("deployments:read");
+ });
+ });
+
+ describe("Readonly mode", () => {
+ it("hides checkboxes in readonly mode", () => {
+ const wrapper = mountPicker({
+ modelValue: ["deployments:read"],
+ readonly: true,
+ });
+ expect(wrapper.findAll("input[type='checkbox']").length).toBe(0);
+ });
+
+ it("shows granted/denied indicators in readonly mode", () => {
+ const wrapper = mountPicker({
+ modelValue: ["deployments:read"],
+ readonly: true,
+ });
+ expect(wrapper.findAll(".readonly-indicator.granted").length).toBeGreaterThan(0);
+ expect(wrapper.findAll(".readonly-indicator.denied").length).toBeGreaterThan(0);
+ });
+
+ it("hides group toggle checkboxes in readonly mode", () => {
+ const wrapper = mountPicker({
+ modelValue: [],
+ readonly: true,
+ });
+ expect(wrapper.findAll(".group-toggle").length).toBe(0);
+ });
+
+ it("still renders group labels in readonly mode", () => {
+ const wrapper = mountPicker({
+ modelValue: [],
+ readonly: true,
+ });
+ expect(wrapper.text()).toContain("Deployments");
+ expect(wrapper.text()).toContain("Containers");
+ });
+ });
+});
diff --git a/src/components/PermissionPicker.vue b/src/components/PermissionPicker.vue
new file mode 100644
index 0000000..b210319
--- /dev/null
+++ b/src/components/PermissionPicker.vue
@@ -0,0 +1,366 @@
+
+
| Name | +Key Prefix | +Role | +Status | +Last Used | +Expires | +Actions | +
|---|---|---|---|---|---|---|
|
+
+
+ {{ key.name }}
+ {{ key.description }}
+
+ |
+
+ {{ key.key_prefix }}
+ |
+ + {{ key.role }} + Inherited + | ++ + {{ key.is_active ? "Active" : "Revoked" }} + + | +{{ formatDate(key.last_used_at) }} | +{{ formatExpiry(key.expires_at) }} | ++ + + | +
{{ newKeyValue }}
+
+ + Are you sure you want to revoke API key {{ selectedKey?.name }}? This action cannot be undone. +
++ Are you sure you want to delete API key {{ selectedKey?.name }}? +
+Manage cron jobs and scheduled commands across deployments
Create cron jobs to run commands in your containers on a schedule.
-