Addresses two interrelated UX complaints:
1. Browser-default controls leaking through the dark theme
(native <select> chevrons, OS checkbox/radio look, date-picker
icons, <details> triangle marker, number input spinners).
2. Subroute pages dropping the main app's tab bar in favor of a back
link, breaking navigation continuity across users/files/queues/
dead-letters/queues-[name].
Design tokens (dashboard/src/routes/+layout.svelte):
- Token vocabulary expanded with 18 new variables covering
text-strong, accent + accent-fg, danger/success/warning bg/fg/border
triplets, bg-elevated-hover, radii (sm/md/lg/pill), shadow-elev-2,
and z scale (popover/modal/toast). 7 alias tokens (--muted,
--link, --text, --color-error, --color-border, --chip-bg,
--code-bg) absorb the orphan references the F-U-004 remediation
partially renamed.
- Global :global(...) resets for <select>, <input type='checkbox'>,
<input type='radio'>, <input type='number'>, <input type='date'>,
and <details>/<summary> ensure native controls track the dark
palette out of the box. No per-page edits needed.
Tab consistency:
- New dashboard/src/lib/AppTabBar.svelte renders all 11 per-app
tabs (Scripts, Domains, Members, Triggers, Topics, Secrets,
Settings, Users, Files, Queues, Dead letters) as <a> links with
an active highlight derived from the URL. Tabs that switch
in-page panels go to ?tab=<id>; tabs that switch routes go to
the subroute. Admin-only tabs are hidden when canAdmin is false.
- New dashboard/src/routes/apps/[slug]/+layout.svelte loads the
app once, handles the historical-slug redirect, exposes the
shared app + canAdmin + canWrite + dead-letter-count state via
Svelte context, and renders the breadcrumb + AppTabBar above
every per-app page. The 5 subroute pages drop their own "← back"
headers since the layout owns them now.
- apps/[slug]/+page.svelte's local-state activeTab becomes URL-
driven via $page.url.searchParams.get('tab'). Defense-in-depth
redirect for non-admin viewers landing on admin-only tabs uses
goto({replaceState:true}) instead of mutating state.
Light-theme leftovers swept on 5 subroute pages:
- dead-letters: error banner, badge, pre/code blocks all swap to
--color-danger-*, --bg-elevated, --text-primary
- files: button.danger, var(--muted,#666) → token-only
- queues + queues/[name]: bare hex fallbacks removed; .toolbar and
.auto-refresh styled with tokens; data-testid for the queues
empty state (already added by previous commit, reaffirmed here)
- users + users/invitations: badge-ok/badge-pending now use
--color-success-bg/fg and --color-warning-bg/fg; chips use
--bg-elevated + --text-strong; .create-form gets a token-styled
surface; row-action buttons gain explicit dark-theme styling
E2E selector updates:
- members.spec.ts, integration.spec.ts, apps.spec.ts — the tab bar
is now <a> elements (role=link) without a count suffix. Test
selectors swap from getByRole('button', name: /^Scripts \(\d+\)$/)
to getByRole('link', { name: 'Scripts' }), etc.
- New navigation/tabs.spec.ts still passes; existing 60+ tests
unchanged except for the selector swap. Two pre-existing failures
(routing.spec.ts:79, integration.spec.ts:89) untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
169 lines
5.5 KiB
TypeScript
169 lines
5.5 KiB
TypeScript
import { expect } from '@playwright/test';
|
|
import { test } from '../fixtures/ids';
|
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
|
import { adminApi } from '../fixtures/api';
|
|
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
|
|
|
|
// Phase B5 — App Members. Setup creates one or two extra admin
|
|
// users via the API; tests drive the Members tab through the
|
|
// dashboard like a real app admin would.
|
|
|
|
const cleanup = new CleanupRegistry();
|
|
test.afterEach(async () => {
|
|
await cleanup.run();
|
|
});
|
|
|
|
async function createApp(slug: string): Promise<string> {
|
|
const api = await adminApi();
|
|
try {
|
|
const res = await api.post('/api/v1/admin/apps', { data: { slug, name: slug } });
|
|
expect(res.ok()).toBe(true);
|
|
return ((await res.json()) as { id: string }).id;
|
|
} finally {
|
|
await api.dispose();
|
|
}
|
|
}
|
|
|
|
async function createMemberUser(username: string): Promise<string> {
|
|
const api = await adminApi();
|
|
try {
|
|
const res = await api.post('/api/v1/admin/admins', {
|
|
data: { username, password: 'e2e-member-pw', instance_role: 'member' }
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
return ((await res.json()) as { id: string }).id;
|
|
} finally {
|
|
await api.dispose();
|
|
}
|
|
}
|
|
|
|
test.describe('B5 app members', () => {
|
|
test('invite a member-role user, then remove them', async ({ page, uniqueSlug, uniqueUsername }) => {
|
|
const slug = uniqueSlug('mem');
|
|
const username = uniqueUsername('inv');
|
|
await createApp(slug);
|
|
const userId = await createMemberUser(username);
|
|
cleanup.app(slug);
|
|
cleanup.adminUser(userId);
|
|
|
|
await page.goto(`/admin/apps/${slug}`);
|
|
await page.getByRole('link', { name: 'Members' }).click();
|
|
|
|
// Invite. Both selects sit in `form.create-form`; locate them
|
|
// by position to avoid getByLabel ambiguity (the Svelte
|
|
// markup nests both labels in a flex row, which makes their
|
|
// accessible names overlap).
|
|
const form = page.locator('form.create-form');
|
|
await form.locator('select').nth(0).selectOption({ label: username });
|
|
await form.locator('select').nth(1).selectOption('editor');
|
|
await page.getByRole('button', { name: /^Add member$/ }).click();
|
|
await expect(page.locator('.member-row')).toContainText(username);
|
|
|
|
// Remove via action menu + confirm modal.
|
|
await page.getByRole('button', { name: new RegExp(`Member actions for ${username}`) }).click();
|
|
await page.getByRole('menuitem', { name: /^Remove from app$/ }).click();
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog).toBeVisible();
|
|
await dialog.getByRole('button', { name: /^Remove member$/ }).click();
|
|
await expect(page.locator('.member-row')).toHaveCount(0);
|
|
});
|
|
|
|
test('role change via action menu updates the role chip', async ({
|
|
page,
|
|
uniqueSlug,
|
|
uniqueUsername
|
|
}) => {
|
|
const slug = uniqueSlug('mem');
|
|
const username = uniqueUsername('role');
|
|
await createApp(slug);
|
|
const userId = await createMemberUser(username);
|
|
cleanup.app(slug);
|
|
cleanup.adminUser(userId);
|
|
|
|
// Seed the membership via API to skip the invite UI.
|
|
const api = await adminApi();
|
|
try {
|
|
const res = await api.post(`/api/v1/admin/apps/${slug}/members`, {
|
|
data: { user_id: userId, role: 'viewer' }
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
} finally {
|
|
await api.dispose();
|
|
}
|
|
|
|
await page.goto(`/admin/apps/${slug}`);
|
|
await page.getByRole('link', { name: 'Members' }).click();
|
|
await page.getByRole('button', { name: new RegExp(`Member actions for ${username}`) }).click();
|
|
await page.getByRole('menuitem', { name: /^Make editor$/ }).click();
|
|
|
|
const row = page.locator('.member-row', { hasText: username });
|
|
await expect(row).toContainText(/editor/i);
|
|
});
|
|
|
|
test('non-app-admin viewers do not see the Members tab', async ({
|
|
browser,
|
|
uniqueSlug,
|
|
uniqueUsername
|
|
}) => {
|
|
const slug = uniqueSlug('mem');
|
|
const username = uniqueUsername('viewer');
|
|
const password = 'e2e-member-pw';
|
|
await createApp(slug);
|
|
const userId = await createMemberUser(username);
|
|
cleanup.app(slug);
|
|
cleanup.adminUser(userId);
|
|
|
|
// Grant viewer membership (not app_admin) so the user can see
|
|
// the app at all.
|
|
const api = await adminApi();
|
|
try {
|
|
const res = await api.post(`/api/v1/admin/apps/${slug}/members`, {
|
|
data: { user_id: userId, role: 'viewer' }
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
} finally {
|
|
await api.dispose();
|
|
}
|
|
|
|
const token = await loginAsUserToken(username, password);
|
|
const viewerPage = await pageWithUserToken(browser, token);
|
|
try {
|
|
await viewerPage.goto(`/admin/apps/${slug}`);
|
|
// Scripts tab loads — that's what a viewer sees.
|
|
await expect(
|
|
viewerPage.getByRole('link', { name: 'Scripts' })
|
|
).toBeVisible();
|
|
// Members tab button is absent for non-app-admins.
|
|
await expect(
|
|
viewerPage.getByRole('link', { name: 'Members' })
|
|
).toHaveCount(0);
|
|
} finally {
|
|
await viewerPage.context().close();
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('B5 app members adversarial', () => {
|
|
test('role dropdown exposes only the documented values', async ({
|
|
page,
|
|
uniqueSlug,
|
|
uniqueUsername
|
|
}) => {
|
|
const slug = uniqueSlug('mem');
|
|
const username = uniqueUsername('rolelist');
|
|
await createApp(slug);
|
|
const userId = await createMemberUser(username);
|
|
cleanup.app(slug);
|
|
cleanup.adminUser(userId);
|
|
|
|
await page.goto(`/admin/apps/${slug}`);
|
|
await page.getByRole('link', { name: 'Members' }).click();
|
|
const form = page.locator('form.create-form');
|
|
const roleSelect = form.locator('select').nth(1);
|
|
const optionValues = await roleSelect.evaluate((el: HTMLSelectElement) =>
|
|
Array.from(el.options).map((o) => o.value)
|
|
);
|
|
expect(optionValues.sort()).toEqual(['app_admin', 'editor', 'viewer']);
|
|
});
|
|
});
|