Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions UI_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand All @@ -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:

Expand Down
20 changes: 12 additions & 8 deletions dashboard/scripts/screenshot-approvals.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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-<ttl> — both live in the bar, not in a side panel.
{
const { page: p, ctx } = await snap.navigateAndSnap(
'detail-remember-open',
Expand All @@ -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-<ttl>.
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();
}

Expand All @@ -155,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 });
Expand Down
39 changes: 14 additions & 25 deletions dashboard/src/lib/components/ApprovalDetail.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string | null>(null);
const submitting = $derived(ctrl.submitting);
const error = $derived(formError ?? ctrl.error);
Expand Down Expand Up @@ -91,7 +92,7 @@
selectedTier = 0;
useCustomKey = false;
customKey = '';
ttl = 'forever';
dur = 'once';
formError = null;
});

Expand Down Expand Up @@ -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');
}
</script>

<div class="aq-page aq-view-detail">
Expand Down Expand Up @@ -217,30 +222,20 @@
</div>

{#if isPending}
<!-- prominent action bar: scope + expiry + the three resolutions,
merged into one control strip above the fold -->
<!-- prominent action bar: the scope to remember, then Deny and the
Allow split button (whose caret picks once vs. remember-for) -->
<div class="aq-actionbar">
<RememberControl
tiers={current.suggested_tiers}
bind:selectedTier
bind:useCustomKey
bind:customKey
/>
<ExpiryControl bind:value={ttl} />
<div class="aq-ab-btns">
<button class="ovs-btn ovs-btn-danger" disabled={submitting} onclick={() => resolve('deny')}
>Deny</button
>
<button
class="ovs-btn ovs-btn-secondary"
disabled={submitting}
onclick={() => resolve('allow')}>Allow once</button
>
<button
class="ovs-btn ovs-btn-primary aq-ab-primary"
disabled={submitting || !canRemember}
onclick={() => resolve('allow_remember')}>Allow &amp; Remember</button
>
<AllowSplit bind:dur {canRemember} disabled={submitting} onallow={allow} />
</div>
</div>
{#if willRunInBackground}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions dashboard/src/lib/components/approval/AllowSplit.stories.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import AllowSplit from './AllowSplit.svelte';

const { Story } = defineMeta({
title: 'Approval/AllowSplit',
component: AllowSplit,
tags: ['autodocs'],
argTypes: {
dur: { control: 'select', options: ['once', '1h', '24h', '7d', '30d', 'forever'] },
disabled: { control: 'boolean' },
canRemember: { control: 'boolean' }
},
args: { dur: 'once', disabled: false, canRemember: true, onallow: () => {} }
});
</script>

<Story name="Once" />
<Story name="Remember for 24 hours" args={{ dur: '24h' }} />
<Story name="Always" args={{ dur: 'forever' }} />
<Story name="No scope to remember" args={{ canRemember: false }} />
Loading
Loading