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 ( + + + + Invite people + +
+ + + {isLoading ? ( + + ) : ( +