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}?
+
+
+
+
+ {
+ open = false;
+ }}>Cancel
+ Yes, upgrade
+
+
+
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}
-
+
+
Add users
-
+
+
+
+ {#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;
}