diff --git a/web-admin/src/features/organizations/users/AddUsersDialog.svelte b/web-admin/src/features/organizations/users/AddUsersDialog.svelte index 966db5e7c5d..13576e5a897 100644 --- a/web-admin/src/features/organizations/users/AddUsersDialog.svelte +++ b/web-admin/src/features/organizations/users/AddUsersDialog.svelte @@ -40,32 +40,27 @@ newRole: string, isSuperUser: boolean = false, ) { - try { - await $addOrganizationMemberUser.mutateAsync({ - organization: organization, - data: { - email: newEmail, - role: newRole, - superuserForceAccess: isSuperUser, - }, - }); - - await queryClient.invalidateQueries({ - queryKey: - getAdminServiceListOrganizationMemberUsersQueryKey(organization), - }); - - await queryClient.invalidateQueries({ - queryKey: getAdminServiceListOrganizationInvitesQueryKey(organization), - }); + await $addOrganizationMemberUser.mutateAsync({ + organization: organization, + data: { + email: newEmail, + role: newRole, + superuserForceAccess: isSuperUser, + }, + }); - email = ""; - role = ""; - isSuperUser = false; - } catch (error) { - console.error("Error adding user to organization", error); - throw error; - } + await queryClient.invalidateQueries({ + queryKey: + getAdminServiceListOrganizationMemberUsersQueryKey(organization), + }); + + await queryClient.invalidateQueries({ + queryKey: getAdminServiceListOrganizationInvitesQueryKey(organization), + }); + + email = ""; + role = ""; + isSuperUser = false; } const formId = "add-user-form"; @@ -139,12 +134,6 @@ // Show error notification if any invites failed if (failed.length > 0) { failedInvites = failed; // Store failed emails - eventBus.emit("notification", { - type: "error", - message: `Failed to invite ${failed.length} ${ - failed.length === 1 ? "person" : "people" - }`, - }); } // Close dialog after showing notifications @@ -220,7 +209,9 @@ {#if failedInvites.length > 0}
- Failed to invite {failedInvites.join(", ")} + {failedInvites.length === 1 + ? `${failedInvites[0]} is already a member of this organization` + : `${failedInvites.join(", ")} are already members of this organization`}
{/if} diff --git a/web-admin/src/features/organizations/users/AvatarListItem.svelte b/web-admin/src/features/organizations/users/AvatarListItem.svelte index e6edea809f9..7dff94bf201 100644 --- a/web-admin/src/features/organizations/users/AvatarListItem.svelte +++ b/web-admin/src/features/organizations/users/AvatarListItem.svelte @@ -1,5 +1,6 @@ + + + + + + + + Upgrade guest to {newRole}? + +
+ Upgrading a guest to {newRole} will grant this user access to all open + projects in the organization. Would you like to upgrade this guest user + to {newRole}? +
+
+
+ + + + +
+
diff --git a/web-admin/src/features/organizations/users/OrgUsersFilters.svelte b/web-admin/src/features/organizations/users/OrgUsersFilters.svelte new file mode 100644 index 00000000000..5186277ac69 --- /dev/null +++ b/web-admin/src/features/organizations/users/OrgUsersFilters.svelte @@ -0,0 +1,64 @@ + + + + + {filterSelection === "all" ? "All users" : filterSelection} + {#if isDropdownOpen} + + {:else} + + {/if} + + + { + filterSelection = "all"; + }} + > + All users + + { + filterSelection = "members"; + }} + > + Members + + { + filterSelection = "guests"; + }} + > + Guests + + { + filterSelection = "pending"; + }} + > + Pending invites + + + diff --git a/web-admin/src/features/organizations/users/OrgUsersTable.svelte b/web-admin/src/features/organizations/users/OrgUsersTable.svelte index dfeba0a7a30..18c4aae7ddf 100644 --- a/web-admin/src/features/organizations/users/OrgUsersTable.svelte +++ b/web-admin/src/features/organizations/users/OrgUsersTable.svelte @@ -28,6 +28,7 @@ InfiniteData, InfiniteQueryObserverResult, } from "@tanstack/svelte-query"; + import { ExternalLinkIcon } from "lucide-svelte"; interface OrgUser extends V1OrganizationMemberUser, V1UserInvite { invitedBy?: string; @@ -43,6 +44,7 @@ RpcStatus >; export let currentUserEmail: string; + export let currentUserRole: string; const ROW_HEIGHT = 69; const OVERSCAN = 5; @@ -72,6 +74,7 @@ pendingAcceptance: Boolean(row.original.invitedBy), isCurrentUser: row.original.userEmail === currentUserEmail, photoUrl: row.original.userPhotoUrl, + role: row.original.roleName, }), meta: { widthPercent: 5, @@ -79,12 +82,13 @@ }, { accessorKey: "roleName", - header: "Role", + header: "Organization Role", cell: ({ row }) => flexRender(OrgUsersTableRoleCell, { email: row.original.userEmail, role: row.original.roleName, isCurrentUser: row.original.userEmail === currentUserEmail, + currentUserRole: currentUserRole, }), meta: { widthPercent: 5, @@ -98,7 +102,9 @@ cell: ({ row }) => flexRender(OrgUsersTableActionsCell, { email: row.original.userEmail, + role: row.original.roleName, isCurrentUser: row.original.userEmail === currentUserEmail, + currentUserRole: currentUserRole, }), meta: { widthPercent: 0, @@ -205,6 +211,20 @@ header.getContext(), )} /> + {#if header.column.id === "roleName"} + + + + {/if} {#if header.column.getIsSorted().toString() === "asc"} diff --git a/web-admin/src/features/organizations/users/OrgUsersTableActionsCell.svelte b/web-admin/src/features/organizations/users/OrgUsersTableActionsCell.svelte index b94cfe06927..7f79f9c3135 100644 --- a/web-admin/src/features/organizations/users/OrgUsersTableActionsCell.svelte +++ b/web-admin/src/features/organizations/users/OrgUsersTableActionsCell.svelte @@ -15,12 +15,21 @@ import { page } from "$app/stores"; export let email: string; + export let role: string; export let isCurrentUser: boolean; + export let currentUserRole: string; let isDropdownOpen = false; let isRemoveConfirmOpen = false; $: organization = $page.params.organization; + $: isAdmin = currentUserRole === "admin"; + $: isEditor = currentUserRole === "editor"; + $: canManageUser = + !isCurrentUser && + (isAdmin || + (isEditor && + (role === "editor" || role === "viewer" || role === "guest"))); const queryClient = useQueryClient(); const removeOrganizationMemberUser = @@ -81,7 +90,7 @@ } -{#if !isCurrentUser} +{#if canManageUser} diff --git a/web-admin/src/features/organizations/users/OrgUsersTableRoleCell.svelte b/web-admin/src/features/organizations/users/OrgUsersTableRoleCell.svelte index 0cf7dfbee9b..0fffb08f591 100644 --- a/web-admin/src/features/organizations/users/OrgUsersTableRoleCell.svelte +++ b/web-admin/src/features/organizations/users/OrgUsersTableRoleCell.svelte @@ -10,14 +10,33 @@ import { page } from "$app/stores"; import { useQueryClient } from "@tanstack/svelte-query"; import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus"; + import OrgUpgradeGuestConfirmDialog from "./OrgUpgradeGuestConfirmDialog.svelte"; export let email: string; export let role: string; export let isCurrentUser: boolean; + export let currentUserRole: string; let isDropdownOpen = false; + let isUpgradeConfirmOpen = false; + let newRole = ""; $: organization = $page.params.organization; + $: isAdmin = currentUserRole === "admin"; + $: isEditor = currentUserRole === "editor"; + $: isGuest = role === "guest"; + $: canManageUser = + !isCurrentUser && + (isAdmin || + (isEditor && + (role === "editor" || role === "viewer" || role === "guest"))); + + const OPTION_DESCRIPTION_MAP = { + admin: "Full access to org settings, members, and all projects", + editor: "Can create/manage projects and non-admin members", + viewer: "Read-only access to all org projects", + guest: "Access to invited projects only", + }; const queryClient = useQueryClient(); const setOrganizationMemberUserRole = @@ -25,6 +44,12 @@ async function handleSetRole(role: string) { try { + if (isGuest) { + newRole = role; + isUpgradeConfirmOpen = true; + return; + } + await $setOrganizationMemberUserRole.mutateAsync({ organization: organization, email: email, @@ -53,63 +78,125 @@ }); } } + + async function handleUpgrade(email: string, role: string) { + try { + await $setOrganizationMemberUserRole.mutateAsync({ + organization: organization, + email: email, + data: { + role: role, + }, + }); + + await queryClient.invalidateQueries({ + queryKey: + getAdminServiceListOrganizationMemberUsersQueryKey(organization), + }); + + await queryClient.invalidateQueries({ + queryKey: getAdminServiceListOrganizationInvitesQueryKey(organization), + }); + + eventBus.emit("notification", { + message: `Guest upgraded to ${role}`, + }); + } catch (error) { + console.error("Error upgrading user role", error); + eventBus.emit("notification", { + message: "Error upgrading user role", + type: "error", + }); + } + } -{#if !isCurrentUser} +{#if canManageUser} - {role ? `Org ${role}` : "-"} + {role ? `${role}` : "-"} {#if isDropdownOpen} {:else} {/if} - - { - handleSetRole("admin"); - }} - > - Admin - - + {#if isAdmin} + { + handleSetRole("admin"); + }} + > + Admin + {OPTION_DESCRIPTION_MAP.admin} + + {/if} + { handleSetRole("editor"); }} > - Editor - - Editor + {OPTION_DESCRIPTION_MAP.editor} + + { handleSetRole("viewer"); }} > - Viewer - - { - handleSetRole("guest"); - }} - > - Guest - + Viewer + {OPTION_DESCRIPTION_MAP.viewer} + + {#if isAdmin} + { + handleSetRole("guest"); + }} + > + Guest + {OPTION_DESCRIPTION_MAP.guest} + + {/if} {:else}
- Org {role} + {role}
{/if} + + diff --git a/web-admin/src/features/organizations/users/OrgUsersTableUserCompositeCell.svelte b/web-admin/src/features/organizations/users/OrgUsersTableUserCompositeCell.svelte index d10493de329..67b7f8e25aa 100644 --- a/web-admin/src/features/organizations/users/OrgUsersTableUserCompositeCell.svelte +++ b/web-admin/src/features/organizations/users/OrgUsersTableUserCompositeCell.svelte @@ -6,6 +6,14 @@ export let isCurrentUser: boolean; export let pendingAcceptance: boolean; export let photoUrl: string | null; + export let role: string; - + diff --git a/web-admin/src/features/projects/user-management/UserInviteForm.svelte b/web-admin/src/features/projects/user-management/UserInviteForm.svelte index a05bab848c2..29b868c0736 100644 --- a/web-admin/src/features/projects/user-management/UserInviteForm.svelte +++ b/web-admin/src/features/projects/user-management/UserInviteForm.svelte @@ -1,6 +1,7 @@ - +
diff --git a/web-admin/src/routes/[organization]/-/users/+page.svelte b/web-admin/src/routes/[organization]/-/users/+page.svelte index 2a5b1d1cfc6..c0bf6aeedb8 100644 --- a/web-admin/src/routes/[organization]/-/users/+page.svelte +++ b/web-admin/src/routes/[organization]/-/users/+page.svelte @@ -9,6 +9,7 @@ import AddUsersDialog from "@rilldata/web-admin/features/organizations/users/AddUsersDialog.svelte"; import OrgUsersTable from "@rilldata/web-admin/features/organizations/users/OrgUsersTable.svelte"; import Button from "@rilldata/web-common/components/button/Button.svelte"; + import OrgUsersFilters from "@rilldata/web-admin/features/organizations/users/OrgUsersFilters.svelte"; import { Search } from "@rilldata/web-common/components/search"; import DelayedSpinner from "@rilldata/web-common/features/entity-management/DelayedSpinner.svelte"; import { Plus } from "lucide-svelte"; @@ -20,6 +21,7 @@ let isSuperUser = false; let isAddUserDialogOpen = false; let searchText = ""; + let filterSelection: "all" | "members" | "guests" | "pending" = "all"; $: organization = $page.params.organization; @@ -80,16 +82,48 @@ ...coerceInvitesToUsers(allOrgInvitesRows), ]; - // Search by email or name - // Member users have a userName field, invites do not - $: filteredUsers = combinedRows.filter((user) => { - const searchLower = searchText.toLowerCase(); - return ( - (user.userEmail?.toLowerCase() || "").includes(searchLower) || - ("userName" in user && - (user.userName?.toLowerCase() || "").includes(searchLower)) - ); - }); + $: currentUserRole = allOrgMemberUsersRows.find( + (member) => member.userEmail === $currentUser.data?.user.email, + )?.roleName; + + // Filter by role + // Filter by search text + $: filteredUsers = combinedRows + .filter((user) => { + const searchLower = searchText.toLowerCase(); + const matchesSearch = + (user.userEmail?.toLowerCase() || "").includes(searchLower) || + ("userName" in user && + (user.userName?.toLowerCase() || "").includes(searchLower)); + + let matchesRole = false; + + if (filterSelection === "all") { + // All org users (members + guests) + matchesRole = !("invitedBy" in user); + } else if (filterSelection === "members") { + // Only members (org admin, editor, viewer) + matchesRole = + !("invitedBy" in user) && + (user.roleName === "admin" || + user.roleName === "editor" || + user.roleName === "viewer"); + } else if (filterSelection === "guests") { + // Only guests + matchesRole = user.roleName === "guest"; + } else if (filterSelection === "pending") { + // Only users with pending invites + matchesRole = "invitedBy" in user; + } + + return matchesSearch && matchesRole; + }) + .sort((a, b) => { + // Sort by current user first + if (a.userEmail === $currentUser.data?.user.email) return -1; + if (b.userEmail === $currentUser.data?.user.email) return 1; + return 0; + }); const currentUser = createAdminServiceGetCurrentUser(); @@ -107,7 +141,7 @@ $orgInvitesInfiniteQuery.error}
{:else if $orgMemberUsersInfiniteQuery.isSuccess && $orgInvitesInfiniteQuery.isSuccess} -
+
+
- +
+ +
+ {#if filteredUsers.length > 0} +
+ + {filteredUsers.length} total user{filteredUsers.length === 1 + ? "" + : "s"} + +
+ {/if}
{/if}
diff --git a/web-common/src/components/chip/core/Chip.svelte b/web-common/src/components/chip/core/Chip.svelte index 1748ca21375..7a29a3a6e82 100644 --- a/web-common/src/components/chip/core/Chip.svelte +++ b/web-common/src/components/chip/core/Chip.svelte @@ -11,7 +11,8 @@ export let removable = false; export let active = false; export let readOnly = false; - export let type: "measure" | "dimension" | "time" | "special" = "dimension"; + export let type: "measure" | "dimension" | "time" | "special" | "amber" = + "dimension"; export let exclude = false; export let grab = false; export let compact = false; @@ -168,6 +169,18 @@ @apply border-slate-400; } + .amber { + @apply rounded-2xl h-[18px] text-xs; + @apply bg-amber-50 border-amber-300 text-amber-600; + @apply font-normal; + } + + .amber:hover, + .amber:active, + .amber.active { + @apply bg-amber-100; + } + .compact { @apply py-0; }