diff --git a/scripts/seed-stress-test.sql b/scripts/seed-stress-test.sql
new file mode 100644
index 000000000000..7ab87c8e14a9
--- /dev/null
+++ b/scripts/seed-stress-test.sql
@@ -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 $$;
diff --git a/web-admin/src/features/organizations/user-management/dialogs/CreateUserGroupDialog.svelte b/web-admin/src/features/organizations/user-management/dialogs/CreateUserGroupDialog.svelte
index dcc4ca261329..4e46acdc3cdb 100644
--- a/web-admin/src/features/organizations/user-management/dialogs/CreateUserGroupDialog.svelte
+++ b/web-admin/src/features/organizations/user-management/dialogs/CreateUserGroupDialog.svelte
@@ -1,12 +1,18 @@
@@ -283,6 +368,7 @@
use:enhance
>
+
+
+
+
+ {#if $projectsQuery?.isLoading}
+
Loading projects...
+ {:else if projects.length === 0}
+
No projects available
+ {:else}
+
+
+
+ {selectedProjectsLabel}
+
+ {#if projectDropdownOpen}
+
+ {:else}
+
+ {/if}
+
+
+ {#each projects as p (p.id)}
+ toggleProjectSelection(p.name)}
+ >
+ {p.name}
+
+ {/each}
+
+
+ {/if}
+
+
+
+
+
+
+
+ {selectedRoleLabel}
+ {#if roleDropdownOpen}
+
+ {:else}
+
+ {/if}
+
+
+ {#each PROJECT_ROLES_OPTIONS as option}
+ (selectedRole = option.value)}
+ >
+ {option.label}
+ {option.description}
+
+ {/each}
+
+
+
+
+
-
-
-
- {#if selectedUsers.length > 0}
-
-
- {selectedUsers.length} User{selectedUsers.length === 1 ? "" : "s"}
+
+
+
Projects
+
+ {#if projectsLoading}
+
+ Loading projects…
+
+ {:else}
+ {#if selectedProjects.length > 0}
+
+ {#each selectedProjects as project (project.name)}
+
+ {project.name}
+
+
+ {capitalize(project.role)}
+ {#if projectRoleDropdownOpen[project.name]}
+
+ {:else}
+
+ {/if}
+
+
+ {#each PROJECT_ROLES_OPTIONS as opt (opt.value)}
+
+ handleProjectRoleChange(
+ project.name,
+ opt.value,
+ )}
+ >
+ {opt.label}
+ {opt.description}
+
+ {/each}
+
+
+
+
+ {/each}
+
+ {/if}
+
+
+
0}
+ class:rounded-md={selectedProjects.length === 0}
+ >
+ {#if projectSearchFocused}
+
+ {#if filteredProjectOptions.length > 0}
+ {#each filteredProjectOptions as name (name)}
+
+ {/each}
+ {:else}
+
+ No more projects found
+
+ {/if}
+
+ {/if}
+
(projectSearchFocused = true)}
+ onblur={() =>
+ setTimeout(() => (projectSearchFocused = false), 150)}
+ placeholder="Search projects…"
+ class="w-full bg-transparent px-3 py-2 text-sm focus:outline-none placeholder:text-fg-muted"
+ />
+
+ {/if}
- {/if}
-
-
- {#each selectedUsers as user (user.userEmail)}
-
-
+
+
Members
+
+ {#if selectedUsers.length > 0}
+
+ {#each selectedUsers as user (user.userEmail)}
+
+
+
+
+ {/each}
+
+ {/if}
+
+
+
0}
+ class:rounded-md={selectedUsers.length === 0}
+ >
+ {#if memberSearchFocused}
+
+ {#if $organizationUsersQuery.isLoading}
+
Loading…
+ {:else if filteredMemberOptions.length > 0}
+ {#each filteredMemberOptions as user (user.userEmail)}
+
+ {/each}
+ {:else}
+
+ No more members found
+
+ {/if}
+
+ {/if}
+
(memberSearchFocused = true)}
+ onblur={() =>
+ setTimeout(() => (memberSearchFocused = false), 150)}
+ placeholder="Search members…"
+ class="w-full bg-transparent px-3 py-2 text-sm focus:outline-none placeholder:text-fg-muted"
/>
-
- {/each}
+
-
+
diff --git a/web-admin/src/features/organizations/user-management/table/groups/GroupProjectsCell.svelte b/web-admin/src/features/organizations/user-management/table/groups/GroupProjectsCell.svelte
new file mode 100644
index 000000000000..51474bc55505
--- /dev/null
+++ b/web-admin/src/features/organizations/user-management/table/groups/GroupProjectsCell.svelte
@@ -0,0 +1,133 @@
+
+
+
+
+
+ {#if hasProcessed}
+ {projectCount} Project{projectCount !== 1 ? "s" : ""}
+ {:else}
+ Projects
+ {/if}
+
+ {#if isDropdownOpen}
+
+ {:else}
+
+ {/if}
+
+
+ {#if isPending}
+ Loading...
+ {:else if !hasProjects}
+ No projects
+ {:else}
+ {#each accessibleProjects as project (project.id)}
+
+ {project.name}
+ {formatRoleName(project.roleName)}
+
+ {/each}
+ {/if}
+
+
diff --git a/web-admin/src/features/organizations/user-management/table/groups/OrgGroupsTable.svelte b/web-admin/src/features/organizations/user-management/table/groups/OrgGroupsTable.svelte
index 6f27f9486fae..dad2e6d33e4c 100644
--- a/web-admin/src/features/organizations/user-management/table/groups/OrgGroupsTable.svelte
+++ b/web-admin/src/features/organizations/user-management/table/groups/OrgGroupsTable.svelte
@@ -4,8 +4,10 @@
import type { ColumnDef } from "tanstack-table-8-svelte-5";
import GroupActionsCell from "@rilldata/web-admin/features/organizations/user-management/table/groups/GroupActionsCell.svelte";
import GroupCompositeCell from "@rilldata/web-admin/features/organizations/user-management/table/groups/GroupCompositeCell.svelte";
+ import GroupProjectsCell from "@rilldata/web-admin/features/organizations/user-management/table/groups/GroupProjectsCell.svelte";
import InfiniteScrollTable from "@rilldata/web-common/components/table/InfiniteScrollTable.svelte";
+ export let organization: string;
export let data: V1MemberUsergroup[];
export let currentUserEmail: string;
export let hasNextPage: boolean;
@@ -34,23 +36,23 @@
usersCount: row.original.usersCount,
}),
meta: {
- widthPercent: 95,
+ widthPercent: 55,
+ },
+ },
+ {
+ accessorKey: "projects",
+ header: "Projects",
+ enableSorting: false,
+ cell: ({ row }) =>
+ renderComponent(GroupProjectsCell, {
+ organization,
+ groupName: row.original.groupName ?? "",
+ }),
+ meta: {
+ widthPercent: 40,
+ marginLeft: "8px",
},
},
- // {
- // accessorKey: "roleName",
- // header: "Role",
- // cell: ({ row }) =>
- // flexRender(OrgGroupsTableRoleCell, {
- // name: row.original.groupName,
- // role: row.original.roleName,
- // manageOrgAdmins: manageOrgAdmins,
- // }),
- // meta: {
- // widthPercent: 20,
- // marginLeft: "8px",
- // },
- // },
{
accessorKey: "actions",
header: "",
diff --git a/web-admin/src/routes/[organization]/-/users/groups/+page.svelte b/web-admin/src/routes/[organization]/-/users/groups/+page.svelte
index cee408d4876e..ffb8d6a1bfad 100644
--- a/web-admin/src/routes/[organization]/-/users/groups/+page.svelte
+++ b/web-admin/src/routes/[organization]/-/users/groups/+page.svelte
@@ -94,6 +94,7 @@