Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d147506
feat(web-admin): Add Projects column to Groups management page
cursoragent Feb 18, 2026
8ab307c
fix(web-admin): Pre-fetch project count on component mount for GroupP…
cursoragent Feb 18, 2026
65211d5
fix(web-admin): Fix project listing and add 'Add Project' feature for…
cursoragent Feb 18, 2026
37c47af
feat(web-admin): Move Add Project feature to group dialogs
cursoragent Feb 19, 2026
72a8e8a
refactor(web-admin): Reorder fields and simplify group dialogs
cursoragent Feb 19, 2026
13fa798
fix(web-admin): Show role in table dropdown, not form dropdown
cursoragent Feb 19, 2026
608d4a9
fix(web-admin): Fix lint error - remove unused variable
cursoragent Feb 20, 2026
11b0e2e
chore: trigger CI re-run
cursoragent Feb 23, 2026
fc0203a
fix: form reset and project URL in Groups page
cursoragent Feb 23, 2026
9704472
fix(canvas): Suppress "Invalid Date/Interval" when metrics view is un…
ericokuma Feb 19, 2026
bdf69a4
Revert "fix(canvas): Suppress "Invalid Date/Interval" when metrics vi…
ericokuma Feb 19, 2026
0f8d16a
feat(web-admin): add project and member management to Edit Group dialog
ericokuma Feb 24, 2026
dea1a98
fix(web-admin): match mock design and show all items on empty search …
ericokuma Feb 24, 2026
7ee383f
fix(web-admin): open search dropdowns downward and show members on focus
ericokuma Feb 24, 2026
c468596
fix(web-admin): pre-fetch org members so dropdown is instant on focus
ericokuma Feb 24, 2026
482218b
fix(web-admin): load org members once and filter client-side
ericokuma Feb 24, 2026
e51e778
fix(web-admin): replace bordered select with borderless role dropdown…
ericokuma Feb 24, 2026
6a40817
fix(web-admin): remove fixed width from role dropdown trigger in Edit…
ericokuma Feb 24, 2026
62c6822
fix(web-admin): make role dropdown full width in Create Group dialog
ericokuma Feb 24, 2026
311d4eb
fix(web-admin): restore bordered full-width style for Create Group ro…
ericokuma Feb 24, 2026
c48ee92
fix(web-admin): widen Edit Group role selector and match Create Group…
ericokuma Feb 24, 2026
386877d
fix(web-admin): use sameWidth on Create Group dropdowns to match fiel…
ericokuma Feb 24, 2026
0b9e443
fix(web-admin): adjust dropdown widths in Create and Edit Group dialogs
ericokuma Feb 24, 2026
a4a913f
fix(web-admin): fix member/project search bars becoming unclickable a…
ericokuma Feb 24, 2026
48911b9
fix(web-admin): close member/project search dropdowns after selection…
ericokuma Feb 24, 2026
312def3
refactor: improve GroupProjectsCell and project access handling
cursoragent Mar 3, 2026
5d87609
fix: use theme-aware CSS variables in EditUserGroupDialog
cursoragent Mar 28, 2026
e9b78f6
fix: resolve merge conflicts with main branch
cursoragent Mar 28, 2026
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
103 changes: 103 additions & 0 deletions scripts/seed-stress-test.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
-- Seed script for stress-testing the user group management UI.
-- Creates 25 fake users and 25 fake projects in the target org.
--
-- Seed:
-- psql postgres://postgres:postgres@localhost:5432/postgres \
-- -v org=rilldata -f scripts/seed-stress-test.sql
--
-- Cleanup:
-- psql postgres://postgres:postgres@localhost:5432/postgres \
-- -v org=rilldata -v cleanup=true -f scripts/seed-stress-test.sql

\if :{?org}
\else
\set org 'rilldata'
\endif

\if :{?cleanup}
SET app.seed_cleanup = :'cleanup';
\else
SET app.seed_cleanup = 'false';
\endif

SET app.seed_org = :'org';

DO $$
DECLARE
v_org_name TEXT := current_setting('app.seed_org');
v_cleanup TEXT := current_setting('app.seed_cleanup');
v_org_id UUID;
v_role_id UUID;
v_user_id UUID;
v_emails TEXT[] := ARRAY[
'alice.johnson@example.com', 'bob.chen@example.com', 'carol.martinez@example.com',
'david.lee@example.com', 'emma.wilson@example.com', 'frank.garcia@example.com',
'grace.kim@example.com', 'henry.patel@example.com', 'iris.rodriguez@example.com',
'jack.thompson@example.com', 'kate.brown@example.com', 'liam.davis@example.com',
'maya.anderson@example.com', 'noah.white@example.com', 'olivia.harris@example.com',
'paul.jackson@example.com', 'quinn.taylor@example.com', 'rachel.moore@example.com',
'sam.nguyen@example.com', 'tina.clark@example.com', 'uma.scott@example.com',
'victor.lewis@example.com', 'wendy.hall@example.com', 'xavier.young@example.com',
'yara.walker@example.com'
];
v_names TEXT[] := ARRAY[
'Alice Johnson', 'Bob Chen', 'Carol Martinez',
'David Lee', 'Emma Wilson', 'Frank Garcia',
'Grace Kim', 'Henry Patel', 'Iris Rodriguez',
'Jack Thompson', 'Kate Brown', 'Liam Davis',
'Maya Anderson', 'Noah White', 'Olivia Harris',
'Paul Jackson', 'Quinn Taylor', 'Rachel Moore',
'Sam Nguyen', 'Tina Clark', 'Uma Scott',
'Victor Lewis', 'Wendy Hall', 'Xavier Young',
'Yara Walker'
];
v_projects TEXT[] := ARRAY[
'analytics-dashboard', 'sales-pipeline', 'marketing-metrics',
'product-analytics', 'revenue-tracking', 'user-behavior',
'customer-insights', 'growth-metrics', 'inventory-analysis',
'financial-reporting', 'ops-dashboard', 'support-metrics',
'data-quality', 'campaign-performance', 'churn-analysis',
'acquisition-funnel', 'retention-metrics', 'cohort-analysis',
'ab-testing', 'ml-predictions', 'event-tracking',
'session-analytics', 'geo-distribution', 'pricing-analysis',
'engineering-metrics'
];
BEGIN
SELECT id INTO v_org_id FROM orgs WHERE lower(name) = lower(v_org_name);
IF v_org_id IS NULL THEN
RAISE EXCEPTION 'Org "%" not found', v_org_name;
END IF;

-- Cleanup mode: remove seeded data and exit
IF v_cleanup = 'true' THEN
DELETE FROM users WHERE email = ANY(v_emails);
DELETE FROM projects WHERE org_id = v_org_id AND name = ANY(v_projects);
RAISE NOTICE 'Cleaned up seeded users and projects from org "%"', v_org_name;
RETURN;
END IF;

SELECT id INTO v_role_id FROM org_roles WHERE lower(name) = 'viewer';

-- Create users and add them to the org as viewers
FOR i IN 1..array_length(v_emails, 1) LOOP
INSERT INTO users (email, display_name)
VALUES (v_emails[i], v_names[i])
ON CONFLICT (lower(email)) DO NOTHING;

SELECT id INTO v_user_id FROM users WHERE lower(email) = lower(v_emails[i]);

INSERT INTO users_orgs_roles (user_id, org_id, org_role_id)
VALUES (v_user_id, v_org_id, v_role_id)
ON CONFLICT (user_id, org_id) DO NOTHING;
END LOOP;

-- Create stub projects
FOR i IN 1..array_length(v_projects, 1) LOOP
INSERT INTO projects (org_id, name, description, public, region, prod_branch, prod_olap_driver, prod_olap_dsn, prod_slots)
VALUES (v_org_id, v_projects[i], '', false, '', 'main', 'duckdb', '', 2)
ON CONFLICT DO NOTHING;
END LOOP;

RAISE NOTICE 'Seeded % users and % projects into org "%"',
array_length(v_emails, 1), array_length(v_projects, 1), v_org_name;
END $$;
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
<script lang="ts">
import { page } from "$app/stores";
import type { V1OrganizationMemberUser } from "@rilldata/web-admin/client";
import type {
V1OrganizationMemberUser,
V1Project,
} from "@rilldata/web-admin/client";
import {
createAdminServiceAddProjectMemberUsergroup,
createAdminServiceAddUsergroupMemberUser,
createAdminServiceCreateUsergroup,
createAdminServiceListOrganizationMemberUsersInfinite,
createAdminServiceListProjectsForOrganization,
getAdminServiceListOrganizationMemberUsergroupsQueryKey,
getAdminServiceListOrganizationMemberUsersQueryKey,
getAdminServiceListProjectMemberUsergroupsQueryKey,
getAdminServiceListUsergroupMemberUsersQueryKey,
} from "@rilldata/web-admin/client";
import AvatarListItem from "@rilldata/web-common/components/avatar/AvatarListItem.svelte";
Expand All @@ -20,8 +26,13 @@
DialogTitle,
DialogTrigger,
} from "@rilldata/web-common/components/dialog";
import * as Dropdown from "@rilldata/web-common/components/dropdown-menu";
import Input from "@rilldata/web-common/components/forms/Input.svelte";
import CaretDownIcon from "@rilldata/web-common/components/icons/CaretDownIcon.svelte";
import CaretUpIcon from "@rilldata/web-common/components/icons/CaretUpIcon.svelte";
import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus.ts";
import { PROJECT_ROLES_OPTIONS } from "@rilldata/web-admin/features/projects/constants";
import { ProjectUserRoles } from "@rilldata/web-common/features/users/roles";
import { useQueryClient } from "@tanstack/svelte-query";
import { defaults, superForm } from "sveltekit-superforms";
import { yup } from "sveltekit-superforms/adapters";
Expand All @@ -39,6 +50,11 @@
let pendingAdditions: string[] = [];
let pendingRemovals: string[] = [];

let selectedProjects: string[] = [];
let projectDropdownOpen = false;
let selectedRole: ProjectUserRoles = ProjectUserRoles.Viewer;
let roleDropdownOpen = false;

// Debounce search input to avoid too many API calls.
// Use a standard Svelte reactive block: it re-runs whenever `searchInput` changes.
// We capture `searchInput` into a local constant to avoid race conditions in the timeout.
Expand All @@ -52,6 +68,40 @@

$: organization = $page.params.organization;

// Projects list
$: projectsQuery = createAdminServiceListProjectsForOrganization(
organization,
undefined,
{
query: {
enabled: open && !!organization,
},
},
);
$: projects = $projectsQuery?.data?.projects ?? ([] as V1Project[]);

$: selectedRoleLabel =
PROJECT_ROLES_OPTIONS.find((o) => o.value === selectedRole)?.label ??
"Viewer";

$: selectedProjectsLabel = (() => {
if (selectedProjects.length === 0) return "Select projects";
if (selectedProjects.length === 1) return selectedProjects[0];
return `${selectedProjects.length} Projects`;
})();

function toggleProjectSelection(projectName: string) {
const idx = selectedProjects.indexOf(projectName);
if (idx >= 0) {
selectedProjects = selectedProjects.filter(
(name) => name !== projectName,
);
} else {
selectedProjects = [...selectedProjects, projectName];
}
projectDropdownOpen = true;
}

// Infinite query for organization users (debounced by search)
$: organizationUsersInfiniteQuery =
createAdminServiceListOrganizationMemberUsersInfinite(
Expand Down Expand Up @@ -100,6 +150,8 @@
const queryClient = useQueryClient();
const createUserGroup = createAdminServiceCreateUsergroup();
const addUsergroupMemberUser = createAdminServiceAddUsergroupMemberUser();
const addProjectMemberUsergroup =
createAdminServiceAddProjectMemberUsergroup();

async function handleCreate(newName: string) {
try {
Expand All @@ -113,6 +165,9 @@
// Apply pending user changes after group creation
await applyPendingChanges(newName);

// Add group to selected projects
await applyProjectAccess(newName);

await queryClient.invalidateQueries({
queryKey: getAdminServiceListOrganizationMemberUsergroupsQueryKey(
organization,
Expand All @@ -126,6 +181,8 @@
selectedUsers = [];
pendingAdditions = [];
pendingRemovals = [];
selectedProjects = [];
selectedRole = ProjectUserRoles.Viewer;
open = false;

eventBus.emit("notification", { message: "User group created" });
Expand All @@ -137,6 +194,37 @@
}
}

// TODO: Handle partial updates - if group is created but only some projects
// get added, we should provide a way for users to retry or rollback.
async function applyProjectAccess(usergroup: string) {
if (selectedProjects.length === 0) return;

try {
for (const projectName of selectedProjects) {
await $addProjectMemberUsergroup.mutateAsync({
org: organization,
project: projectName,
usergroup: usergroup,
data: {
role: selectedRole,
},
});

await queryClient.invalidateQueries({
queryKey: getAdminServiceListProjectMemberUsergroupsQueryKey(
organization,
projectName,
),
});
}
} catch (error) {
eventBus.emit("notification", {
message: `Error adding group to projects: ${error.response?.data?.message || error.message}`,
type: "error",
});
}
}

async function applyPendingChanges(usergroup: string) {
try {
// Add pending users to the group
Expand Down Expand Up @@ -211,7 +299,7 @@
}),
);

const { form, enhance, submit, errors, submitting } = superForm(
const { form, enhance, submit, errors, submitting, reset } = superForm(
defaults(initialValues, schema),
{
SPA: true,
Expand Down Expand Up @@ -239,20 +327,17 @@
: undefined;
}

// Check if form has been modified
$: hasFormChanges = $form.name !== initialValues.name;

function handleClose() {
open = false;
searchInput = "";
selectedUsers = [];
pendingAdditions = [];
pendingRemovals = [];
$errors = {};
// Only reset the form if it has been modified
if (hasFormChanges) {
$form.name = initialValues.name;
}
selectedProjects = [];
selectedRole = ProjectUserRoles.Viewer;
projectDropdownOpen = false;
roleDropdownOpen = false;
reset();
}
</script>

Expand Down Expand Up @@ -283,6 +368,7 @@
use:enhance
>
<div class="flex flex-col gap-4 w-full">
<!-- Name -->
<Input
bind:value={$form.name}
id="create-user-group-name"
Expand All @@ -292,6 +378,94 @@
alwaysShowError={true}
/>

<!-- Project access multi-select -->
<div class="flex flex-col gap-y-1">
<label
for="project-access"
class="line-clamp-1 text-sm font-medium text-fg-primary"
>
Project access
</label>
{#if $projectsQuery?.isLoading}
<div class="text-sm text-fg-secondary">Loading projects...</div>
{:else if projects.length === 0}
<div class="text-sm text-fg-secondary">No projects available</div>
{:else}
<Dropdown.Root bind:open={projectDropdownOpen}>
<Dropdown.Trigger
class="min-h-[36px] flex flex-row justify-between gap-1 items-center rounded-sm border border-gray-300 bg-surface-background text-sm px-3 {projectDropdownOpen
? 'bg-gray-200'
: 'hover:bg-surface-hover'}"
>
<span class="truncate">
{selectedProjectsLabel}
</span>
{#if projectDropdownOpen}
<CaretUpIcon size="12px" />
{:else}
<CaretDownIcon size="12px" />
{/if}
</Dropdown.Trigger>
<Dropdown.Content
align="start"
sameWidth
class="max-h-60 overflow-y-auto"
>
{#each projects as p (p.id)}
<Dropdown.CheckboxItem
class="font-normal flex items-center overflow-hidden"
checked={selectedProjects.includes(p.name)}
onCheckedChange={() => toggleProjectSelection(p.name)}
>
<span class="truncate w-full" title={p.name}>{p.name}</span>
</Dropdown.CheckboxItem>
{/each}
</Dropdown.Content>
</Dropdown.Root>
{/if}
</div>

<!-- Access level selector -->
<div class="flex flex-col gap-y-1">
<label
for="access-level"
class="line-clamp-1 text-sm font-medium text-fg-primary"
>
Access level
</label>
<Dropdown.Root bind:open={roleDropdownOpen}>
<Dropdown.Trigger
class="min-h-[36px] flex flex-row justify-between gap-1 items-center rounded-sm border border-gray-300 bg-surface-background text-sm px-3 {roleDropdownOpen
? 'bg-gray-200'
: 'hover:bg-surface-hover'}"
>
<span>{selectedRoleLabel}</span>
{#if roleDropdownOpen}
<CaretUpIcon size="12px" />
{:else}
<CaretDownIcon size="12px" />
{/if}
</Dropdown.Trigger>
<Dropdown.Content align="start" sameWidth>
{#each PROJECT_ROLES_OPTIONS as option}
<Dropdown.Item
class="font-normal flex flex-col items-start py-2 {selectedRole ===
option.value
? 'bg-surface-active'
: ''}"
onclick={() => (selectedRole = option.value)}
>
<span class="font-medium">{option.label}</span>
<span class="text-xs text-fg-secondary"
>{option.description}</span
>
</Dropdown.Item>
{/each}
</Dropdown.Content>
</Dropdown.Root>
</div>

<!-- Users -->
<div class="flex flex-col gap-y-1">
<label
for="user-group-users"
Expand Down
Loading
Loading