From 24b492ff181b169d72e0004e6e1f12f1abc6e781 Mon Sep 17 00:00:00 2001 From: Factory Date: Wed, 28 Oct 2026 11:46:47 +0000 Subject: [PATCH 1/2] feat(dashboard): single Allow split button with duration menu on approval detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds "Allow once", "Allow & Remember" and the expiry clock into one primary split button: the caret picks Once / remember For 1h…30d / Always, the main half runs it. Once resolves `allow`; any other choice resolves `allow_remember` at the "Remember as" scope with that TTL. Co-Authored-By: Claude Opus 5.5 --- UI_SPEC.md | 15 +- dashboard/scripts/screenshot-approvals.mjs | 16 +- .../src/lib/components/ApprovalDetail.svelte | 39 ++- .../approval/AllowSplit.stories.svelte | 21 ++ .../lib/components/approval/AllowSplit.svelte | 229 ++++++++++++++++++ 5 files changed, 281 insertions(+), 39 deletions(-) create mode 100644 dashboard/src/lib/components/approval/AllowSplit.stories.svelte create mode 100644 dashboard/src/lib/components/approval/AllowSplit.svelte diff --git a/UI_SPEC.md b/UI_SPEC.md index cf2991149..d00ab800c 100644 --- a/UI_SPEC.md +++ b/UI_SPEC.md @@ -290,11 +290,11 @@ A unified search component used across Services, Template Catalog, Audit Log, Ap ### Split Button -A button with two halves joined seamlessly — left side is the default action, right side is a dropdown to choose an alternative. Used for **Allow & Remember** (approval resolution) and **Refresh Control**. +A button with two halves joined seamlessly — left side is the default action, right side is a dropdown to choose an alternative. Used for **Allow** (approval resolution) and **Refresh Control**. **Structure**: left half has rounded left corners, right half (▾) has rounded right corners. No gap between them — they share a continuous border. -**Approval variant**: Left = "Allow & Remember" (applies most specific tier). Right dropdown = specificity picker showing all tiers with radio selection + expiry dropdown. +**Approval variant**: Left = runs the selected duration, labelled "Allow once" / "Allow for 24 hours" / "Allow always". Right dropdown = duration menu: **Once** (plain allow), then an "Allow & remember" section with **For 1 hour / 24 hours / 7 days / 30 days** and **Always** (remember at the scope chosen in the action bar's "Remember as" dropdown, with that expiry). Picking a duration only changes the selection; the left half resolves. Defaults to Once. ### Notifications Dropdown @@ -492,12 +492,13 @@ ALL permission keys for a request must be covered for auto-approval. A single mi ### Approval resolution -When a user resolves an approval (from the detail panel, notification dropdown, or standalone approval page), two actions are presented: +When a user resolves an approval (from the detail panel, notification dropdown, or standalone approval page), the action bar presents: -- **Allow & Remember** — a **split button** (see Design System). Left side applies the most specific tier (safest default). Right dropdown (▾) opens the specificity picker to choose a broader scope. +- **Remember as** — the specificity picker (below). The most specific tier is pre-selected. - **Deny** +- **Allow** — a **split button** (see Design System). Left side runs the selected duration (default: once). Right dropdown (▾) picks Once, or remember For 1 hour … 30 days / Always at the scope chosen in "Remember as". -#### Specificity picker (split button dropdown) +#### Specificity picker ("Remember as" dropdown) The dropdown reads `suggested_tiers` from the approval API. Each tier includes a `description` (English label generated by Overslash). The permission key string is shown as secondary text. @@ -514,12 +515,10 @@ The dropdown reads `suggested_tiers` from the approval API. Each tier includes a │ │ │ ○ Any GitHub action │ │ github:*:* │ -│ │ -│ Expires: [24h ▾] │ └──────────────────────────────────────────────────────┘ ``` -The most specific tier is pre-selected. Selecting a tier and clicking "Allow & Remember" (or just clicking the left side of the split button) stores permission keys at that scope. +The most specific tier is pre-selected. Allowing with any remember duration (For … / Always) stores permission keys at the selected scope; "Allow once" stores nothing. For `http` service approvals, tiers compose paired keys: diff --git a/dashboard/scripts/screenshot-approvals.mjs b/dashboard/scripts/screenshot-approvals.mjs index a1153f878..af6702496 100644 --- a/dashboard/scripts/screenshot-approvals.mjs +++ b/dashboard/scripts/screenshot-approvals.mjs @@ -115,9 +115,9 @@ try { } }); - // 7. The merged action bar's two dropdowns, open. Picking a tier here is - // what "Allow & Remember" writes — the scope ladder and the expiry menu - // live in the bar, not in a side panel. + // 7. The merged action bar's two dropdowns, open. The scope ladder is what + // a remembering allow writes; the Allow split button's menu picks once + // vs. remember-for- — both live in the bar, not in a side panel. { const { page: p, ctx } = await snap.navigateAndSnap( 'detail-remember-open', @@ -134,10 +134,14 @@ try { } ); await p.keyboard.press('Escape'); - await p.getByRole('button', { name: /Rule expires/ }).click(); - await p.getByRole('listbox', { name: /Rule expiry/i }).waitFor({ timeout: 5_000 }); + // The Allow split button's caret picks once vs. remember-for-. + await p.getByRole('button', { name: 'More allow options' }).click(); + await p.getByRole('menu', { name: /Allow duration/i }).waitFor({ timeout: 5_000 }); await p.waitForTimeout(300); - await snap.snap(p, 'detail-expiry-open'); + await snap.snap(p, 'detail-allow-open'); + await p.getByRole('menuitemradio', { name: /For 24 hours/ }).click(); + await p.getByRole('button', { name: 'Allow for 24 hours', exact: true }).waitFor({ timeout: 5_000 }); + await snap.snap(p, 'detail-allow-24h'); await ctx.close(); } diff --git a/dashboard/src/lib/components/ApprovalDetail.svelte b/dashboard/src/lib/components/ApprovalDetail.svelte index 60aa1c365..cd2dd031f 100644 --- a/dashboard/src/lib/components/ApprovalDetail.svelte +++ b/dashboard/src/lib/components/ApprovalDetail.svelte @@ -10,7 +10,7 @@ import RiskBadge from './approval/RiskBadge.svelte'; import ServiceTile from './approval/ServiceTile.svelte'; import RememberControl from './approval/RememberControl.svelte'; - import ExpiryControl from './approval/ExpiryControl.svelte'; + import AllowSplit from './approval/AllowSplit.svelte'; import { relativeTime } from '#lib/utils/time.ts'; import { shortEmail } from '#lib/identityDisplay.ts'; import { createResolution } from '#lib/approvals/resolution.svelte.ts'; @@ -63,7 +63,8 @@ let selectedTier = $state(0); let useCustomKey = $state(false); let customKey = $state(''); - let ttl = $state('forever'); + // 'once' or a TTL_OPTIONS value — what the Allow split button runs. + let dur = $state('once'); let formError = $state(null); const submitting = $derived(ctrl.submitting); const error = $derived(formError ?? ctrl.error); @@ -91,7 +92,7 @@ selectedTier = 0; useCustomKey = false; customKey = ''; - ttl = 'forever'; + dur = 'once'; formError = null; }); @@ -174,13 +175,17 @@ return; } body.remember_keys = keys; - if (ttl !== 'forever') body.ttl = ttl; + if (dur !== 'forever') body.ttl = dur; } const updated = await ctrl.resolve(body); if (!updated) return; pushToast('success', resolutionToast(resolution, before, updated, body.remember_keys)); onResolved?.(updated); } + + function allow(d: string) { + void resolve(d === 'once' ? 'allow' : 'allow_remember'); + }
@@ -217,8 +222,8 @@
{#if isPending} - +
-
- - +
{#if willRunInBackground} @@ -673,9 +668,6 @@ .ovs-btn-danger:not(:disabled):hover { background: var(--badge-bg-danger); } - .aq-ab-primary { - font-weight: 500; - } /* status bars (non-pending states) */ .aq-statusbar { @@ -1117,15 +1109,12 @@ align-items: stretch; gap: 10px; } - /* stack the merged bar: scope, then expiry, then the buttons */ + /* stack the merged bar: scope, then the buttons */ .aq-actionbar :global(.aq-remember) { order: 1; } - .aq-actionbar :global(.aq-expctl) { - order: 2; - } .aq-ab-btns { - order: 3; + order: 2; } .aq-ab-btns .ovs-btn { flex: 1; diff --git a/dashboard/src/lib/components/approval/AllowSplit.stories.svelte b/dashboard/src/lib/components/approval/AllowSplit.stories.svelte new file mode 100644 index 000000000..e0e632a76 --- /dev/null +++ b/dashboard/src/lib/components/approval/AllowSplit.stories.svelte @@ -0,0 +1,21 @@ + + + + + + diff --git a/dashboard/src/lib/components/approval/AllowSplit.svelte b/dashboard/src/lib/components/approval/AllowSplit.svelte new file mode 100644 index 000000000..e052f6aee --- /dev/null +++ b/dashboard/src/lib/components/approval/AllowSplit.svelte @@ -0,0 +1,229 @@ + + +
+ + + + {#if open} + + {/if} +
+ + From cae159d4f9551fffbcc448d03824cc19e98de8fd Mon Sep 17 00:00:00 2001 From: Factory Date: Wed, 28 Oct 2026 11:53:06 +0000 Subject: [PATCH 2/2] fix(dashboard): screenshot-approvals waits for load, not networkidle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authenticated pages hold an SSE stream open, so networkidle never fires and goto times out — the same reason snap.mjs dropped it. Co-Authored-By: Claude Opus 5.5 --- dashboard/scripts/screenshot-approvals.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dashboard/scripts/screenshot-approvals.mjs b/dashboard/scripts/screenshot-approvals.mjs index af6702496..af459ce08 100644 --- a/dashboard/scripts/screenshot-approvals.mjs +++ b/dashboard/scripts/screenshot-approvals.mjs @@ -44,7 +44,7 @@ try { const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); const page = await ctx.newPage(); await page.goto(`${session.dashboardUrl}/approvals/${approval.id}`, { - waitUntil: 'networkidle' + waitUntil: 'load' }); await page.waitForURL(/\/login\?return_to=/, { timeout: 10_000 }); await page.waitForTimeout(500); @@ -159,7 +159,7 @@ try { body: '{"n":2}' }); const { page: p, ctx } = await snap.page({ viewport: { width: 1280, height: 800 } }); - await p.goto(`${session.dashboardUrl}/approvals`, { waitUntil: 'networkidle' }); + await p.goto(`${session.dashboardUrl}/approvals`, { waitUntil: 'load' }); await p.getByRole('button', { name: 'Approve once', exact: true }).first().waitFor({ timeout: 15_000 }); await p.getByRole('button', { name: 'Approve once', exact: true }).first().click(); await p.getByRole('status').waitFor({ timeout: 10_000 });