diff --git a/web/apps/client-demo/src/Router.tsx b/web/apps/client-demo/src/Router.tsx
index c86ef892e..02a4fde51 100644
--- a/web/apps/client-demo/src/Router.tsx
+++ b/web/apps/client-demo/src/Router.tsx
@@ -12,6 +12,7 @@ import General from './pages/settings/General';
import Preferences from './pages/settings/Preferences';
import Profile from './pages/settings/Profile';
import Sessions from './pages/settings/Sessions';
+import Members from './pages/settings/Members';
function Router() {
return (
@@ -30,6 +31,7 @@ function Router() {
} />
} />
} />
+ } />
} />
diff --git a/web/apps/client-demo/src/pages/Settings.tsx b/web/apps/client-demo/src/pages/Settings.tsx
index 3a95a4d15..911422b39 100644
--- a/web/apps/client-demo/src/pages/Settings.tsx
+++ b/web/apps/client-demo/src/pages/Settings.tsx
@@ -1,13 +1,14 @@
import { useEffect } from 'react';
import { Flex, Sidebar, Text } from '@raystack/apsara';
-import { Outlet, useParams, useLocation, Navigate } from 'react-router-dom';
+import { Outlet, useParams, useLocation, Navigate, Link } from 'react-router-dom';
import { useFrontier } from '@raystack/frontier/react';
const NAV_ITEMS = [
{ label: 'General', path: 'general' },
{ label: 'Preferences', path: 'preferences' },
{ label: 'Profile', path: 'profile' },
- { label: 'Sessions', path: 'sessions' }
+ { label: 'Sessions', path: 'sessions' },
+ { label: 'Members', path: 'members' }
];
export default function Settings() {
@@ -49,7 +50,7 @@ export default function Settings() {
return (
}
active={isActive}
data-test-id={`[settings-nav-${item.path}]`}
>
diff --git a/web/apps/client-demo/src/pages/settings/Members.tsx b/web/apps/client-demo/src/pages/settings/Members.tsx
new file mode 100644
index 000000000..1fe4cfe98
--- /dev/null
+++ b/web/apps/client-demo/src/pages/settings/Members.tsx
@@ -0,0 +1,5 @@
+import { MembersView } from '@raystack/frontier/react';
+
+export default function Members() {
+ return ;
+}
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 1f12e10b2..d8cc7c634 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -212,9 +212,6 @@ importers:
sdk:
dependencies:
- '@base-ui/react':
- specifier: 1.2.0
- version: 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@bufbuild/protobuf':
specifier: ^2.11.0
version: 2.11.0
@@ -231,8 +228,8 @@ importers:
specifier: ^3.10.0
version: 3.10.0(react-hook-form@7.71.2(react@19.2.4))
'@raystack/apsara-v1':
- specifier: npm:@raystack/apsara@1.0.0-rc.1
- version: '@raystack/apsara@1.0.0-rc.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)'
+ specifier: npm:@raystack/apsara@1.0.0-rc.2
+ version: '@raystack/apsara@1.0.0-rc.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)'
'@raystack/proton':
specifier: 0.1.0-fcb776fb2962a9a0378ea4216177b7c2686efc15
version: 0.1.0-fcb776fb2962a9a0378ea4216177b7c2686efc15(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -614,8 +611,8 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
- '@base-ui/react@1.2.0':
- resolution: {integrity: sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw==}
+ '@base-ui/react@1.3.0':
+ resolution: {integrity: sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@types/react': ^17 || ^18 || ^19
@@ -625,16 +622,6 @@ packages:
'@types/react':
optional: true
- '@base-ui/utils@0.2.5':
- resolution: {integrity: sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw==}
- peerDependencies:
- '@types/react': ^17 || ^18 || ^19
- react: ^17 || ^18 || ^19
- react-dom: ^17 || ^18 || ^19
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
'@base-ui/utils@0.2.6':
resolution: {integrity: sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw==}
peerDependencies:
@@ -2275,8 +2262,8 @@ packages:
'@types/react':
optional: true
- '@raystack/apsara@1.0.0-rc.1':
- resolution: {integrity: sha512-SHttsstqu1xWRaNlTpPU17IkJB8/vCQejpHhG2nVViC5zyJINyzlD0QeUweHyARAVHcDrtI/g0lM66REFogSwA==}
+ '@raystack/apsara@1.0.0-rc.2':
+ resolution: {integrity: sha512-uhnN4PX7xSfL/huupfXpe2tJjykON2rPECdtKGFPE8JWGTFNMou9dJyXrj0NOYu0guRmQchBp7+uLjoKUojxoA==}
engines: {node: '>=22'}
peerDependencies:
'@types/react': ^19
@@ -8079,10 +8066,10 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
- '@base-ui/react@1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@base-ui/react@1.3.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
- '@base-ui/utils': 0.2.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@base-ui/utils': 0.2.6(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.11
react: 19.2.4
@@ -8092,17 +8079,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
- '@base-ui/utils@0.2.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
- dependencies:
- '@babel/runtime': 7.28.6
- '@floating-ui/utils': 0.2.11
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- reselect: 5.1.1
- use-sync-external-store: 1.6.0(react@19.2.4)
- optionalDependencies:
- '@types/react': 19.2.14
-
'@base-ui/utils@0.2.6(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
@@ -9821,9 +9797,9 @@ snapshots:
transitivePeerDependencies:
- '@types/react-dom'
- '@raystack/apsara@1.0.0-rc.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@raystack/apsara@1.0.0-rc.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
- '@base-ui/react': 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@base-ui/react': 1.3.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@base-ui/utils': 0.2.6(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-icons': 1.3.2(react@19.2.4)
'@tanstack/match-sorter-utils': 8.19.4
diff --git a/web/sdk/package.json b/web/sdk/package.json
index 301c3097f..faf06d5ba 100644
--- a/web/sdk/package.json
+++ b/web/sdk/package.json
@@ -96,19 +96,16 @@
"zod": "^3.22.3"
},
"dependencies": {
- "@base-ui/react": "1.2.0",
"@bufbuild/protobuf": "^2.11.0",
"@connectrpc/connect": "2.1.1",
"@connectrpc/connect-query": "2.1.1",
"@connectrpc/connect-web": "2.1.1",
"@hookform/resolvers": "^3.10.0",
- "@raystack/apsara-v1": "npm:@raystack/apsara@1.0.0-rc.1",
"@raystack/proton": "0.1.0-fcb776fb2962a9a0378ea4216177b7c2686efc15",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-router": "^1.168.3",
"axios": "^1.9.0",
- "@raystack/apsara-v1": "npm:@raystack/apsara@1.0.0-rc.1",
- "@base-ui/react": "1.2.0",
+ "@raystack/apsara-v1": "npm:@raystack/apsara@1.0.0-rc.2",
"class-variance-authority": "^0.7.1",
"dayjs": "^1.11.13",
"lodash": "^4.17.21",
diff --git a/web/sdk/react/index.ts b/web/sdk/react/index.ts
index fd037df9c..9c6c948e4 100644
--- a/web/sdk/react/index.ts
+++ b/web/sdk/react/index.ts
@@ -34,6 +34,7 @@ export { GeneralView } from './views-new/general';
export { PreferencesView, PreferenceRow } from './views-new/preferences';
export { ProfileView } from './views-new/profile';
export { SessionsView } from './views-new/sessions';
+export { MembersView } from './views-new/members';
export type {
FrontierClientOptions,
diff --git a/web/sdk/react/views-new/members/components/invite-member-dialog.tsx b/web/sdk/react/views-new/members/components/invite-member-dialog.tsx
new file mode 100644
index 000000000..5462e1203
--- /dev/null
+++ b/web/sdk/react/views-new/members/components/invite-member-dialog.tsx
@@ -0,0 +1,284 @@
+'use client';
+
+import { useCallback, useMemo } from 'react';
+import { yupResolver } from '@hookform/resolvers/yup';
+import { Controller, useForm } from 'react-hook-form';
+import * as yup from 'yup';
+import { create } from '@bufbuild/protobuf';
+import { useMutation, useQuery } from '@connectrpc/connect-query';
+import {
+ FrontierServiceQueries,
+ CreateOrganizationInvitationRequestSchema,
+ ListOrganizationRolesRequestSchema,
+ ListRolesRequestSchema,
+ ListOrganizationGroupsRequestSchema
+} from '@raystack/proton/frontier';
+import {
+ Button,
+ Skeleton,
+ Text,
+ Label,
+ Select,
+ Flex,
+ Dialog,
+ TextArea,
+ toastManager
+} from '@raystack/apsara-v1';
+import { useFrontier } from '../../../contexts/FrontierContext';
+import { PERMISSIONS } from '../../../../utils';
+
+const inviteSchema = yup.object({
+ type: yup.string().required(),
+ team: yup.string(),
+ emails: yup.string().required()
+});
+
+type InviteSchemaType = yup.InferType;
+
+export interface InviteMemberDialogProps {
+ handle: ReturnType;
+ showTeamField?: boolean;
+ refetch: () => void;
+}
+
+export function InviteMemberDialog({ handle, showTeamField = true, refetch }: InviteMemberDialogProps) {
+ const {
+ watch,
+ register,
+ control,
+ handleSubmit,
+ reset,
+ formState: { errors, isSubmitting }
+ } = useForm({
+ resolver: yupResolver(inviteSchema)
+ });
+ const { activeOrganization: organization } = useFrontier();
+
+ const handleOpenChange = (open: boolean) => {
+ if (!open) {
+ reset();
+ refetch();
+ }
+ };
+
+ const { data: orgRolesData, isLoading: isOrgRolesLoading } = useQuery(
+ FrontierServiceQueries.listOrganizationRoles,
+ create(ListOrganizationRolesRequestSchema, {
+ orgId: organization?.id || '',
+ scopes: [PERMISSIONS.OrganizationNamespace]
+ }),
+ { enabled: !!organization?.id }
+ );
+
+ const orgRoles = useMemo(() => orgRolesData?.roles || [], [orgRolesData]);
+
+ const { data: globalRolesData, isLoading: isGlobalRolesLoading } = useQuery(
+ FrontierServiceQueries.listRoles,
+ create(ListRolesRequestSchema, {
+ scopes: [PERMISSIONS.OrganizationNamespace]
+ }),
+ { enabled: !!organization?.id }
+ );
+
+ const globalRoles = useMemo(
+ () => globalRolesData?.roles || [],
+ [globalRolesData]
+ );
+
+ const { data: teamsData, isLoading: isGroupsLoading } = useQuery(
+ FrontierServiceQueries.listOrganizationGroups,
+ create(ListOrganizationGroupsRequestSchema, {
+ orgId: organization?.id || ''
+ }),
+ { enabled: !!organization?.id && showTeamField }
+ );
+
+ const teams = useMemo(() => teamsData?.groups || [], [teamsData]);
+
+ const isLoading =
+ isOrgRolesLoading || isGlobalRolesLoading || (showTeamField && isGroupsLoading);
+
+ const roles = useMemo(
+ () => [...(globalRoles || []), ...(orgRoles || [])],
+ [globalRoles, orgRoles]
+ );
+
+ const { mutateAsync: createInvitation } = useMutation(
+ FrontierServiceQueries.createOrganizationInvitation,
+ {
+ onSuccess: () => {
+ toastManager.add({ title: 'User(s) invited', type: 'success' });
+ handle.close();
+ },
+ onError: (error: Error) => {
+ toastManager.add({
+ title: 'Something went wrong',
+ description: error?.message || 'Failed to create invitation',
+ type: 'error'
+ });
+ }
+ }
+ );
+
+ const values = watch(['emails', 'type']);
+
+ const onSubmit = useCallback(
+ async ({ emails, type, team }: InviteSchemaType) => {
+ const emailList = emails
+ .split(',')
+ .map(e => e.trim())
+ .filter(str => str.length > 0);
+
+ if (!organization?.id) return;
+ if (!emailList.length) return;
+ if (!type) return;
+
+ try {
+ const req = create(CreateOrganizationInvitationRequestSchema, {
+ orgId: organization.id,
+ userIds: emailList,
+ groupIds: showTeamField && team ? [team] : undefined,
+ roleIds: [type]
+ });
+ await createInvitation(req);
+ } catch (error: unknown) {
+ toastManager.add({
+ title: 'Something went wrong',
+ description:
+ error instanceof Error
+ ? error.message
+ : 'Failed to create invitation',
+ type: 'error'
+ });
+ }
+ },
+ [createInvitation, organization?.id, showTeamField]
+ );
+
+ const isDisabled = useMemo(() => {
+ const [emails, type] = values;
+ const emailList =
+ emails
+ ?.split(',')
+ .map((e: string) => e.trim())
+ .filter((str: string) => str.length > 0) || [];
+ return emailList.length <= 0 || !type || isSubmitting;
+ }, [isSubmitting, values]);
+
+ return (
+
+ );
+}
diff --git a/web/sdk/react/views-new/members/components/member-columns.module.css b/web/sdk/react/views-new/members/components/member-columns.module.css
new file mode 100644
index 000000000..c826cd61c
--- /dev/null
+++ b/web/sdk/react/views-new/members/components/member-columns.module.css
@@ -0,0 +1,11 @@
+.pendingText {
+ color: var(--rs-color-foreground-base-tertiary);
+}
+
+.actionsCell {
+ visibility: hidden;
+}
+
+tr:hover .actionsCell {
+ visibility: visible;
+}
diff --git a/web/sdk/react/views-new/members/components/member-columns.tsx b/web/sdk/react/views-new/members/components/member-columns.tsx
new file mode 100644
index 000000000..19717b076
--- /dev/null
+++ b/web/sdk/react/views-new/members/components/member-columns.tsx
@@ -0,0 +1,155 @@
+'use client';
+
+import { DotsVerticalIcon } from '@radix-ui/react-icons';
+import {
+ Flex,
+ Text,
+ Avatar,
+ getAvatarColor,
+ Menu,
+ IconButton,
+ DataTableColumnDef
+} from '@raystack/apsara-v1';
+import type { Role } from '@raystack/proton/frontier';
+import type { MemberWithInvite } from '~/react/hooks/useOrganizationMembers';
+import { differenceWith, getInitials, isEqualById } from '~/utils';
+import styles from './member-columns.module.css';
+
+export interface MemberMenuPayload {
+ memberId: string;
+ invited: boolean;
+ excludedRoles: Role[];
+ canUpdateRole: boolean;
+ canRemove: boolean;
+}
+type MenuHandle = ReturnType;
+
+export const getColumns = ({
+ memberRoles,
+ roles,
+ canDeleteUser,
+ menuHandle
+}: {
+ memberRoles: Record;
+ roles: Role[];
+ canDeleteUser: boolean;
+ menuHandle: MenuHandle;
+}): DataTableColumnDef[] => [
+ {
+ header: 'Name',
+ accessorKey: 'title',
+ cell: ({ row, getValue }) => {
+ const id = row.original?.id || '';
+ const title = getValue() as string;
+ const email = row.original.email || row.original.userId;
+ const fallback = title || email;
+ return (
+
+
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {email}
+
+
+
+ );
+ }
+ },
+ {
+ header: 'Role',
+ accessorKey: 'email',
+ cell: ({ row }) => {
+ const member = row.original;
+ let roleDisplay: string;
+
+ if (member.invited) {
+ const inviteRoleIds = (member as { roleIds?: string[] }).roleIds;
+ if (inviteRoleIds?.length) {
+ roleDisplay =
+ inviteRoleIds
+ .map(id => roles.find(r => r.id === id))
+ .filter(Boolean)
+ .map(r => r?.title || r?.name)
+ .join(', ') || 'Member';
+ } else {
+ roleDisplay = 'Member';
+ }
+ } else if (member.id && memberRoles[member.id]) {
+ roleDisplay = memberRoles[member.id]
+ .map((r: Role) => r.title || r.name)
+ .join(', ');
+ } else {
+ roleDisplay = 'Inherited role';
+ }
+
+ return (
+
+ {roleDisplay}
+ {member.invited && (
+
+ {' '}
+ (Pending invite)
+
+ )}
+
+ );
+ }
+ },
+ {
+ header: '',
+ accessorKey: 'id',
+ enableSorting: false,
+ styles: {
+ cell: { width: '48px' }
+ },
+ cell: ({ row }) => {
+ const member = row.original;
+ const memberId = member.id || '';
+ const userRoles = memberId ? memberRoles[memberId] : [];
+ const excludedRoles = differenceWith(
+ isEqualById,
+ roles,
+ userRoles || []
+ );
+ const canUpdateRole = canDeleteUser && !member.invited;
+ const canRemove = canDeleteUser;
+
+ if (!canUpdateRole && !canRemove) return null;
+
+ return (
+
+
+ }
+ >
+
+
+
+ );
+ }
+ }
+ ];
diff --git a/web/sdk/react/views-new/members/components/remove-member-dialog.tsx b/web/sdk/react/views-new/members/components/remove-member-dialog.tsx
new file mode 100644
index 000000000..478dc24fe
--- /dev/null
+++ b/web/sdk/react/views-new/members/components/remove-member-dialog.tsx
@@ -0,0 +1,150 @@
+'use client';
+
+import { useState } from 'react';
+import { create } from '@bufbuild/protobuf';
+import { useMutation } from '@connectrpc/connect-query';
+import {
+ FrontierServiceQueries,
+ DeleteOrganizationInvitationRequestSchema,
+ RemoveOrganizationUserRequestSchema
+} from '@raystack/proton/frontier';
+import {
+ Button,
+ Text,
+ Dialog,
+ Flex,
+ toastManager
+} from '@raystack/apsara-v1';
+import { useFrontier } from '../../../contexts/FrontierContext';
+import { useTerminology } from '../../../hooks/useTerminology';
+
+export type RemoveMemberPayload = { memberId: string; invited: string };
+
+export interface RemoveMemberDialogProps {
+ handle: ReturnType>;
+ refetch: () => void;
+}
+
+export function RemoveMemberDialog({ handle, refetch }: RemoveMemberDialogProps) {
+ const { activeOrganization } = useFrontier();
+ const organizationId = activeOrganization?.id ?? '';
+ const [isLoading, setIsLoading] = useState(false);
+ const t = useTerminology();
+
+ const handleOpenChange = (open: boolean) => {
+ if (!open) {
+ refetch();
+ }
+ };
+
+ const { mutateAsync: deleteInvitation } = useMutation(
+ FrontierServiceQueries.deleteOrganizationInvitation,
+ {
+ onSuccess: () => {
+ handle.close();
+ toastManager.add({ title: 'Member deleted', type: 'success' });
+ },
+ onError: (error: Error) => {
+ toastManager.add({
+ title: 'Something went wrong',
+ description: error?.message || 'Failed to delete invitation',
+ type: 'error'
+ });
+ }
+ }
+ );
+
+ const { mutateAsync: removeUser } = useMutation(
+ FrontierServiceQueries.removeOrganizationUser,
+ {
+ onSuccess: () => {
+ handle.close();
+ toastManager.add({ title: 'Member deleted', type: 'success' });
+ },
+ onError: (error: Error) => {
+ toastManager.add({
+ title: 'Something went wrong',
+ description: error?.message || 'Failed to remove user',
+ type: 'error'
+ });
+ }
+ }
+ );
+
+ const deleteMember = async (memberId: string, invited: string) => {
+ setIsLoading(true);
+ try {
+ if (invited === 'true') {
+ const req = create(DeleteOrganizationInvitationRequestSchema, {
+ orgId: organizationId,
+ id: memberId
+ });
+ await deleteInvitation(req);
+ } else {
+ const req = create(RemoveOrganizationUserRequestSchema, {
+ id: organizationId,
+ userId: memberId
+ });
+ await removeUser(req);
+ }
+ } catch (error: unknown) {
+ toastManager.add({
+ title: 'Something went wrong',
+ description:
+ error instanceof Error
+ ? error.message
+ : 'Failed to remove member',
+ type: 'error'
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/web/sdk/react/views-new/members/components/update-role-dialog.tsx b/web/sdk/react/views-new/members/components/update-role-dialog.tsx
new file mode 100644
index 000000000..18f220a79
--- /dev/null
+++ b/web/sdk/react/views-new/members/components/update-role-dialog.tsx
@@ -0,0 +1,170 @@
+'use client';
+
+import { useState } from 'react';
+import { create } from '@bufbuild/protobuf';
+import { useMutation, useQuery } from '@connectrpc/connect-query';
+import {
+ FrontierServiceQueries,
+ DeletePolicyRequestSchema,
+ CreatePolicyRequestSchema,
+ ListPoliciesRequestSchema
+} from '@raystack/proton/frontier';
+import type { Role, Policy } from '@raystack/proton/frontier';
+import {
+ Button,
+ Text,
+ Dialog,
+ Flex,
+ toastManager
+} from '@raystack/apsara-v1';
+
+export type UpdateRolePayload = { memberId: string; role: Role };
+
+export interface UpdateRoleDialogProps {
+ handle: ReturnType>;
+ organizationId: string;
+ refetch: () => void;
+}
+
+export function UpdateRoleDialog({ handle, organizationId, refetch }: UpdateRoleDialogProps) {
+ const handleOpenChange = (open: boolean) => {
+ if (!open) {
+ refetch();
+ }
+ };
+
+ return (
+
+ );
+}
+
+function UpdateRoleContent({
+ payload,
+ organizationId,
+ onClose
+}: {
+ payload: UpdateRolePayload;
+ organizationId: string;
+ onClose: () => void;
+}) {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const { data: policiesData } = useQuery(
+ FrontierServiceQueries.listPolicies,
+ create(ListPoliciesRequestSchema, {
+ orgId: organizationId,
+ userId: payload.memberId
+ }),
+ { enabled: !!payload.memberId && !!payload.role }
+ );
+
+ const { mutateAsync: deletePolicy } = useMutation(
+ FrontierServiceQueries.deletePolicy
+ );
+
+ const { mutateAsync: createPolicy } = useMutation(
+ FrontierServiceQueries.createPolicy
+ );
+
+ const handleUpdate = async () => {
+ setIsLoading(true);
+ try {
+ const resource = `app/organization:${organizationId}`;
+ const principal = `app/user:${payload.memberId}`;
+ const policies = policiesData?.policies || [];
+
+ const deleteResults = await Promise.allSettled(
+ policies.map((p: Policy) => {
+ const req = create(DeletePolicyRequestSchema, {
+ id: p.id as string
+ });
+ return deletePolicy(req);
+ })
+ );
+
+ const deleteErrors = deleteResults
+ .filter(
+ (result): result is PromiseRejectedResult =>
+ result.status === 'rejected'
+ )
+ .map(result => result.reason);
+
+ if (deleteErrors.length > 0) {
+ console.warn('Some policy deletions failed:', deleteErrors);
+ }
+
+ const createReq = create(CreatePolicyRequestSchema, {
+ body: {
+ roleId: payload.role.id as string,
+ title: payload.role.name as string,
+ resource,
+ principal
+ }
+ });
+ await createPolicy(createReq);
+
+ toastManager.add({ title: 'Member role updated', type: 'success' });
+ onClose();
+ } catch (error: unknown) {
+ toastManager.add({
+ title: 'Something went wrong',
+ description:
+ error instanceof Error
+ ? error.message
+ : 'Failed to update member role',
+ type: 'error'
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ Update role
+
+
+ This action will remove access to all projects where the user
+ doesn't have an explicit project-level role.
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/sdk/react/views-new/members/index.ts b/web/sdk/react/views-new/members/index.ts
new file mode 100644
index 000000000..5ff94716e
--- /dev/null
+++ b/web/sdk/react/views-new/members/index.ts
@@ -0,0 +1,2 @@
+export { MembersView } from './members-view';
+export type { MembersViewProps } from './members-view';
diff --git a/web/sdk/react/views-new/members/members-view.module.css b/web/sdk/react/views-new/members/members-view.module.css
new file mode 100644
index 000000000..f3df5d5d0
--- /dev/null
+++ b/web/sdk/react/views-new/members/members-view.module.css
@@ -0,0 +1,14 @@
+.roleFilter {
+ width: auto;
+ min-width: 80px;
+ box-shadow: none;
+}
+
+.tableRoot {
+ border: none;
+}
+
+
+.menuContent {
+ min-width: 180px;
+}
diff --git a/web/sdk/react/views-new/members/members-view.tsx b/web/sdk/react/views-new/members/members-view.tsx
new file mode 100644
index 000000000..a3131685d
--- /dev/null
+++ b/web/sdk/react/views-new/members/members-view.tsx
@@ -0,0 +1,253 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import { ExclamationTriangleIcon, TrashIcon, UpdateIcon } from '@radix-ui/react-icons';
+import {
+ Button,
+ Tooltip,
+ Skeleton,
+ Flex,
+ Select,
+ EmptyState,
+ DataTable,
+ Dialog,
+ Menu
+} from '@raystack/apsara-v1';
+import { useFrontier } from '../../contexts/FrontierContext';
+import { useOrganizationMembers } from '../../hooks/useOrganizationMembers';
+import { usePermissions } from '../../hooks/usePermissions';
+import { AuthTooltipMessage } from '../../utils';
+import { PERMISSIONS, shouldShowComponent } from '../../../utils';
+import { ViewContainer } from '../../components/view-container';
+import { ViewHeader } from '../../components/view-header';
+import { getColumns, type MemberMenuPayload } from './components/member-columns';
+import { InviteMemberDialog } from './components/invite-member-dialog';
+import { RemoveMemberDialog, type RemoveMemberPayload } from './components/remove-member-dialog';
+import { UpdateRoleDialog, type UpdateRolePayload } from './components/update-role-dialog';
+import styles from './members-view.module.css';
+
+const memberMenuHandle = Menu.createHandle();
+const inviteDialogHandle = Dialog.createHandle();
+const removeMemberDialogHandle = Dialog.createHandle();
+const updateRoleDialogHandle = Dialog.createHandle();
+
+export interface MembersViewProps {
+ showTeamField?: boolean;
+}
+
+export function MembersView({ showTeamField = true }: MembersViewProps) {
+ const { activeOrganization: organization } = useFrontier();
+
+ const resource = `app/organization:${organization?.id}`;
+ const listOfPermissionsToCheck = useMemo(
+ () => [
+ {
+ permission: PERMISSIONS.InvitationCreatePermission,
+ resource
+ },
+ {
+ permission: PERMISSIONS.UpdatePermission,
+ resource
+ }
+ ],
+ [resource]
+ );
+
+ const { permissions, isFetching: isPermissionsFetching } = usePermissions(
+ listOfPermissionsToCheck,
+ !!organization?.id
+ );
+
+ const { canCreateInvite, canDeleteUser } = useMemo(() => {
+ return {
+ canCreateInvite: shouldShowComponent(
+ permissions,
+ `${PERMISSIONS.InvitationCreatePermission}::${resource}`
+ ),
+ canDeleteUser: shouldShowComponent(
+ permissions,
+ `${PERMISSIONS.UpdatePermission}::${resource}`
+ )
+ };
+ }, [permissions, resource]);
+
+ const {
+ roles,
+ members,
+ memberRoles,
+ refetch,
+ isFetching: isOrgMembersLoading
+ } = useOrganizationMembers({
+ showInvitations: canCreateInvite
+ });
+
+ const isLoading = !organization?.id || isOrgMembersLoading || isPermissionsFetching;
+
+ const [roleFilter, setRoleFilter] = useState('all');
+
+ const filteredMembers = useMemo(() => {
+ if (roleFilter === 'all') return members;
+ return members.filter(member => {
+ if (roleFilter === 'invited') return member.invited;
+ if (member.invited) return false;
+ const userRoles = member.id ? memberRoles[member.id] : [];
+ return userRoles?.some(r => r.id === roleFilter);
+ }) || [];
+ }, [members, roleFilter, memberRoles]);
+
+ const columns = useMemo(
+ () =>
+ getColumns({
+ memberRoles,
+ roles,
+ canDeleteUser,
+ menuHandle: memberMenuHandle
+ }),
+ [memberRoles, roles, canDeleteUser]
+ );
+
+ return (
+
+
+
+
+
+
+
+ {isLoading ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ {isLoading ? (
+
+ ) : (
+
+ }
+ >
+
+
+ {!canCreateInvite && (
+ {AuthTooltipMessage}
+ )}
+
+ )}
+
+ }
+ heading="No members found"
+ subHeading="Get started by adding your first member"
+ />
+ }
+ classNames={{
+ root: styles.tableRoot
+ }}
+ />
+
+
+
+
+
+
+
+
+
+ );
+}