From 073071d90bbcd6300d8d8668a468be873d9f3e80 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 25 Feb 2026 02:13:02 +0530 Subject: [PATCH 1/6] feat: move sdk general --- .../components/organization/general/index.tsx | 135 +----------------- .../react/components/organization/routes.tsx | 9 +- .../components/organization/shared/types.ts | 7 + .../general/delete-organization-dialog.tsx} | 21 ++- .../general/general-organization.tsx} | 5 +- web/sdk/react/views/general/general-page.tsx | 127 ++++++++++++++++ .../general/general.module.css | 1 + web/sdk/react/views/general/index.ts | 8 ++ 8 files changed, 166 insertions(+), 147 deletions(-) create mode 100644 web/sdk/react/components/organization/shared/types.ts rename web/sdk/react/{components/organization/general/delete.tsx => views/general/delete-organization-dialog.tsx} (92%) rename web/sdk/react/{components/organization/general/general.workspace.tsx => views/general/general-organization.tsx} (98%) create mode 100644 web/sdk/react/views/general/general-page.tsx rename web/sdk/react/{components/organization => views}/general/general.module.css (99%) create mode 100644 web/sdk/react/views/general/index.ts diff --git a/web/sdk/react/components/organization/general/index.tsx b/web/sdk/react/components/organization/general/index.tsx index 54c531c3b..54665016b 100644 --- a/web/sdk/react/components/organization/general/index.tsx +++ b/web/sdk/react/components/organization/general/index.tsx @@ -1,135 +1,10 @@ 'use client'; -import { useMemo } from 'react'; -import { - Button, - Tooltip, - Separator, - Skeleton, - Text, - Flex -} from '@raystack/apsara'; -import { Outlet, useNavigate } from '@tanstack/react-router'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import { usePermissions } from '~/react/hooks/usePermissions'; -import { PERMISSIONS, shouldShowComponent } from '~/utils'; -import { GeneralOrganization } from './general.workspace'; -import { AuthTooltipMessage } from '~/react/utils'; -import { useTerminology } from '~/react/hooks/useTerminology'; -import { PageHeader } from '~/react/components/common/page-header'; -import sharedStyles from '../styles.module.css'; +import { GeneralPage } from '~/react/views/general'; export default function GeneralSetting() { - const t = useTerminology(); - const { activeOrganization: organization, isActiveOrganizationLoading } = - useFrontier(); - - const resource = `app/organization:${organization?.id}`; - - const listOfPermissionsToCheck = useMemo(() => { - return [ - { - permission: PERMISSIONS.UpdatePermission, - resource: resource - }, - { - permission: PERMISSIONS.DeletePermission, - resource: resource - } - ]; - }, [resource]); - - const { permissions, isFetching: isPermissionsFetching } = usePermissions( - listOfPermissionsToCheck, - !!organization?.id - ); - - const { canUpdateWorkspace, canDeleteWorkspace } = useMemo(() => { - return { - canUpdateWorkspace: shouldShowComponent( - permissions, - `${PERMISSIONS.UpdatePermission}::${resource}` - ), - canDeleteWorkspace: shouldShowComponent( - permissions, - `${PERMISSIONS.DeletePermission}::${resource}` - ) - }; - }, [permissions, resource]); - - const isLoading = isActiveOrganizationLoading || isPermissionsFetching; - - return ( - - - - - - - - - - - - - ); + return { + // @ts-ignore + window.location = window.location.origin; + }}/>; } - -export const GeneralDeleteOrganization = ({ - isLoading, - canDelete -}: { - isLoading?: boolean; - canDelete: boolean; -}) => { - const t = useTerminology(); - const navigate = useNavigate({ from: '/' }); - return ( - <> - - {isLoading ? ( - - ) : ( - - If you want to permanently delete this{' '} - {t.organization({ case: 'lower' })} and all of its data. - - )} - {isLoading ? ( - - ) : ( - - - - )} - - - - ); -}; diff --git a/web/sdk/react/components/organization/routes.tsx b/web/sdk/react/components/organization/routes.tsx index 721856712..81e994cb8 100644 --- a/web/sdk/react/components/organization/routes.tsx +++ b/web/sdk/react/components/organization/routes.tsx @@ -13,7 +13,6 @@ import Domain from './domain'; import { AddDomain } from './domain/add-domain'; import { VerifyDomain } from './domain/verify-domain'; import GeneralSetting from './general'; -import { DeleteOrganization } from './general/delete'; import WorkspaceMembers from './members'; import { InviteMember } from './members/invite'; import UserPreferences from './preferences'; @@ -148,12 +147,6 @@ const indexRoute = createRoute({ component: GeneralSetting }); -const deleteOrgRoute = createRoute({ - getParentRoute: () => indexRoute, - path: '/delete', - component: DeleteOrganization -}); - const securityRoute = createRoute({ getParentRoute: () => rootRoute, path: '/security', @@ -367,7 +360,7 @@ interface getRootTreeOptions { export function getRootTree({ customScreens = [] }: getRootTreeOptions) { return rootRoute.addChildren([ - indexRoute.addChildren([deleteOrgRoute]), + indexRoute, securityRoute, sessionsRoute.addChildren([revokeSessionRoute]), membersRoute.addChildren([inviteMemberRoute, removeMemberRoute]), diff --git a/web/sdk/react/components/organization/shared/types.ts b/web/sdk/react/components/organization/shared/types.ts new file mode 100644 index 000000000..7ffa037f7 --- /dev/null +++ b/web/sdk/react/components/organization/shared/types.ts @@ -0,0 +1,7 @@ +export type OnNavigate = (to: string, params?: Record) => void; + +export interface BasePageProps { + organizationId: string; + onNavigate?: OnNavigate; +} + diff --git a/web/sdk/react/components/organization/general/delete.tsx b/web/sdk/react/views/general/delete-organization-dialog.tsx similarity index 92% rename from web/sdk/react/components/organization/general/delete.tsx rename to web/sdk/react/views/general/delete-organization-dialog.tsx index aaebfb357..c5057c8c6 100644 --- a/web/sdk/react/components/organization/general/delete.tsx +++ b/web/sdk/react/views/general/delete-organization-dialog.tsx @@ -10,7 +10,6 @@ import { } from '@raystack/apsara'; import { yupResolver } from '@hookform/resolvers/yup'; -import { useNavigate } from '@tanstack/react-router'; import { useForm } from 'react-hook-form'; import * as yup from 'yup'; import { useFrontier } from '~/react/contexts/FrontierContext'; @@ -30,7 +29,17 @@ const orgSchema = yup }) .required(); -export const DeleteOrganization = () => { +export interface DeleteOrganizationDialogProps { + open: boolean; + onOpenChange: (value: boolean) => void; + onDeleteSuccess?: () => void; +} + +export const DeleteOrganizationDialog = ({ + open, + onOpenChange, + onDeleteSuccess +}: DeleteOrganizationDialogProps) => { const { watch, handleSubmit, @@ -40,7 +49,6 @@ export const DeleteOrganization = () => { } = useForm({ resolver: yupResolver(orgSchema) }); - const navigate = useNavigate({ from: '/delete' }); const t = useTerminology(); const { activeOrganization: organization } = useFrontier(); const { mutateAsync: deleteOrganization } = useMutation( @@ -62,8 +70,7 @@ export const DeleteOrganization = () => { await deleteOrganization(req); toast.success(`${t.organization({ case: 'capital' })} deleted`); - // @ts-ignore - window.location = window.location.origin; + onDeleteSuccess?.(); } catch (error: any) { toast.error('Something went wrong', { description: @@ -75,14 +82,13 @@ export const DeleteOrganization = () => { const title = watch('title', ''); return ( - + Verify {t.organization({ case: 'lower' })} deletion navigate({ to: '/' })} data-test-id="frontier-sdk-delete-organization-close-btn" /> @@ -139,3 +145,4 @@ export const DeleteOrganization = () => { ); }; + diff --git a/web/sdk/react/components/organization/general/general.workspace.tsx b/web/sdk/react/views/general/general-organization.tsx similarity index 98% rename from web/sdk/react/components/organization/general/general.workspace.tsx rename to web/sdk/react/views/general/general-organization.tsx index 8bc28fae8..1d94fcf9b 100644 --- a/web/sdk/react/components/organization/general/general.workspace.tsx +++ b/web/sdk/react/views/general/general-organization.tsx @@ -26,7 +26,7 @@ import { } from '@raystack/proton/frontier'; import { create } from '@bufbuild/protobuf'; import { AuthTooltipMessage } from '~/react/utils'; -import { AvatarUpload } from '../../avatar-upload'; +import { AvatarUpload } from '~/react/components/avatar-upload'; import { getInitials } from '~/utils'; import { useTerminology } from '~/react/hooks/useTerminology'; import styles from './general.module.css'; @@ -41,7 +41,7 @@ const generalSchema = yup type FormData = yup.InferType; -interface GeneralOrganizationProps { +export interface GeneralOrganizationProps { organization?: Organization; isLoading?: boolean; canUpdateWorkspace?: boolean; @@ -201,3 +201,4 @@ export const GeneralOrganization = ({ ); }; + diff --git a/web/sdk/react/views/general/general-page.tsx b/web/sdk/react/views/general/general-page.tsx new file mode 100644 index 000000000..5873dd431 --- /dev/null +++ b/web/sdk/react/views/general/general-page.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { + Button, + Tooltip, + Separator, + Skeleton, + Text, + Flex +} from '@raystack/apsara'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { usePermissions } from '~/react/hooks/usePermissions'; +import { PERMISSIONS, shouldShowComponent } from '~/utils'; +import { GeneralOrganization } from './general-organization'; +import { AuthTooltipMessage } from '~/react/utils'; +import { useTerminology } from '~/react/hooks/useTerminology'; +import { PageHeader } from '~/react/components/common/page-header'; +import { DeleteOrganizationDialog } from './delete-organization-dialog'; +import sharedStyles from '../../components/organization/styles.module.css'; + +export interface GeneralPageProps { + onDeleteSuccess?: () => void; +} + +export function GeneralPage({ onDeleteSuccess }: GeneralPageProps = {}) { + const t = useTerminology(); + const { activeOrganization: organization, isActiveOrganizationLoading } = + useFrontier(); + + const resource = `app/organization:${organization?.id}`; + + const listOfPermissionsToCheck = useMemo(() => { + return [ + { + permission: PERMISSIONS.UpdatePermission, + resource: resource + }, + { + permission: PERMISSIONS.DeletePermission, + resource: resource + } + ]; + }, [resource]); + + const { permissions, isFetching: isPermissionsFetching } = usePermissions( + listOfPermissionsToCheck, + !!organization?.id + ); + + const { canUpdateWorkspace, canDeleteWorkspace } = useMemo(() => { + return { + canUpdateWorkspace: shouldShowComponent( + permissions, + `${PERMISSIONS.UpdatePermission}::${resource}` + ), + canDeleteWorkspace: shouldShowComponent( + permissions, + `${PERMISSIONS.DeletePermission}::${resource}` + ) + }; + }, [permissions, resource]); + + const isLoading = isActiveOrganizationLoading || isPermissionsFetching; + + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + return ( + + + + + + + + + + {isLoading ? ( + + ) : ( + + If you want to permanently delete this{' '} + {t.organization({ case: 'lower' })} and all of its data. + + )} + {isLoading ? ( + + ) : ( + + + + )} + + + + + + ); +} + diff --git a/web/sdk/react/components/organization/general/general.module.css b/web/sdk/react/views/general/general.module.css similarity index 99% rename from web/sdk/react/components/organization/general/general.module.css rename to web/sdk/react/views/general/general.module.css index dcbcd9b58..e396d0f3b 100644 --- a/web/sdk/react/components/organization/general/general.module.css +++ b/web/sdk/react/views/general/general.module.css @@ -49,3 +49,4 @@ .deleteFooter { flex-direction: column; } + diff --git a/web/sdk/react/views/general/index.ts b/web/sdk/react/views/general/index.ts new file mode 100644 index 000000000..f18f55af0 --- /dev/null +++ b/web/sdk/react/views/general/index.ts @@ -0,0 +1,8 @@ +export { GeneralPage } from './general-page'; +export type { GeneralPageProps } from './general-page'; + +export { GeneralOrganization } from './general-organization'; +export type { GeneralOrganizationProps } from './general-organization'; + +export { DeleteOrganizationDialog } from './delete-organization-dialog'; +export type { DeleteOrganizationDialogProps } from './delete-organization-dialog'; From 0d00b142f6c2230242838b00025d983e3403e672 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 25 Feb 2026 17:12:39 +0530 Subject: [PATCH 2/6] feat: migrate sdk members --- .../components/organization/members/index.tsx | 193 +-------------- web/sdk/react/views/members/index.ts | 8 + .../members/invite-member-dialog.tsx} | 58 +++-- .../members/member-columns.tsx} | 53 ++-- .../members/member-types.ts} | 1 + web/sdk/react/views/members/members-page.tsx | 233 ++++++++++++++++++ .../members/members.module.css | 4 + .../members/remove-member-dialog.tsx} | 52 ++-- 8 files changed, 344 insertions(+), 258 deletions(-) create mode 100644 web/sdk/react/views/members/index.ts rename web/sdk/react/{components/organization/members/invite.tsx => views/members/invite-member-dialog.tsx} (89%) rename web/sdk/react/{components/organization/members/member.columns.tsx => views/members/member-columns.tsx} (89%) rename web/sdk/react/{components/organization/members/member.types.tsx => views/members/member-types.ts} (88%) create mode 100644 web/sdk/react/views/members/members-page.tsx rename web/sdk/react/{components/organization => views}/members/members.module.css (98%) rename web/sdk/react/{components/organization/members/MemberRemoveConfirm.tsx => views/members/remove-member-dialog.tsx} (79%) diff --git a/web/sdk/react/components/organization/members/index.tsx b/web/sdk/react/components/organization/members/index.tsx index 459df57d3..4ed5a0faf 100644 --- a/web/sdk/react/components/organization/members/index.tsx +++ b/web/sdk/react/components/organization/members/index.tsx @@ -1,196 +1,7 @@ 'use client'; -import { useEffect, useMemo } from 'react'; - -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; -import { - Button, - Tooltip, - Skeleton, - EmptyState, - Flex, - DataTable -} from '@raystack/apsara'; -import { Outlet, useNavigate, useRouterState } from '@tanstack/react-router'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import { useOrganizationMembers } from '~/react/hooks/useOrganizationMembers'; -import { usePermissions } from '~/react/hooks/usePermissions'; -import { AuthTooltipMessage } from '~/react/utils'; -import { PERMISSIONS, shouldShowComponent } from '~/utils'; -import { getColumns } from './member.columns'; -import type { MembersTableType } from './member.types'; -import { PageHeader } from '~/react/components/common/page-header'; -import sharedStyles from '../styles.module.css'; -import styles from './members.module.css'; +import { MembersPage } from '~/react/views/members'; export default function WorkspaceMembers() { - const { activeOrganization: organization } = useFrontier(); - - const routerState = useRouterState(); - - const isListRoute = useMemo(() => { - return routerState.location.pathname === '/members'; - }, [routerState.location.pathname]); - - 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 = isOrgMembersLoading || isPermissionsFetching; - - useEffect(() => { - if (isListRoute) { - refetch(); - } - }, [isListRoute, refetch, routerState.location.state.key]); - - return ( - - - - - - - {organization?.id ? ( - - ) : null} - - - - - ); + return ; } - -const MembersTable = ({ - isLoading, - users, - canCreateInvite, - canDeleteUser, - organizationId, - memberRoles, - roles, - refetch -}: MembersTableType) => { - const navigate = useNavigate({ from: '/members' }); - - const columns = useMemo( - () => - getColumns(organizationId, memberRoles, roles, canDeleteUser, refetch), - [organizationId, memberRoles, canDeleteUser, roles, refetch] - ); - - return ( - - - - - {isLoading ? ( - - ) : ( - - )} - - {isLoading ? ( - - ) : ( - - - - )} - - - - - ); -}; - -const noDataChildren = ( - } - heading="No members found" - subHeading="Get started by adding your first member" - /> -); diff --git a/web/sdk/react/views/members/index.ts b/web/sdk/react/views/members/index.ts new file mode 100644 index 000000000..14e1c5b2b --- /dev/null +++ b/web/sdk/react/views/members/index.ts @@ -0,0 +1,8 @@ +export { MembersPage } from './members-page'; +export type { MembersPageProps } from './members-page'; + +export { InviteMemberDialog } from './invite-member-dialog'; +export type { InviteMemberDialogProps } from './invite-member-dialog'; + +export { RemoveMemberDialog as MemberRemoveConfirmDialog } from './remove-member-dialog'; +export type { MemberRemoveConfirmDialogProps } from './remove-member-dialog'; diff --git a/web/sdk/react/components/organization/members/invite.tsx b/web/sdk/react/views/members/invite-member-dialog.tsx similarity index 89% rename from web/sdk/react/components/organization/members/invite.tsx rename to web/sdk/react/views/members/invite-member-dialog.tsx index c8f2a17d9..508cf69c5 100644 --- a/web/sdk/react/components/organization/members/invite.tsx +++ b/web/sdk/react/views/members/invite-member-dialog.tsx @@ -12,7 +12,6 @@ import { } from '@raystack/apsara'; import { yupResolver } from '@hookform/resolvers/yup'; -import { useNavigate } from '@tanstack/react-router'; import { useCallback, useMemo } from 'react'; import { Controller, useForm } from 'react-hook-form'; import * as yup from 'yup'; @@ -20,10 +19,16 @@ import cross from '~/react/assets/cross.svg'; import { useFrontier } from '~/react/contexts/FrontierContext'; import { PERMISSIONS } from '~/utils'; import { useMutation, useQuery } from '@connectrpc/connect-query'; -import { FrontierServiceQueries, CreateOrganizationInvitationRequestSchema, ListOrganizationRolesRequestSchema, ListRolesRequestSchema, ListOrganizationGroupsRequestSchema } from '@raystack/proton/frontier'; +import { + FrontierServiceQueries, + CreateOrganizationInvitationRequestSchema, + ListOrganizationRolesRequestSchema, + ListRolesRequestSchema, + ListOrganizationGroupsRequestSchema +} from '@raystack/proton/frontier'; import { create } from '@bufbuild/protobuf'; import { handleSelectValueChange } from '~/react/utils'; -import styles from '../organization.module.css'; +import styles from '../../components/organization/organization.module.css'; const inviteSchema = yup.object({ type: yup.string().required(), @@ -33,7 +38,15 @@ const inviteSchema = yup.object({ type InviteSchemaType = yup.InferType; -export const InviteMember = () => { +export interface InviteMemberDialogProps { + open: boolean; + onOpenChange: (value: boolean) => void; +} + +export const InviteMemberDialog = ({ + open, + onOpenChange +}: InviteMemberDialogProps) => { const { watch, register, @@ -43,11 +56,10 @@ export const InviteMember = () => { } = useForm({ resolver: yupResolver(inviteSchema) }); - const navigate = useNavigate({ from: '/members/modal' }); const { activeOrganization: organization } = useFrontier(); - + // Organization roles query - const { data: orgRolesData, isLoading: isOrgRolesLoading, error: orgRolesError } = useQuery( + const { data: orgRolesData, isLoading: isOrgRolesLoading } = useQuery( FrontierServiceQueries.listOrganizationRoles, create(ListOrganizationRolesRequestSchema, { orgId: organization?.id || '', @@ -61,7 +73,7 @@ export const InviteMember = () => { const orgRoles = useMemo(() => orgRolesData?.roles || [], [orgRolesData]); // Global roles query - const { data: globalRolesData, isLoading: isGlobalRolesLoading, error: globalRolesError } = useQuery( + const { data: globalRolesData, isLoading: isGlobalRolesLoading } = useQuery( FrontierServiceQueries.listRoles, create(ListRolesRequestSchema, { scopes: [PERMISSIONS.OrganizationNamespace] @@ -71,10 +83,13 @@ export const InviteMember = () => { } ); - const globalRoles = useMemo(() => globalRolesData?.roles || [], [globalRolesData]); + const globalRoles = useMemo( + () => globalRolesData?.roles || [], + [globalRolesData] + ); // Organization groups query - const { data: teamsData, isLoading: isGroupsLoading, error: groupsError } = useQuery( + const { data: teamsData, isLoading: isGroupsLoading } = useQuery( FrontierServiceQueries.listOrganizationGroups, create(ListOrganizationGroupsRequestSchema, { orgId: organization?.id || '' @@ -85,26 +100,27 @@ export const InviteMember = () => { ); const teams = useMemo(() => teamsData?.groups || [], [teamsData]); - - const isLoading = isOrgRolesLoading || isGlobalRolesLoading || isGroupsLoading; - - const roles = useMemo(() => - [...(globalRoles || []), ...(orgRoles || [])], + + const isLoading = + isOrgRolesLoading || isGlobalRolesLoading || isGroupsLoading; + + const roles = useMemo( + () => [...(globalRoles || []), ...(orgRoles || [])], [globalRoles, orgRoles] ); - + const { mutateAsync: createInvitation } = useMutation( FrontierServiceQueries.createOrganizationInvitation, { onSuccess: () => { toast.success('User(s) invited'); - navigate({ to: '/members' }); + onOpenChange(false); }, onError: (error: any) => { toast.error('Something went wrong', { description: error?.message || 'Failed to create invitation' }); - }, + } } ); @@ -138,8 +154,6 @@ export const InviteMember = () => { [createInvitation, organization?.id] ); - - const isDisabled = useMemo(() => { const [emails, type] = values; const emailList = @@ -151,7 +165,7 @@ export const InviteMember = () => { }, [isSubmitting, values]); return ( - + { alt="cross" style={{ cursor: 'pointer' }} src={cross as unknown as string} - onClick={() => navigate({ to: '/members' })} + onClick={() => onOpenChange(false)} data-test-id="frontier-sdk-invite-member-close-btn" /> diff --git a/web/sdk/react/components/organization/members/member.columns.tsx b/web/sdk/react/views/members/member-columns.tsx similarity index 89% rename from web/sdk/react/components/organization/members/member.columns.tsx rename to web/sdk/react/views/members/member-columns.tsx index e0a5168d8..eb35c413e 100644 --- a/web/sdk/react/components/organization/members/member.columns.tsx +++ b/web/sdk/react/views/members/member-columns.tsx @@ -3,7 +3,6 @@ import { TrashIcon, UpdateIcon } from '@radix-ui/react-icons'; -import { useNavigate } from '@tanstack/react-router'; import { useState } from 'react'; import { toast, @@ -18,16 +17,23 @@ import { import type { Role, Policy } from '@raystack/proton/frontier'; import { differenceWith, getInitials, isEqualById } from '~/utils'; import { useMutation, useQuery } from '@connectrpc/connect-query'; -import { FrontierServiceQueries, DeletePolicyRequestSchema, CreatePolicyRequestSchema, ListPoliciesRequestSchema } from '@raystack/proton/frontier'; +import { + FrontierServiceQueries, + DeletePolicyRequestSchema, + CreatePolicyRequestSchema, + ListPoliciesRequestSchema +} from '@raystack/proton/frontier'; import { create } from '@bufbuild/protobuf'; import type { MemberWithInvite } from '~/react/hooks/useOrganizationMembers'; +import { MembersTableType } from './member-types'; export const getColumns = ( organizationId: string, memberRoles: Record = {}, roles: Role[] = [], canDeleteUser = false, - refetch = () => {} + refetch = () => {}, + onRemoveMember?: MembersTableType['onRemoveMember'] ): DataTableColumnDef[] => [ { header: '', @@ -105,6 +111,7 @@ export const getColumns = ( ? memberRoles[row.original?.id] : [] )} + onRemoveMember={onRemoveMember} /> ) } @@ -115,16 +122,16 @@ const MembersActions = ({ organizationId, canUpdateGroup, excludedRoles = [], - refetch = () => null + refetch = () => null, + onRemoveMember }: { member: MemberWithInvite; canUpdateGroup?: boolean; organizationId: string; excludedRoles: Role[]; refetch: () => void; + onRemoveMember?: MembersTableType['onRemoveMember']; }) => { - const navigate = useNavigate({ from: '/members' }); - // Query to fetch policies for the current member const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -140,7 +147,7 @@ const MembersActions = ({ gcTime: 300_000 } ); - + const { mutateAsync: deletePolicy } = useMutation( FrontierServiceQueries.deletePolicy, { @@ -148,10 +155,10 @@ const MembersActions = ({ toast.error('Something went wrong', { description: error?.message || 'Failed to delete policy' }); - }, + } } ); - + const { mutateAsync: createPolicy } = useMutation( FrontierServiceQueries.createPolicy, { @@ -163,7 +170,7 @@ const MembersActions = ({ toast.error('Something went wrong', { description: error?.message || 'Failed to create policy' }); - }, + } } ); @@ -171,10 +178,10 @@ const MembersActions = ({ try { const resource = `app/organization:${organizationId}`; const principal = `app/user:${member?.id}`; - + // Use policies from Connect RPC query const policies = policiesData?.policies || []; - + // Delete existing policies with individual error handling const deleteResults = await Promise.allSettled( policies.map((p: Policy) => { @@ -184,16 +191,19 @@ const MembersActions = ({ return deletePolicy(req); }) ); - + // Check for delete errors const deleteErrors = deleteResults - .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .filter( + (result): result is PromiseRejectedResult => + result.status === 'rejected' + ) .map(result => result.reason); - + if (deleteErrors.length > 0) { console.warn('Some policy deletions failed:', deleteErrors); } - + // Create new policy const createReq = create(CreatePolicyRequestSchema, { body: { @@ -240,13 +250,10 @@ const MembersActions = ({ - navigate({ - to: `/members/remove-member/$memberId/$invited`, - params: { - memberId: member?.id || '', - invited: (member?.invited || false).toString() - } - }) + onRemoveMember?.( + member?.id || '', + String(member?.invited || false) + ) } data-test-id="remove-member-dropdown-item" > diff --git a/web/sdk/react/components/organization/members/member.types.tsx b/web/sdk/react/views/members/member-types.ts similarity index 88% rename from web/sdk/react/components/organization/members/member.types.tsx rename to web/sdk/react/views/members/member-types.ts index 5a509bc38..7941c6757 100644 --- a/web/sdk/react/components/organization/members/member.types.tsx +++ b/web/sdk/react/views/members/member-types.ts @@ -18,4 +18,5 @@ export type MembersTableType = { memberRoles: Record; roles: Role[]; refetch?: () => void; + onRemoveMember?: (memberId: string, invited: string) => void; }; diff --git a/web/sdk/react/views/members/members-page.tsx b/web/sdk/react/views/members/members-page.tsx new file mode 100644 index 000000000..0f829418d --- /dev/null +++ b/web/sdk/react/views/members/members-page.tsx @@ -0,0 +1,233 @@ +'use client'; + +import { useMemo, useState } from 'react'; + +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { + Button, + Tooltip, + Skeleton, + EmptyState, + Flex, + DataTable +} from '@raystack/apsara'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { useOrganizationMembers } from '~/react/hooks/useOrganizationMembers'; +import { usePermissions } from '~/react/hooks/usePermissions'; +import { AuthTooltipMessage } from '~/react/utils'; +import { PERMISSIONS, shouldShowComponent } from '~/utils'; +import type { MembersTableType } from './member-types'; +import { PageHeader } from '~/react/components/common/page-header'; +import { InviteMemberDialog } from './invite-member-dialog'; +import { RemoveMemberDialog } from './remove-member-dialog'; +import sharedStyles from '../../components/organization/styles.module.css'; +import styles from './members.module.css'; +import { getColumns } from './member-columns'; + +export interface MembersPageProps { + title?: string; + description?: string; +} + +export function MembersPage({ + title = 'Members', + description = 'Manage members in this domain.' +}: MembersPageProps = {}) { + 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 = isOrgMembersLoading || isPermissionsFetching; + + const [showInviteDialog, setShowInviteDialog] = useState(false); + const [removeMemberState, setRemoveMemberState] = useState<{ + open: boolean; + memberId: string; + invited: string; + }>({ open: false, memberId: '', invited: 'false' }); + + const handleRemoveMember = (memberId: string, invited: string) => { + setRemoveMemberState({ open: true, memberId, invited }); + }; + + const handleInviteOpenChange = (value: boolean) => { + setShowInviteDialog(value); + refetch(); + }; + + const handleRemoveOpenChange = (value: boolean) => { + setRemoveMemberState({ open: value, memberId: '', invited: 'false' }); + refetch(); + }; + + return ( + + + + + + + {organization?.id ? ( + setShowInviteDialog(true)} + /> + ) : null} + + + + + + ); +} + +const MembersTable = ({ + isLoading, + users, + canCreateInvite, + canDeleteUser, + organizationId, + memberRoles, + roles, + refetch, + onRemoveMember, + onInviteClick +}: MembersTableType & { onInviteClick: () => void }) => { + const columns = useMemo( + () => + getColumns( + organizationId, + memberRoles, + roles, + canDeleteUser, + refetch, + onRemoveMember + ), + [organizationId, memberRoles, canDeleteUser, roles, refetch, onRemoveMember] + ); + + return ( + + + + + {isLoading ? ( + + ) : ( + + )} + + {isLoading ? ( + + ) : ( + + + + )} + + + + + ); +}; + +const noDataChildren = ( + } + heading="No members found" + subHeading="Get started by adding your first member" + /> +); diff --git a/web/sdk/react/components/organization/members/members.module.css b/web/sdk/react/views/members/members.module.css similarity index 98% rename from web/sdk/react/components/organization/members/members.module.css rename to web/sdk/react/views/members/members.module.css index 0a0801173..5506d2f5a 100644 --- a/web/sdk/react/components/organization/members/members.module.css +++ b/web/sdk/react/views/members/members.module.css @@ -26,3 +26,7 @@ .skeletonRow { margin-top: var(--rs-space-8); } + + + + diff --git a/web/sdk/react/components/organization/members/MemberRemoveConfirm.tsx b/web/sdk/react/views/members/remove-member-dialog.tsx similarity index 79% rename from web/sdk/react/components/organization/members/MemberRemoveConfirm.tsx rename to web/sdk/react/views/members/remove-member-dialog.tsx index 93ea946ef..df7b5f14e 100644 --- a/web/sdk/react/components/organization/members/MemberRemoveConfirm.tsx +++ b/web/sdk/react/views/members/remove-member-dialog.tsx @@ -1,67 +1,77 @@ import { Button, toast, Image, Text, Dialog, Flex } from '@raystack/apsara'; import cross from '~/react/assets/cross.svg'; -import { useNavigate, useParams } from '@tanstack/react-router'; import { useFrontier } from '~/react/contexts/FrontierContext'; import { useState } from 'react'; import { useTerminology } from '~/react/hooks/useTerminology'; import { useMutation } from '@connectrpc/connect-query'; -import { FrontierServiceQueries, DeleteOrganizationInvitationRequestSchema, RemoveOrganizationUserRequestSchema } from '@raystack/proton/frontier'; +import { + FrontierServiceQueries, + DeleteOrganizationInvitationRequestSchema, + RemoveOrganizationUserRequestSchema +} from '@raystack/proton/frontier'; import { create } from '@bufbuild/protobuf'; -const MemberRemoveConfirm = () => { - const navigate = useNavigate({ - from: '/members/remove-member/$memberId/$invited' - }); - const { memberId, invited } = useParams({ - from: '/members/remove-member/$memberId/$invited' - }); +export interface MemberRemoveConfirmDialogProps { + open: boolean; + onOpenChange: (value: boolean) => void; + memberId: string; + invited: string; +} + +export const RemoveMemberDialog = ({ + open, + onOpenChange, + memberId, + invited +}: MemberRemoveConfirmDialogProps) => { const { activeOrganization } = useFrontier(); const organizationId = activeOrganization?.id ?? ''; const [isLoading, setIsLoading] = useState(false); const t = useTerminology(); - + const { mutateAsync: deleteInvitation } = useMutation( FrontierServiceQueries.deleteOrganizationInvitation, { onSuccess: () => { - navigate({ to: '/members' }); + onOpenChange(false); toast.success('Member deleted'); }, onError: (error: any) => { toast.error('Something went wrong', { description: error?.message || 'Failed to delete invitation' }); - }, + } } ); - + const { mutateAsync: removeUser } = useMutation( FrontierServiceQueries.removeOrganizationUser, { onSuccess: () => { - navigate({ to: '/members' }); + onOpenChange(false); toast.success('Member deleted'); }, onError: (error: any) => { toast.error('Something went wrong', { description: error?.message || 'Failed to remove user' }); - }, + } } ); + const deleteMember = async () => { setIsLoading(true); try { if (invited === 'true') { const req = create(DeleteOrganizationInvitationRequestSchema, { orgId: organizationId, - id: memberId as string + id: memberId }); await deleteInvitation(req); } else { const req = create(RemoveOrganizationUserRequestSchema, { id: organizationId, - userId: memberId as string + userId: memberId }); await removeUser(req); } @@ -75,7 +85,7 @@ const MemberRemoveConfirm = () => { }; return ( - navigate({ to: '/members' })}> + @@ -85,7 +95,7 @@ const MemberRemoveConfirm = () => { cross (isLoading ? null : navigate({ to: '/members' }))} + onClick={() => (isLoading ? null : onOpenChange(false))} style={{ cursor: isLoading ? 'not-allowed' : 'pointer' }} data-test-id="close-remove-member-dialog" /> @@ -106,7 +116,7 @@ const MemberRemoveConfirm = () => { ); }; - -export default MemberRemoveConfirm; From 18f70d1073e353485b74de0b3161bc9455cbc9b8 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 25 Feb 2026 17:25:59 +0530 Subject: [PATCH 3/6] feat: updates routes for sdk members --- web/sdk/react/components/organization/routes.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/web/sdk/react/components/organization/routes.tsx b/web/sdk/react/components/organization/routes.tsx index 81e994cb8..c4add3aba 100644 --- a/web/sdk/react/components/organization/routes.tsx +++ b/web/sdk/react/components/organization/routes.tsx @@ -14,7 +14,6 @@ import { AddDomain } from './domain/add-domain'; import { VerifyDomain } from './domain/verify-domain'; import GeneralSetting from './general'; import WorkspaceMembers from './members'; -import { InviteMember } from './members/invite'; import UserPreferences from './preferences'; import { default as WorkspaceProjects } from './project'; @@ -38,7 +37,6 @@ import { AddTokens } from './tokens/add-tokens'; import { ConfirmCycleSwitch } from './billing/cycle-switch'; import Plans from './plans'; import ConfirmPlanChange from './plans/confirm-change'; -import MemberRemoveConfirm from './members/MemberRemoveConfirm'; import APIKeys from './api-keys'; import { AddServiceAccount } from './api-keys/add'; import ServiceUserPage from './api-keys/service-user'; @@ -159,18 +157,6 @@ const membersRoute = createRoute({ component: WorkspaceMembers }); -const inviteMemberRoute = createRoute({ - getParentRoute: () => membersRoute, - path: '/modal', - component: InviteMember -}); - -const removeMemberRoute = createRoute({ - getParentRoute: () => membersRoute, - path: '/remove-member/$memberId/$invited', - component: MemberRemoveConfirm -}); - const teamsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/teams', @@ -363,7 +349,7 @@ export function getRootTree({ customScreens = [] }: getRootTreeOptions) { indexRoute, securityRoute, sessionsRoute.addChildren([revokeSessionRoute]), - membersRoute.addChildren([inviteMemberRoute, removeMemberRoute]), + membersRoute, teamsRoute.addChildren([addTeamRoute]), domainsRoute.addChildren([ addDomainRoute, From 3dccd04c1c8e331860ce9d366fb6a4bf0d3c3d61 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 26 Feb 2026 02:21:31 +0530 Subject: [PATCH 4/6] feat: move sdk teams --- .../react/components/organization/routes.tsx | 25 +- .../components/organization/teams/add.tsx | 131 ------- .../components/organization/teams/delete.tsx | 178 --------- .../organization/teams/general/index.tsx | 225 ----------- .../components/organization/teams/index.tsx | 239 +----------- .../organization/teams/members/index.tsx | 307 --------------- .../organization/teams/members/invite.tsx | 314 --------------- .../components/organization/teams/team.tsx | 143 +------ .../organization/teams/teams.columns.tsx | 101 ----- .../teams/details/delete-team-dialog.tsx | 205 ++++++++++ web/sdk/react/views/teams/details/index.ts | 9 + .../details/invite-team-member-dialog.tsx | 368 ++++++++++++++++++ .../views/teams/details/team-detail-page.tsx | 197 ++++++++++ .../teams/details/team-detail.module.css | 8 + .../views/teams/details/team-general.tsx | 212 ++++++++++ .../teams/details/team-member-columns.tsx} | 105 ++--- .../teams/details/team-members.module.css} | 1 + .../views/teams/details/team-members.tsx | 335 ++++++++++++++++ web/sdk/react/views/teams/index.ts | 14 + .../views/teams/list/add-team-dialog.tsx | 145 +++++++ web/sdk/react/views/teams/list/index.ts | 6 + .../react/views/teams/list/teams-columns.tsx | 99 +++++ .../views/teams/list/teams-list-page.tsx | 272 +++++++++++++ .../teams/list}/teams.module.css | 3 - 24 files changed, 1949 insertions(+), 1693 deletions(-) delete mode 100644 web/sdk/react/components/organization/teams/add.tsx delete mode 100644 web/sdk/react/components/organization/teams/delete.tsx delete mode 100644 web/sdk/react/components/organization/teams/general/index.tsx delete mode 100644 web/sdk/react/components/organization/teams/members/index.tsx delete mode 100644 web/sdk/react/components/organization/teams/members/invite.tsx delete mode 100644 web/sdk/react/components/organization/teams/teams.columns.tsx create mode 100644 web/sdk/react/views/teams/details/delete-team-dialog.tsx create mode 100644 web/sdk/react/views/teams/details/index.ts create mode 100644 web/sdk/react/views/teams/details/invite-team-member-dialog.tsx create mode 100644 web/sdk/react/views/teams/details/team-detail-page.tsx create mode 100644 web/sdk/react/views/teams/details/team-detail.module.css create mode 100644 web/sdk/react/views/teams/details/team-general.tsx rename web/sdk/react/{components/organization/teams/members/member.columns.tsx => views/teams/details/team-member-columns.tsx} (78%) rename web/sdk/react/{components/organization/teams/members/members.module.css => views/teams/details/team-members.module.css} (99%) create mode 100644 web/sdk/react/views/teams/details/team-members.tsx create mode 100644 web/sdk/react/views/teams/index.ts create mode 100644 web/sdk/react/views/teams/list/add-team-dialog.tsx create mode 100644 web/sdk/react/views/teams/list/index.ts create mode 100644 web/sdk/react/views/teams/list/teams-columns.tsx create mode 100644 web/sdk/react/views/teams/list/teams-list-page.tsx rename web/sdk/react/{components/organization/teams => views/teams/list}/teams.module.css (89%) diff --git a/web/sdk/react/components/organization/routes.tsx b/web/sdk/react/components/organization/routes.tsx index c4add3aba..cee0dd637 100644 --- a/web/sdk/react/components/organization/routes.tsx +++ b/web/sdk/react/components/organization/routes.tsx @@ -25,11 +25,8 @@ import { RemoveProjectMember } from './project/members/remove'; import WorkspaceSecurity from './security'; import { Sidebar } from './sidebar'; import WorkspaceTeams from './teams'; -import { AddTeam } from './teams/add'; -import { DeleteTeam } from './teams/delete'; import { TeamPage } from './teams/team'; import { UserSetting } from './user'; -import { InviteTeamMembers } from './teams/members/invite'; import { DeleteDomain } from './domain/delete'; import Billing from './billing'; import Tokens from './tokens'; @@ -163,12 +160,6 @@ const teamsRoute = createRoute({ component: WorkspaceTeams }); -const addTeamRoute = createRoute({ - getParentRoute: () => teamsRoute, - path: '/modal', - component: AddTeam -}); - const domainsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/domains', @@ -199,18 +190,6 @@ const teamRoute = createRoute({ component: TeamPage }); -const inviteTeamMembersRoute = createRoute({ - getParentRoute: () => teamRoute, - path: '/invite', - component: InviteTeamMembers -}); - -const deleteTeamRoute = createRoute({ - getParentRoute: () => teamRoute, - path: '/delete', - component: DeleteTeam -}); - const projectsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/projects', @@ -350,13 +329,13 @@ export function getRootTree({ customScreens = [] }: getRootTreeOptions) { securityRoute, sessionsRoute.addChildren([revokeSessionRoute]), membersRoute, - teamsRoute.addChildren([addTeamRoute]), + teamsRoute, domainsRoute.addChildren([ addDomainRoute, verifyDomainRoute, deleteDomainRoute ]), - teamRoute.addChildren([deleteTeamRoute, inviteTeamMembersRoute]), + teamRoute, projectsRoute.addChildren([addProjectRoute]), projectPageRoute.addChildren([ deleteProjectRoute, diff --git a/web/sdk/react/components/organization/teams/add.tsx b/web/sdk/react/components/organization/teams/add.tsx deleted file mode 100644 index d8f99694a..000000000 --- a/web/sdk/react/components/organization/teams/add.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { - Button, - toast, - Image, - Text, - Flex, - Dialog, - InputField -} from '@raystack/apsara'; - -import { yupResolver } from '@hookform/resolvers/yup'; -import { useNavigate } from '@tanstack/react-router'; -import { useForm } from 'react-hook-form'; -import * as yup from 'yup'; -import cross from '~/react/assets/cross.svg'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import { useMutation } from '@connectrpc/connect-query'; -import { FrontierServiceQueries, CreateGroupRequestSchema } from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import styles from '../organization.module.css'; - -const teamSchema = yup - .object({ - title: yup.string().required(), - name: yup - .string() - .required('name is a required field') - .min(3, 'name is not valid, Min 3 characters allowed') - .max(50, 'name is not valid, Max 50 characters allowed') - .matches( - /^[a-zA-Z0-9_-]{3,50}$/, - "Only numbers, letters, '-', and '_' are allowed. Spaces are not allowed." - ) - }) - .required(); - -type FormData = yup.InferType; - -export const AddTeam = () => { - const { - handleSubmit, - formState: { errors, isSubmitting }, - register - } = useForm({ - resolver: yupResolver(teamSchema) - }); - const navigate = useNavigate({ from: '/members/modal' }); - const { activeOrganization: organization } = useFrontier(); - - const { mutateAsync: createTeam } = useMutation(FrontierServiceQueries.createGroup, { - onSuccess: () => { - toast.success('Team added'); - navigate({ to: '/teams' }); - }, - onError: (error) => { - toast.error('Something went wrong', { - description: error.message - }); - } - }); - - async function onSubmit(data: FormData) { - if (!organization?.id) return; - - const request = create(CreateGroupRequestSchema, { - orgId: organization.id, - body: { - title: data.title, - name: data.name - } - }); - - await createTeam(request); - } - - return ( - - - - - - Add Team - - cross navigate({ to: '/teams' })} - style={{ cursor: 'pointer' }} - data-test-id="frontier-sdk-add-team-close-btn" - /> - - -
- - - - - - - - - - - -
-
-
- ); -}; diff --git a/web/sdk/react/components/organization/teams/delete.tsx b/web/sdk/react/components/organization/teams/delete.tsx deleted file mode 100644 index 66ebe4bab..000000000 --- a/web/sdk/react/components/organization/teams/delete.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { - Button, - Checkbox, - Skeleton, - Image, - Text, - Flex, - Dialog, - toast, - InputField -} from '@raystack/apsara'; - -import { yupResolver } from '@hookform/resolvers/yup'; -import { useNavigate, useParams } from '@tanstack/react-router'; -import { useEffect, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import * as yup from 'yup'; -import cross from '~/react/assets/cross.svg'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import { useMutation, useQuery } from '@connectrpc/connect-query'; -import { FrontierServiceQueries, DeleteGroupRequestSchema, GetGroupRequestSchema } from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import styles from '../organization.module.css'; - -const teamSchema = yup - .object({ - title: yup.string() - }) - .required(); - -export const DeleteTeam = () => { - const { - watch, - handleSubmit, - setError, - formState: { errors, isSubmitting }, - register - } = useForm({ - resolver: yupResolver(teamSchema) - }); - let { teamId } = useParams({ from: `/teams/$teamId/delete` }); - const navigate = useNavigate(); - const [isAcknowledged, setIsAcknowledged] = useState(false); - - const { activeOrganization: organization } = useFrontier(); - - // Get team details using Connect RPC - const { data: teamData, isLoading: isTeamLoading, error: teamError } = useQuery( - FrontierServiceQueries.getGroup, - create(GetGroupRequestSchema, { id: teamId || '', orgId: organization?.id || '' }), - { enabled: !!organization?.id && !!teamId } - ); - - const team = teamData?.group; - - // Handle team error - useEffect(() => { - if (teamError) { - toast.error('Something went wrong', { - description: teamError.message - }); - } - }, [teamError]); - - // Delete team using Connect RPC - const deleteTeamMutation = useMutation(FrontierServiceQueries.deleteGroup, { - onSuccess: () => { - toast.success('team deleted'); - navigate({ to: '/teams' }); - }, - onError: (error) => { - toast.error('Something went wrong', { - description: error.message - }); - } - }); - - function onSubmit(data: { title?: string }) { - if (!organization?.id) return; - if (!teamId) return; - - if (data.title !== team?.title) - return setError('title', { message: 'Team title does not match' }); - - const request = create(DeleteGroupRequestSchema, { - id: teamId, - orgId: organization.id - }); - - deleteTeamMutation.mutate(request); - } - - const title = watch('title', ''); - return ( - - - - - - Verify team deletion - - cross - navigate({ - to: `/teams/$teamId`, - params: { - teamId - } - }) - } - style={{ cursor: 'pointer' }} - data-test-id="frontier-sdk-delete-team-close-btn" - /> - - -
- - - {isTeamLoading ? ( - <> - - - - - - - ) : ( - <> - - This action can not be undone. This will permanently delete - team {team?.title}. - - - - - - setIsAcknowledged(v === true)} - data-test-id="frontier-sdk-delete-team-checkbox" - /> - - I acknowledge and understand that all of the team data will be deleted - and want to proceed. - - - - - )} - - -
-
-
- ); -}; diff --git a/web/sdk/react/components/organization/teams/general/index.tsx b/web/sdk/react/components/organization/teams/general/index.tsx deleted file mode 100644 index e8d791c28..000000000 --- a/web/sdk/react/components/organization/teams/general/index.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import { - Separator, - Button, - toast, - Tooltip, - Skeleton, - Text, - Flex, - InputField -} from '@raystack/apsara'; - -import { yupResolver } from '@hookform/resolvers/yup'; -import { useNavigate, useParams } from '@tanstack/react-router'; -import { useEffect, useMemo } from 'react'; -import { useForm } from 'react-hook-form'; -import * as yup from 'yup'; -import { usePermissions } from '~/react/hooks/usePermissions'; -import { PERMISSIONS, shouldShowComponent } from '~/utils'; -import { AuthTooltipMessage } from '~/react/utils'; -import { useMutation } from '@connectrpc/connect-query'; -import { FrontierServiceQueries, UpdateGroupRequestSchema, type Group, type Organization } from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; - -const teamSchema = yup - .object({ - title: yup.string().required(), - name: yup.string().required() - }) - .required(); - -type FormData = yup.InferType; - -interface GeneralTeamProps { - team?: Group; - organization?: Organization; - isLoading?: boolean; -} - -export const General = ({ - organization, - team, - isLoading: isTeamLoading -}: GeneralTeamProps) => { - const { - reset, - handleSubmit, - formState: { errors, isSubmitting }, - register - } = useForm({ - resolver: yupResolver(teamSchema) - }); - - let { teamId } = useParams({ from: '/teams/$teamId' }); - - useEffect(() => { - reset(team); - }, [reset, team]); - - const resource = `app/group:${teamId}`; - const listOfPermissionsToCheck = useMemo( - () => [ - { - permission: PERMISSIONS.UpdatePermission, - resource - }, - { - permission: PERMISSIONS.DeletePermission, - resource - } - ], - [resource] - ); - - const { permissions, isFetching: isPermissionsFetching } = usePermissions( - listOfPermissionsToCheck, - !!teamId - ); - - const { canUpdateGroup, canDeleteGroup } = useMemo(() => { - return { - canUpdateGroup: shouldShowComponent( - permissions, - `${PERMISSIONS.UpdatePermission}::${resource}` - ), - canDeleteGroup: shouldShowComponent( - permissions, - `${PERMISSIONS.DeletePermission}::${resource}` - ) - }; - }, [permissions, resource]); - - const isLoading = isTeamLoading || isPermissionsFetching; - - const { mutateAsync: updateTeam } = useMutation(FrontierServiceQueries.updateGroup, { - onSuccess: () => { - toast.success('Team updated'); - }, - onError: (error) => { - toast.error('Something went wrong', { - description: error.message || 'Failed to update team' - }); - } - }); - - async function onSubmit(data: FormData) { - if (!organization?.id) return; - if (!teamId) return; - - const request = create(UpdateGroupRequestSchema, { - id: teamId, - orgId: organization.id, - body: { - title: data.title, - name: data.name - } - }); - - await updateTeam(request); - } - - return ( - -
- - {isLoading ? ( -
- - -
- ) : ( - - )} - {isLoading ? ( -
- - -
- ) : ( - - )} - - {isLoading ? ( - - ) : ( - - - - )} -
-
- - -
- ); -}; - -interface GeneralDeleteTeamProps extends GeneralTeamProps { - canDeleteGroup?: boolean; -} - -export const GeneralDeleteTeam = ({ - canDeleteGroup, - isLoading -}: GeneralDeleteTeamProps) => { - let { teamId } = useParams({ from: '/teams/$teamId' }); - const navigate = useNavigate({ from: '/teams/$teamId' }); - - return ( - - {isLoading ? ( - - ) : ( - - If you want to permanently delete this team and all of its data. - - )} - {isLoading ? ( - - ) : ( - - - - )} - - ); -}; diff --git a/web/sdk/react/components/organization/teams/index.tsx b/web/sdk/react/components/organization/teams/index.tsx index f4a9ec748..36aca38c9 100644 --- a/web/sdk/react/components/organization/teams/index.tsx +++ b/web/sdk/react/components/organization/teams/index.tsx @@ -1,235 +1,12 @@ -'use client'; - -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { - Tooltip, - Skeleton, - EmptyState, - Flex, - Button, - Select, - DataTable -} from '@raystack/apsara'; -import { Outlet, useNavigate, useRouterState } from '@tanstack/react-router'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; -import { useFrontier } from '~/react/contexts/FrontierContext'; - -import { useOrganizationTeams } from '~/react/hooks/useOrganizationTeams'; -import { usePermissions } from '~/react/hooks/usePermissions'; -import type { Group } from '@raystack/proton/frontier'; -import { PERMISSIONS, shouldShowComponent } from '~/utils'; -import { getColumns } from './teams.columns'; -import { AuthTooltipMessage } from '~/react/utils'; -import { PageHeader } from '~/react/components/common/page-header'; -import sharedStyles from '../styles.module.css'; -import styles from './teams.module.css'; -import { useTerminology } from '~/react/hooks/useTerminology'; - -const teamsSelectOptions = [ - { value: 'my-teams', label: 'My Teams' }, - { value: 'all-teams', label: 'All Teams' } -]; - -interface WorkspaceTeamProps { - teams: Group[]; - isLoading?: boolean; - canCreateGroup?: boolean; - userAccessOnTeam: Record; - canListOrgGroups?: boolean; - onOrgTeamsFilterChange: (value: string) => void; -} +import { useNavigate } from '@tanstack/react-router'; +import { TeamsListPage } from '~/react/views/teams'; export default function WorkspaceTeams() { - const [showOrgTeams, setShowOrgTeams] = useState(false); - const t = useTerminology(); - const routerState = useRouterState(); - - const isListRoute = useMemo(() => { - return routerState.location.pathname === '/teams'; - }, [routerState.location.pathname]); - - const { - isFetching: isTeamsLoading, - teams, - userAccessOnTeam, - refetch - } = useOrganizationTeams({ - withPermissions: ['update', 'delete'], - showOrgTeams, - withMemberCount: true - }); - const { activeOrganization: organization } = useFrontier(); - - const resource = `app/organization:${organization?.id}`; - const listOfPermissionsToCheck = useMemo( - () => [ - { - permission: PERMISSIONS.GroupCreatePermission, - resource - }, - { - permission: PERMISSIONS.GroupListPermission, - resource - } - ], - [resource] - ); - - const { permissions, isFetching: isPermissionsFetching } = usePermissions( - listOfPermissionsToCheck, - !!organization?.id - ); - - const { canCreateGroup, canListOrgGroups } = useMemo(() => { - return { - canCreateGroup: shouldShowComponent( - permissions, - `${PERMISSIONS.GroupCreatePermission}::${resource}` - ), - canListOrgGroups: shouldShowComponent( - permissions, - `${PERMISSIONS.GroupCreatePermission}::${resource}` - ) - }; - }, [permissions, resource]); - - const onOrgTeamsFilterChange = useCallback((value: string) => { - if (value === 'all-teams') { - setShowOrgTeams(true); - } else { - setShowOrgTeams(false); - } - }, []); - - useEffect(() => { - if (isListRoute) { - refetch(); - } - }, [isListRoute, refetch, routerState.location.state.key]); - - const isLoading = isPermissionsFetching || isTeamsLoading; - - return ( - - - - - - - - - - - - ); -} - -const TeamsTable = ({ - teams, - isLoading, - canCreateGroup, - userAccessOnTeam, - canListOrgGroups, - onOrgTeamsFilterChange -}: WorkspaceTeamProps) => { const navigate = useNavigate({ from: '/teams' }); - const columns = useMemo( - () => getColumns(userAccessOnTeam), - [userAccessOnTeam] - ); - - return ( - - - - - {isLoading ? ( - - ) : ( - - )} - {canListOrgGroups ? ( - - ) : null} - - {isLoading ? ( - - ) : ( - - - - )} - - - - - ); -}; - -const noDataChildren = ( - } - heading="No teams found" - subHeading="Get started by creating your first team." - /> -); + return + navigate({ to: '/teams/$teamId', params: { teamId } }) + } + />; +} \ No newline at end of file diff --git a/web/sdk/react/components/organization/teams/members/index.tsx b/web/sdk/react/components/organization/teams/members/index.tsx deleted file mode 100644 index 2adb2c0a1..000000000 --- a/web/sdk/react/components/organization/teams/members/index.tsx +++ /dev/null @@ -1,307 +0,0 @@ -import type React from 'react'; -import { - Button, - EmptyState, - Tooltip, - toast, - Separator, - Avatar, - Skeleton, - Text, - Flex, - DataTable, - Popover, - Search -} from '@raystack/apsara'; -import { Link, useParams } from '@tanstack/react-router'; -import { useCallback, useEffect, useMemo, useState } from 'react'; - -import { ExclamationTriangleIcon, PaperPlaneIcon } from '@radix-ui/react-icons'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import { usePermissions } from '~/react/hooks/usePermissions'; -import { AuthTooltipMessage } from '~/react/utils'; -import { - PERMISSIONS, - filterUsersfromUsers, - getInitials, - shouldShowComponent -} from '~/utils'; -import { getColumns } from './member.columns'; - -import { useQuery, useMutation } from '@connectrpc/connect-query'; -import { FrontierServiceQueries, AddGroupUsersRequestSchema, ListOrganizationUsersRequestSchema, type User, type Role } from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import styles from './members.module.css'; - -export type MembersProps = { - members: User[]; - roles: Role[]; - organizationId: string; - memberRoles?: Record; - isLoading?: boolean; - refetchMembers: () => void; -}; - -export const Members = ({ - members, - roles = [], - organizationId, - memberRoles = {}, - isLoading: isMemberLoading, - refetchMembers -}: MembersProps) => { - const { teamId } = useParams({ from: '/teams/$teamId' }); - - const resource = `app/group:${teamId}`; - const listOfPermissionsToCheck = useMemo( - () => [ - { - permission: PERMISSIONS.UpdatePermission, - resource - } - ], - [resource] - ); - - const { permissions, isFetching: isPermissionsFetching } = usePermissions( - listOfPermissionsToCheck, - !!teamId - ); - const { canUpdateGroup } = useMemo(() => { - return { - canUpdateGroup: shouldShowComponent( - permissions, - `${PERMISSIONS.UpdatePermission}::${resource}` - ) - }; - }, [permissions, resource]); - - const isLoading = isPermissionsFetching || isMemberLoading; - - const columns = useMemo( - () => - getColumns({ - roles, - organizationId, - canUpdateGroup, - memberRoles, - refetchMembers - }), - [roles, organizationId, canUpdateGroup, memberRoles, refetchMembers] - ); - - return ( - - - - - - - - {isLoading ? ( - - ) : ( - - - - )} - - - - - - ); -}; - -interface AddMemberDropdownProps { - canUpdateGroup: boolean; - refetchMembers: () => void; - members: User[]; -} - -const AddMemberDropdown = ({ - canUpdateGroup, - refetchMembers, - members -}: AddMemberDropdownProps) => { - let { teamId } = useParams({ from: '/teams/$teamId' }); - const [query, setQuery] = useState(''); - - const { activeOrganization: organization } = useFrontier(); - - // Get organization members using Connect RPC - const { data: orgMembersData, isLoading: isOrgMembersLoading, error: orgMembersError } = useQuery( - FrontierServiceQueries.listOrganizationUsers, - create(ListOrganizationUsersRequestSchema, { id: organization?.id || '' }), - { enabled: !!organization?.id && canUpdateGroup } - ); - - // Handle organization members error - useEffect(() => { - if (orgMembersError) { - toast.error('Something went wrong', { - description: orgMembersError.message - }); - } - }, [orgMembersError]); - - - const invitableUser = useMemo( - () => filterUsersfromUsers(orgMembersData?.users || [], members) || [], - [orgMembersData?.users, members] - ); - - const isUserLoading = isOrgMembersLoading; - - const topUsers = useMemo( - () => - invitableUser - .filter(user => - query - ? user.title && - user.title.toLowerCase().includes(query.toLowerCase()) - : true - ) - .slice(0, 7), - [invitableUser, query] - ); - - function onTextChange(e: React.ChangeEvent) { - setQuery(e.target.value); - } - - // Add group user using Connect RPC - const addGroupUserMutation = useMutation(FrontierServiceQueries.addGroupUsers, { - onSuccess: () => { - toast.success('member added'); - if (refetchMembers) { - refetchMembers(); - } - }, - onError: (error) => { - toast.error('Something went wrong', { - description: error.message - }); - } - }); - - const addMember = useCallback( - (userId: string) => { - if (!userId || !organization?.id) return; - - const request = create(AddGroupUsersRequestSchema, { - id: teamId as string, - orgId: organization.id, - userIds: [userId] - }); - - addGroupUserMutation.mutate(request); - }, - [organization?.id, teamId, addGroupUserMutation] - ); - - return ( - - - - - - setQuery('')} - /> - - - {isUserLoading ? ( - - ) : topUsers.length ? ( -
- {topUsers.map(user => { - const initials = getInitials(user?.title || user.email); - return ( - addMember(user?.id || '')} - className={styles.inviteDropdownItem} - data-test-id={`frontier-sdk-add-member-${user.id}`} - > - - {user?.title || user?.email} - - ); - })} -
- ) : ( - - No Users found - - )} - -
- - {' '} - Invite People - -
-
-
- ); -}; - -const noDataChildren = ( - } - heading="No members found" - subHeading="Get started by adding your first team member." - /> -); diff --git a/web/sdk/react/components/organization/teams/members/invite.tsx b/web/sdk/react/components/organization/teams/members/invite.tsx deleted file mode 100644 index 52e2ac60b..000000000 --- a/web/sdk/react/components/organization/teams/members/invite.tsx +++ /dev/null @@ -1,314 +0,0 @@ -import { yupResolver } from '@hookform/resolvers/yup'; -import { - Button, - toast, - Skeleton, - Image, - Text, - Flex, - Dialog, - Select, - Label -} from '@raystack/apsara'; -import { useNavigate, useParams } from '@tanstack/react-router'; -import { useCallback, useEffect, useMemo } from 'react'; -import { Controller, useForm } from 'react-hook-form'; -import * as yup from 'yup'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import { PERMISSIONS, filterUsersfromUsers } from '~/utils'; -import cross from '~/react/assets/cross.svg'; -import { handleSelectValueChange } from '~/react/utils'; -import { useQuery, useMutation } from '@connectrpc/connect-query'; -import { FrontierServiceQueries, - CreatePolicyRequestSchema, - AddGroupUsersRequestSchema, - ListOrganizationUsersRequestSchema, - ListGroupUsersRequestSchema, - ListOrganizationRolesRequestSchema, - ListRolesRequestSchema } from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import styles from '../../organization.module.css'; - -const inviteSchema = yup.object({ - userId: yup.string().required('Member is required'), - role: yup.string().required('Role is required') -}); - -type InviteSchemaType = yup.InferType; - -export const InviteTeamMembers = () => { - let { teamId } = useParams({ from: '/teams/$teamId/invite' }); - const navigate = useNavigate({ from: '/teams/$teamId/invite' }); - const { activeOrganization: organization } = useFrontier(); - - const { - control, - handleSubmit, - formState: { errors, isSubmitting } - } = useForm({ - resolver: yupResolver(inviteSchema) - }); - - // Get organization members using Connect RPC - const { data: orgMembersData, isLoading: isOrgMembersLoading, error: orgMembersError } = useQuery( - FrontierServiceQueries.listOrganizationUsers, - create(ListOrganizationUsersRequestSchema, { id: organization?.id || '' }), - { enabled: !!organization?.id } - ); - - - // Handle organization members error - useEffect(() => { - if (orgMembersError) { - toast.error('Something went wrong', { - description: orgMembersError.message - }); - } - }, [orgMembersError]); - - // Get team members using Connect RPC - const { data: teamMembersData, isLoading: isTeamMembersLoading, error: teamMembersError } = useQuery( - FrontierServiceQueries.listGroupUsers, - create(ListGroupUsersRequestSchema, { id: teamId || '', orgId: organization?.id || '', withRoles: true }), - { enabled: !!organization?.id && !!teamId } - ); - - - // Handle team members error - useEffect(() => { - if (teamMembersError) { - toast.error('Something went wrong', { - description: teamMembersError.message - }); - } - }, [teamMembersError]); - - // Get organization roles using Connect RPC - const { data: orgRolesData, isLoading: isOrgRolesLoading, error: orgRolesError } = useQuery( - FrontierServiceQueries.listOrganizationRoles, - create(ListOrganizationRolesRequestSchema, { orgId: organization?.id || '', scopes: [PERMISSIONS.GroupNamespace] }), - { enabled: !!organization?.id } - ); - - // Get roles using Connect RPC - const { data: rolesData, isLoading: isRolesLoading, error: rolesError } = useQuery( - FrontierServiceQueries.listRoles, - create(ListRolesRequestSchema, { scopes: [PERMISSIONS.GroupNamespace] }), - { enabled: !!organization?.id } - ); - - const roles = useMemo(() => { - const orgRoles = orgRolesData?.roles || []; - const systemRoles = rolesData?.roles || []; - return [...systemRoles, ...orgRoles]; - }, [orgRolesData?.roles, rolesData?.roles]); - - const isRolesLoadingCombined = isOrgRolesLoading || isRolesLoading; - - // Handle roles errors - useEffect(() => { - if (orgRolesError) { - toast.error('Something went wrong', { - description: orgRolesError.message - }); - } - }, [orgRolesError]); - - useEffect(() => { - if (rolesError) { - toast.error('Something went wrong', { - description: rolesError.message - }); - } - }, [rolesError]); - - // Create policy using Connect RPC - const createPolicyMutation = useMutation(FrontierServiceQueries.createPolicy, { - onError: (error) => { - toast.error('Something went wrong', { - description: error.message - }); - } - }); - - const addGroupTeamPolicy = useCallback( - async (roleId: string, userId: string) => { - const role = roles.find(r => r.id === roleId); - if (role?.name && role.name !== PERMISSIONS.RoleGroupMember) { - const resource = `${PERMISSIONS.GroupPrincipal}:${teamId}`; - const principal = `${PERMISSIONS.UserPrincipal}:${userId}`; - - const request = create(CreatePolicyRequestSchema, { - body: { - roleId: roleId, - resource, - principal - } - }); - - await createPolicyMutation.mutateAsync(request); - } - }, - [roles, teamId, createPolicyMutation] - ); - - // Add group users using Connect RPC - const addGroupUsersMutation = useMutation(FrontierServiceQueries.addGroupUsers, { - onError: (error) => { - toast.error('Something went wrong', { - description: error.message - }); - } - }); - - async function onSubmit({ role, userId }: InviteSchemaType) { - if (!userId || !role || !organization?.id) return; - - const request = create(AddGroupUsersRequestSchema, { - id: teamId as string, - orgId: organization.id, - userIds: [userId] - }); - - await addGroupUsersMutation.mutateAsync(request); - await addGroupTeamPolicy(role, userId); - toast.success('member added'); - navigate({ - to: '/teams/$teamId', - params: { teamId } - }); - - } - - const invitableUser = useMemo( - () => filterUsersfromUsers(orgMembersData?.users || [], teamMembersData?.users || []) || [], - [orgMembersData?.users, teamMembersData?.users] - ); - - const isUserLoading = isOrgMembersLoading || isTeamMembersLoading; - - return ( - - - - - - Add Member - - - cross - navigate({ to: '/teams/$teamId', params: { teamId } }) - } - data-test-id="frontier-sdk-invite-team-members-close-btn" - /> - - - -
- - - - {isUserLoading ? ( - - ) : ( - ( - - )} - control={control} - name="userId" - /> - )} - - {errors.userId && String(errors.userId?.message)} - - - - - {isRolesLoadingCombined ? ( - - ) : ( - ( - - )} - control={control} - name="role" - /> - )} - - {errors.role && String(errors.role?.message)} - - - - - - -
-
-
-
- ); -}; diff --git a/web/sdk/react/components/organization/teams/team.tsx b/web/sdk/react/components/organization/teams/team.tsx index 430bba9c1..2e6733f43 100644 --- a/web/sdk/react/components/organization/teams/team.tsx +++ b/web/sdk/react/components/organization/teams/team.tsx @@ -1,139 +1,16 @@ -import { useEffect, useMemo } from 'react'; -import { Tabs, Image, Flex, toast } from '@raystack/apsara'; -import { - Outlet, - useNavigate, - useParams, - useRouterState -} from '@tanstack/react-router'; -import backIcon from '~/react/assets/chevron-left.svg'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import { PERMISSIONS } from '~/utils'; -import { General } from './general'; -import { Members } from './members'; -import { useQuery } from '@connectrpc/connect-query'; -import { FrontierServiceQueries, GetGroupRequestSchema, ListGroupUsersRequestSchema, ListRolesRequestSchema, Organization, type Role } from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import { PageHeader } from '~/react/components/common/page-header'; -import sharedStyles from '../styles.module.css'; -import styles from './teams.module.css'; +'use client'; -export const TeamPage = () => { - let { teamId } = useParams({ from: '/teams/$teamId' }); - - const { activeOrganization: organization } = useFrontier(); - let navigate = useNavigate({ from: '/teams/$teamId' }); - const routerState = useRouterState(); - - const isDeleteRoute = useMemo(() => { - return routerState.matches.some( - route => route.routeId === '/teams/$teamId/delete' - ); - }, [routerState.matches]); - - // Get team details using Connect RPC - const { data: teamData, isLoading: isTeamLoading, error: teamError } = useQuery( - FrontierServiceQueries.getGroup, - create(GetGroupRequestSchema, { id: teamId || '', orgId: organization?.id || '', withMembers: true }), - { enabled: !!organization?.id && !!teamId && !isDeleteRoute } - ); - - const team = teamData?.group; +import { useNavigate, useParams } from '@tanstack/react-router'; +import { TeamDetailPage } from '~/react/views/teams'; - // Handle team error - useEffect(() => { - if (teamError) { - toast.error('Something went wrong', { - description: teamError.message - }); - } - }, [teamError]); - - // Get team members using Connect RPC - const { data: membersData, isLoading: isMembersLoading, error: membersError, refetch: refetchMembers } = useQuery( - FrontierServiceQueries.listGroupUsers, - create(ListGroupUsersRequestSchema, { id: teamId || '', orgId: organization?.id || '', withRoles: true }), - { enabled: !!organization?.id && !!teamId && !isDeleteRoute } - ); - - const members = membersData?.users || []; - const memberRoles = useMemo(() => { - if (!membersData?.rolePairs) return {}; - return membersData.rolePairs.reduce((previous: Record, mr: { userId: string; roles: Role[] }) => { - return { ...previous, [mr.userId]: mr.roles }; - }, {}); - }, [membersData?.rolePairs]); - - // Handle members error - useEffect(() => { - if (membersError) { - toast.error('Something went wrong', { - description: membersError.message - }); - } - }, [membersError]); - - // Get team roles using Connect RPC - const { data: rolesData, error: rolesError } = useQuery( - FrontierServiceQueries.listRoles, - create(ListRolesRequestSchema, { state: 'enabled', scopes: [PERMISSIONS.GroupNamespace] }), - { enabled: !!organization?.id && !!teamId && !isDeleteRoute } - ); - - const roles = rolesData?.roles || []; - - // Handle roles error - useEffect(() => { - if (rolesError) { - toast.error('Something went wrong', { - description: rolesError.message - }); - } - }, [rolesError]); +export const TeamPage = () => { + const { teamId } = useParams({ from: '/teams/$teamId' }); + const navigate = useNavigate({ from: '/teams/$teamId' }); return ( - - - - - back-icon navigate({ to: '/teams' })} - data-test-id="frontier-sdk-team-back-btn" - /> - - - - - - General - Members - - - - - - - - - - - + navigate({ to: '/teams' })} + /> ); }; diff --git a/web/sdk/react/components/organization/teams/teams.columns.tsx b/web/sdk/react/components/organization/teams/teams.columns.tsx deleted file mode 100644 index 9c34657fb..000000000 --- a/web/sdk/react/components/organization/teams/teams.columns.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { - DotsHorizontalIcon, - Pencil1Icon, - TrashIcon -} from '@radix-ui/react-icons'; -import { Text, DropdownMenu, DataTableColumnDef } from '@raystack/apsara'; -import { Link } from '@tanstack/react-router'; -import type { Group } from '@raystack/proton/frontier'; -import styles from '../organization.module.css'; - -export const getColumns: ( - userAccessOnTeam: Record -) => DataTableColumnDef[] = userAccessOnTeam => [ - { - header: 'Title', - accessorKey: 'title', - cell: ({ row, getValue }) => ( - - {getValue() as string} - - ) - }, - { - header: 'Members', - accessorKey: 'members_count', - cell: ({ row, getValue }) => { - const value = getValue() as string; - return value ? {value} members : null; - } - }, - { - header: '', - accessorKey: 'id', - enableSorting: false, - cell: ({ row, getValue }) => ( - - ) - } -]; - -const TeamActions = ({ - team, - userAccessOnTeam -}: { - team: Group; - userAccessOnTeam: Record; -}) => { - const canUpdateTeam = (userAccessOnTeam[team.id!] ?? []).includes('update'); - const canDeleteTeam = (userAccessOnTeam[team.id!] ?? []).includes('delete'); - const canDoActions = canUpdateTeam || canDeleteTeam; - - return canDoActions ? ( - - - - - {/* @ts-ignore */} - - {canUpdateTeam ? ( - - - Rename - - - ) : null} - {canDeleteTeam ? ( - - - Delete team - - - ) : null} - - - ) : null; -}; diff --git a/web/sdk/react/views/teams/details/delete-team-dialog.tsx b/web/sdk/react/views/teams/details/delete-team-dialog.tsx new file mode 100644 index 000000000..fd8844024 --- /dev/null +++ b/web/sdk/react/views/teams/details/delete-team-dialog.tsx @@ -0,0 +1,205 @@ +'use client'; + +import { + Button, + Checkbox, + Skeleton, + Image, + Text, + Flex, + Dialog, + toast, + InputField +} from '@raystack/apsara'; + +import { yupResolver } from '@hookform/resolvers/yup'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import * as yup from 'yup'; +import cross from '~/react/assets/cross.svg'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { useMutation, useQuery } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + DeleteGroupRequestSchema, + GetGroupRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import orgStyles from '../../../components/organization/organization.module.css'; + +const teamSchema = yup + .object({ + title: yup.string() + }) + .required(); + +export interface DeleteTeamDialogProps { + open: boolean; + onOpenChange: (value: boolean) => void; + teamId: string; + onDeleteSuccess?: () => void; +} + +export const DeleteTeamDialog = ({ + open, + onOpenChange, + teamId, + onDeleteSuccess +}: DeleteTeamDialogProps) => { + const { + watch, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + register, + reset + } = useForm({ + resolver: yupResolver(teamSchema) + }); + const [isAcknowledged, setIsAcknowledged] = useState(false); + + const { activeOrganization: organization } = useFrontier(); + + // Reset form when dialog closes + const handleOpenChange = (value: boolean) => { + if (!value) { + reset(); + setIsAcknowledged(false); + } + onOpenChange?.(value); + }; + + // Get team details using Connect RPC + const { + data: teamData, + isLoading: isTeamLoading, + error: teamError + } = useQuery( + FrontierServiceQueries.getGroup, + create(GetGroupRequestSchema, { + id: teamId || '', + orgId: organization?.id || '' + }), + { enabled: !!organization?.id && !!teamId && open } + ); + + const team = teamData?.group; + + // Handle team error + useEffect(() => { + if (teamError) { + toast.error('Something went wrong', { + description: teamError.message + }); + } + }, [teamError]); + + // Delete team using Connect RPC + const deleteTeamMutation = useMutation(FrontierServiceQueries.deleteGroup, { + onSuccess: () => { + toast.success('team deleted'); + handleOpenChange(false); + onDeleteSuccess?.(); + }, + onError: error => { + toast.error('Something went wrong', { + description: error.message + }); + } + }); + + function onSubmit(data: { title?: string }) { + if (!organization?.id) return; + if (!teamId) return; + + if (data.title !== team?.title) + return setError('title', { message: 'Team title does not match' }); + + const request = create(DeleteGroupRequestSchema, { + id: teamId, + orgId: organization.id + }); + + deleteTeamMutation.mutate(request); + } + + const title = watch('title', ''); + return ( + + + + + + Verify team deletion + + cross handleOpenChange(false)} + style={{ cursor: 'pointer' }} + data-test-id="frontier-sdk-delete-team-close-btn" + /> + + +
+ + + {isTeamLoading ? ( + <> + + + + + + + ) : ( + <> + + This action can not be undone. This will permanently delete + team {team?.title}. + + + + + + setIsAcknowledged(v === true)} + data-test-id="frontier-sdk-delete-team-checkbox" + /> + + I acknowledge and understand that all of the team data + will be deleted and want to proceed. + + + + + )} + + +
+
+
+ ); +}; + diff --git a/web/sdk/react/views/teams/details/index.ts b/web/sdk/react/views/teams/details/index.ts new file mode 100644 index 000000000..e51e086fd --- /dev/null +++ b/web/sdk/react/views/teams/details/index.ts @@ -0,0 +1,9 @@ +export { TeamDetailPage } from './team-detail-page'; +export type { TeamDetailPageProps } from './team-detail-page'; + +export { DeleteTeamDialog } from './delete-team-dialog'; +export type { DeleteTeamDialogProps } from './delete-team-dialog'; + +export { InviteTeamMemberDialog } from './invite-team-member-dialog'; +export type { InviteTeamMemberDialogProps } from './invite-team-member-dialog'; + diff --git a/web/sdk/react/views/teams/details/invite-team-member-dialog.tsx b/web/sdk/react/views/teams/details/invite-team-member-dialog.tsx new file mode 100644 index 000000000..d682382fa --- /dev/null +++ b/web/sdk/react/views/teams/details/invite-team-member-dialog.tsx @@ -0,0 +1,368 @@ +'use client'; + +import { yupResolver } from '@hookform/resolvers/yup'; +import { + Button, + toast, + Skeleton, + Image, + Text, + Flex, + Dialog, + Select, + Label +} from '@raystack/apsara'; +import { useCallback, useEffect, useMemo } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import * as yup from 'yup'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { PERMISSIONS, filterUsersfromUsers } from '~/utils'; +import cross from '~/react/assets/cross.svg'; +import { handleSelectValueChange } from '~/react/utils'; +import { useQuery, useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + CreatePolicyRequestSchema, + AddGroupUsersRequestSchema, + ListOrganizationUsersRequestSchema, + ListGroupUsersRequestSchema, + ListOrganizationRolesRequestSchema, + ListRolesRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import orgStyles from '../../../components/organization/organization.module.css'; + +const inviteSchema = yup.object({ + userId: yup.string().required('Member is required'), + role: yup.string().required('Role is required') +}); + +type InviteSchemaType = yup.InferType; + +export interface InviteTeamMemberDialogProps { + open: boolean; + onOpenChange: (value: boolean) => void; + teamId: string; +} + +export const InviteTeamMemberDialog = ({ + open, + onOpenChange, + teamId +}: InviteTeamMemberDialogProps) => { + const { activeOrganization: organization } = useFrontier(); + + const { + control, + handleSubmit, + formState: { errors, isSubmitting }, + reset + } = useForm({ + resolver: yupResolver(inviteSchema) + }); + + // Reset form when dialog closes + const handleOpenChange = (value: boolean) => { + if (!value) { + reset?.(); + } + onOpenChange?.(value); + }; + + // Get organization members using Connect RPC + const { + data: orgMembersData, + isLoading: isOrgMembersLoading, + error: orgMembersError + } = useQuery( + FrontierServiceQueries.listOrganizationUsers, + create(ListOrganizationUsersRequestSchema, { + id: organization?.id || '' + }), + { enabled: !!organization?.id && open } + ); + + // Handle organization members error + useEffect(() => { + if (orgMembersError) { + toast.error('Something went wrong', { + description: orgMembersError.message + }); + } + }, [orgMembersError]); + + // Get team members using Connect RPC + const { + data: teamMembersData, + isLoading: isTeamMembersLoading, + error: teamMembersError + } = useQuery( + FrontierServiceQueries.listGroupUsers, + create(ListGroupUsersRequestSchema, { + id: teamId || '', + orgId: organization?.id || '', + withRoles: true + }), + { enabled: !!organization?.id && !!teamId && open } + ); + + // Handle team members error + useEffect(() => { + if (teamMembersError) { + toast.error('Something went wrong', { + description: teamMembersError.message + }); + } + }, [teamMembersError]); + + // Get organization roles using Connect RPC + const { + data: orgRolesData, + isLoading: isOrgRolesLoading, + error: orgRolesError + } = useQuery( + FrontierServiceQueries.listOrganizationRoles, + create(ListOrganizationRolesRequestSchema, { + orgId: organization?.id || '', + scopes: [PERMISSIONS.GroupNamespace] + }), + { enabled: !!organization?.id && open } + ); + + // Get roles using Connect RPC + const { + data: rolesData, + isLoading: isRolesLoading, + error: rolesError + } = useQuery( + FrontierServiceQueries.listRoles, + create(ListRolesRequestSchema, { + scopes: [PERMISSIONS.GroupNamespace] + }), + { enabled: !!organization?.id && open } + ); + + const roles = useMemo(() => { + const orgRoles = orgRolesData?.roles || []; + const systemRoles = rolesData?.roles || []; + return [...systemRoles, ...orgRoles]; + }, [orgRolesData?.roles, rolesData?.roles]); + + const isRolesLoadingCombined = isOrgRolesLoading || isRolesLoading; + + // Handle roles errors + useEffect(() => { + if (orgRolesError) { + toast.error('Something went wrong', { + description: orgRolesError.message + }); + } + }, [orgRolesError]); + + useEffect(() => { + if (rolesError) { + toast.error('Something went wrong', { + description: rolesError.message + }); + } + }, [rolesError]); + + // Create policy using Connect RPC + const createPolicyMutation = useMutation( + FrontierServiceQueries.createPolicy, + { + onError: error => { + toast.error('Something went wrong', { + description: error.message + }); + } + } + ); + + const addGroupTeamPolicy = useCallback( + async (roleId: string, userId: string) => { + const role = roles.find(r => r.id === roleId); + if (role?.name && role.name !== PERMISSIONS.RoleGroupMember) { + const resource = `${PERMISSIONS.GroupPrincipal}:${teamId}`; + const principal = `${PERMISSIONS.UserPrincipal}:${userId}`; + + const request = create(CreatePolicyRequestSchema, { + body: { + roleId: roleId, + resource, + principal + } + }); + + await createPolicyMutation.mutateAsync(request); + } + }, + [roles, teamId, createPolicyMutation] + ); + + // Add group users using Connect RPC + const addGroupUsersMutation = useMutation( + FrontierServiceQueries.addGroupUsers, + { + onError: error => { + toast.error('Something went wrong', { + description: error.message + }); + } + } + ); + + async function onSubmit({ role, userId }: InviteSchemaType) { + if (!userId || !role || !organization?.id) return; + + const request = create(AddGroupUsersRequestSchema, { + id: teamId as string, + orgId: organization.id, + userIds: [userId] + }); + + await addGroupUsersMutation.mutateAsync(request); + await addGroupTeamPolicy(role, userId); + toast.success('member added'); + handleOpenChange(false); + } + + const invitableUser = useMemo( + () => + filterUsersfromUsers( + orgMembersData?.users || [], + teamMembersData?.users || [] + ) || [], + [orgMembersData?.users, teamMembersData?.users] + ); + + const isUserLoading = isOrgMembersLoading || isTeamMembersLoading; + + return ( + + + + + + Add Member + + + cross handleOpenChange(false)} + data-test-id="frontier-sdk-invite-team-members-close-btn" + /> + + + +
+ + + + {isUserLoading ? ( + + ) : ( + ( + + )} + control={control} + name="userId" + /> + )} + + {errors.userId && String(errors.userId?.message)} + + + + + {isRolesLoadingCombined ? ( + + ) : ( + ( + + )} + control={control} + name="role" + /> + )} + + {errors.role && String(errors.role?.message)} + + + + + + +
+
+
+
+ ); +}; + diff --git a/web/sdk/react/views/teams/details/team-detail-page.tsx b/web/sdk/react/views/teams/details/team-detail-page.tsx new file mode 100644 index 000000000..ab823f42f --- /dev/null +++ b/web/sdk/react/views/teams/details/team-detail-page.tsx @@ -0,0 +1,197 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Tabs, Image, Flex, toast } from '@raystack/apsara'; +import backIcon from '~/react/assets/chevron-left.svg'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { PERMISSIONS } from '~/utils'; +import { TeamGeneral } from './team-general'; +import { TeamMembers } from './team-members'; +import { DeleteTeamDialog } from './delete-team-dialog'; +import { InviteTeamMemberDialog } from './invite-team-member-dialog'; +import { useQuery } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + GetGroupRequestSchema, + ListGroupUsersRequestSchema, + ListRolesRequestSchema, + Organization, + type Role +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import { PageHeader } from '~/react/components/common/page-header'; +import sharedStyles from '../../../components/organization/styles.module.css'; +import styles from './team-detail.module.css'; + +export interface TeamDetailPageProps { + teamId: string; + onBack?: () => void; +} + +export const TeamDetailPage = ({ teamId, onBack }: TeamDetailPageProps) => { + const { activeOrganization: organization } = useFrontier(); + + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showInviteDialog, setShowInviteDialog] = useState(false); + + // Get team details using Connect RPC + const { + data: teamData, + isLoading: isTeamLoading, + error: teamError + } = useQuery( + FrontierServiceQueries.getGroup, + create(GetGroupRequestSchema, { + id: teamId || '', + orgId: organization?.id || '', + withMembers: true + }), + { enabled: !!organization?.id && !!teamId } + ); + + const team = teamData?.group; + + // Handle team error + useEffect(() => { + if (teamError) { + toast.error('Something went wrong', { + description: teamError.message + }); + } + }, [teamError]); + + // Get team members using Connect RPC + const { + data: membersData, + isLoading: isMembersLoading, + error: membersError, + refetch: refetchMembers + } = useQuery( + FrontierServiceQueries.listGroupUsers, + create(ListGroupUsersRequestSchema, { + id: teamId || '', + orgId: organization?.id || '', + withRoles: true + }), + { enabled: !!organization?.id && !!teamId } + ); + + const members = membersData?.users || []; + const memberRoles = useMemo(() => { + if (!membersData?.rolePairs) return {}; + return membersData.rolePairs.reduce( + ( + previous: Record, + mr: { userId: string; roles: Role[] } + ) => { + return { ...previous, [mr.userId]: mr.roles }; + }, + {} + ); + }, [membersData?.rolePairs]); + + // Handle members error + useEffect(() => { + if (membersError) { + toast.error('Something went wrong', { + description: membersError.message + }); + } + }, [membersError]); + + // Get team roles using Connect RPC + const { data: rolesData, error: rolesError } = useQuery( + FrontierServiceQueries.listRoles, + create(ListRolesRequestSchema, { + state: 'enabled', + scopes: [PERMISSIONS.GroupNamespace] + }), + { enabled: !!organization?.id && !!teamId } + ); + + const roles = rolesData?.roles || []; + + // Handle roles error + useEffect(() => { + if (rolesError) { + toast.error('Something went wrong', { + description: rolesError.message + }); + } + }, [rolesError]); + + const handleDeleteOpenChange = (value: boolean) => { + setShowDeleteDialog(value); + }; + + const handleInviteOpenChange = (value: boolean) => { + setShowInviteDialog(value); + if (!value) refetchMembers(); + }; + + return ( + + + + + back-icon + + + + + + General + Members + + + setShowDeleteDialog(true)} + /> + + + setShowInviteDialog(true)} + /> + + + + + + + ); +}; + diff --git a/web/sdk/react/views/teams/details/team-detail.module.css b/web/sdk/react/views/teams/details/team-detail.module.css new file mode 100644 index 000000000..b6f5313ae --- /dev/null +++ b/web/sdk/react/views/teams/details/team-detail.module.css @@ -0,0 +1,8 @@ +.tabContent { + height: 100%; +} + +.container { + box-sizing: border-box; +} + diff --git a/web/sdk/react/views/teams/details/team-general.tsx b/web/sdk/react/views/teams/details/team-general.tsx new file mode 100644 index 000000000..58b8688fc --- /dev/null +++ b/web/sdk/react/views/teams/details/team-general.tsx @@ -0,0 +1,212 @@ +'use client'; + +import { + Separator, + Button, + toast, + Tooltip, + Skeleton, + Text, + Flex, + InputField +} from '@raystack/apsara'; + +import { yupResolver } from '@hookform/resolvers/yup'; +import { useEffect, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import * as yup from 'yup'; +import { usePermissions } from '~/react/hooks/usePermissions'; +import { PERMISSIONS, shouldShowComponent } from '~/utils'; +import { AuthTooltipMessage } from '~/react/utils'; +import { useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + UpdateGroupRequestSchema, + type Group, + type Organization +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; + +const teamSchema = yup + .object({ + title: yup.string().required(), + name: yup.string().required() + }) + .required(); + +type FormData = yup.InferType; + +interface TeamGeneralProps { + team?: Group; + organization?: Organization; + teamId: string; + isLoading?: boolean; + onDeleteClick?: () => void; +} + +export const TeamGeneral = ({ + organization, + team, + teamId, + isLoading: isTeamLoading, + onDeleteClick +}: TeamGeneralProps) => { + const { + reset, + handleSubmit, + formState: { errors, isSubmitting }, + register + } = useForm({ + resolver: yupResolver(teamSchema) + }); + + useEffect(() => { + reset(team); + }, [reset, team]); + + const resource = `app/group:${teamId}`; + const listOfPermissionsToCheck = useMemo( + () => [ + { + permission: PERMISSIONS.UpdatePermission, + resource + }, + { + permission: PERMISSIONS.DeletePermission, + resource + } + ], + [resource] + ); + + const { permissions, isFetching: isPermissionsFetching } = usePermissions( + listOfPermissionsToCheck, + !!teamId + ); + + const { canUpdateGroup, canDeleteGroup } = useMemo(() => { + return { + canUpdateGroup: shouldShowComponent( + permissions, + `${PERMISSIONS.UpdatePermission}::${resource}` + ), + canDeleteGroup: shouldShowComponent( + permissions, + `${PERMISSIONS.DeletePermission}::${resource}` + ) + }; + }, [permissions, resource]); + + const isLoading = isTeamLoading || isPermissionsFetching; + + const { mutateAsync: updateTeam } = useMutation( + FrontierServiceQueries.updateGroup, + { + onSuccess: () => { + toast.success('Team updated'); + }, + onError: error => { + toast.error('Something went wrong', { + description: error.message || 'Failed to update team' + }); + } + } + ); + + async function onSubmit(data: FormData) { + if (!organization?.id) return; + if (!teamId) return; + + const request = create(UpdateGroupRequestSchema, { + id: teamId, + orgId: organization.id, + body: { + title: data.title, + name: data.name + } + }); + + await updateTeam(request); + } + + return ( + +
+ + {isLoading ? ( +
+ + +
+ ) : ( + + )} + {isLoading ? ( +
+ + +
+ ) : ( + + )} + + {isLoading ? ( + + ) : ( + + + + )} +
+
+ + + {isLoading ? ( + + ) : ( + + If you want to permanently delete this team and all of its data. + + )} + {isLoading ? ( + + ) : ( + + + + )} + +
+ ); +}; + diff --git a/web/sdk/react/components/organization/teams/members/member.columns.tsx b/web/sdk/react/views/teams/details/team-member-columns.tsx similarity index 78% rename from web/sdk/react/components/organization/teams/members/member.columns.tsx rename to web/sdk/react/views/teams/details/team-member-columns.tsx index dd741df07..b1c37e87f 100644 --- a/web/sdk/react/components/organization/teams/members/member.columns.tsx +++ b/web/sdk/react/views/teams/details/team-member-columns.tsx @@ -1,9 +1,10 @@ +'use client'; + import { DotsHorizontalIcon, TrashIcon, UpdateIcon } from '@radix-ui/react-icons'; -import { useNavigate, useParams } from '@tanstack/react-router'; import { toast, Label, @@ -16,33 +17,35 @@ import { } from '@raystack/apsara'; import { differenceWith, getInitials, isEqualById } from '~/utils'; import { useMutation, useQuery } from '@connectrpc/connect-query'; -import { FrontierServiceQueries, +import { + FrontierServiceQueries, RemoveGroupUserRequestSchema, DeletePolicyRequestSchema, CreatePolicyRequestSchema, Policy, Role, User, - ListPoliciesRequestSchema } from '@raystack/proton/frontier'; + ListPoliciesRequestSchema +} from '@raystack/proton/frontier'; import { create } from '@bufbuild/protobuf'; interface getColumnsOptions { roles: Role[]; organizationId: string; + teamId: string; canUpdateGroup?: boolean; memberRoles?: Record; refetchMembers: () => void; } -export const getColumns: ( - options: getColumnsOptions -) => DataTableColumnDef[] = ({ +export const getColumns = ({ roles = [], organizationId, + teamId, canUpdateGroup = false, memberRoles = {}, refetchMembers -}) => [ +}: getColumnsOptions): DataTableColumnDef[] => [ { header: '', accessorKey: 'avatar', @@ -103,6 +106,7 @@ export const getColumns: ( refetch={refetchMembers} member={row.original as User} organizationId={organizationId} + teamId={teamId} canUpdateGroup={canUpdateGroup} excludedRoles={differenceWith( isEqualById, @@ -119,6 +123,7 @@ export const getColumns: ( const MembersActions = ({ member, organizationId, + teamId, canUpdateGroup, excludedRoles = [], refetch = () => null @@ -126,34 +131,29 @@ const MembersActions = ({ member: User; canUpdateGroup?: boolean; organizationId: string; + teamId: string; excludedRoles: Role[]; refetch: () => void; }) => { - let { teamId } = useParams({ from: '/teams/$teamId' }); - const navigate = useNavigate({ from: '/teams/$teamId' }); - // Remove group user using Connect RPC - const removeGroupUserMutation = useMutation(FrontierServiceQueries.removeGroupUser, { - onSuccess: () => { - refetch(); - navigate({ - to: '/teams/$teamId', - params: { - teamId - } - }); - toast.success('Member deleted'); - }, - onError: (error) => { - toast.error('Something went wrong', { - description: error.message - }); + const removeGroupUserMutation = useMutation( + FrontierServiceQueries.removeGroupUser, + { + onSuccess: () => { + refetch(); + toast.success('Member deleted'); + }, + onError: error => { + toast.error('Something went wrong', { + description: error.message + }); + } } - }); + ); function deleteMember() { const request = create(RemoveGroupUserRequestSchema, { - id: teamId as string, + id: teamId, orgId: organizationId, userId: member?.id as string }); @@ -164,37 +164,46 @@ const MembersActions = ({ // Get policies using Connect RPC const { refetch: refetchPolicies } = useQuery( FrontierServiceQueries.listPolicies, - create(ListPoliciesRequestSchema, { groupId: teamId as string, userId: member?.id as string }), + create(ListPoliciesRequestSchema, { + groupId: teamId, + userId: member?.id as string + }), { enabled: false } // Only fetch when needed ); // Delete policy using Connect RPC - const deletePolicyMutation = useMutation(FrontierServiceQueries.deletePolicy, { - onError: (error) => { - toast.error('Something went wrong', { - description: error.message - }); + const deletePolicyMutation = useMutation( + FrontierServiceQueries.deletePolicy, + { + onError: error => { + toast.error('Something went wrong', { + description: error.message + }); + } } - }); + ); // Create policy using Connect RPC - const createPolicyMutation = useMutation(FrontierServiceQueries.createPolicy, { - onSuccess: () => { - refetch(); - toast.success('Team member role updated'); - }, - onError: (error) => { - toast.error('Something went wrong', { - description: error.message - }); + const createPolicyMutation = useMutation( + FrontierServiceQueries.createPolicy, + { + onSuccess: () => { + refetch(); + toast.success('Team member role updated'); + }, + onError: error => { + toast.error('Something went wrong', { + description: error.message + }); + } } - }); + ); async function updateRole(role: Role) { try { const resource = `app/group:${teamId}`; const principal = `app/user:${member?.id}`; - + // Get policies using Connect RPC const policiesResponse = await refetchPolicies(); const policies = policiesResponse?.data?.policies || []; @@ -208,7 +217,7 @@ const MembersActions = ({ }); await Promise.all(deletePromises); - + // Create new policy const createRequest = create(CreatePolicyRequestSchema, { body: { @@ -218,7 +227,7 @@ const MembersActions = ({ principal: principal } }); - + await createPolicyMutation.mutateAsync(createRequest); } catch (error: any) { toast.error('Something went wrong', { @@ -226,6 +235,7 @@ const MembersActions = ({ }); } } + return canUpdateGroup ? ( @@ -256,3 +266,4 @@ const MembersActions = ({ ) : null; }; + diff --git a/web/sdk/react/components/organization/teams/members/members.module.css b/web/sdk/react/views/teams/details/team-members.module.css similarity index 99% rename from web/sdk/react/components/organization/teams/members/members.module.css rename to web/sdk/react/views/teams/details/team-members.module.css index 6da051c21..d160a783a 100644 --- a/web/sdk/react/components/organization/teams/members/members.module.css +++ b/web/sdk/react/views/teams/details/team-members.module.css @@ -51,3 +51,4 @@ margin-top: var(--rs-space-5); height: calc(100% - var(--rs-space-5)); } + diff --git a/web/sdk/react/views/teams/details/team-members.tsx b/web/sdk/react/views/teams/details/team-members.tsx new file mode 100644 index 000000000..59670ded8 --- /dev/null +++ b/web/sdk/react/views/teams/details/team-members.tsx @@ -0,0 +1,335 @@ +'use client'; + +import type React from 'react'; +import { + Button, + EmptyState, + Tooltip, + toast, + Separator, + Avatar, + Skeleton, + Text, + Flex, + DataTable, + Popover, + Search +} from '@raystack/apsara'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { ExclamationTriangleIcon, PaperPlaneIcon } from '@radix-ui/react-icons'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { usePermissions } from '~/react/hooks/usePermissions'; +import { AuthTooltipMessage } from '~/react/utils'; +import { + PERMISSIONS, + filterUsersfromUsers, + getInitials, + shouldShowComponent +} from '~/utils'; +import { getColumns } from './team-member-columns'; + +import { useQuery, useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + AddGroupUsersRequestSchema, + ListOrganizationUsersRequestSchema, + type User, + type Role +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import styles from './team-members.module.css'; + +export type TeamMembersProps = { + members: User[]; + roles: Role[]; + organizationId: string; + teamId: string; + memberRoles?: Record; + isLoading?: boolean; + refetchMembers: () => void; + onInviteClick?: () => void; +}; + +export const TeamMembers = ({ + members, + roles = [], + organizationId, + teamId, + memberRoles = {}, + isLoading: isMemberLoading, + refetchMembers, + onInviteClick +}: TeamMembersProps) => { + const resource = `app/group:${teamId}`; + const listOfPermissionsToCheck = useMemo( + () => [ + { + permission: PERMISSIONS.UpdatePermission, + resource + } + ], + [resource] + ); + + const { permissions, isFetching: isPermissionsFetching } = usePermissions( + listOfPermissionsToCheck, + !!teamId + ); + const { canUpdateGroup } = useMemo(() => { + return { + canUpdateGroup: shouldShowComponent( + permissions, + `${PERMISSIONS.UpdatePermission}::${resource}` + ) + }; + }, [permissions, resource]); + + const isLoading = isPermissionsFetching || isMemberLoading; + + const columns = useMemo( + () => + getColumns({ + roles, + organizationId, + teamId, + canUpdateGroup, + memberRoles, + refetchMembers + }), + [roles, organizationId, teamId, canUpdateGroup, memberRoles, refetchMembers] + ); + + return ( + + + + + + + + {isLoading ? ( + + ) : ( + + + + )} + + + + + + ); +}; + +interface AddMemberDropdownProps { + canUpdateGroup: boolean; + refetchMembers: () => void; + members: User[]; + teamId: string; + onInviteClick?: () => void; +} + +const AddMemberDropdown = ({ + canUpdateGroup, + refetchMembers, + members, + teamId, + onInviteClick +}: AddMemberDropdownProps) => { + const [query, setQuery] = useState(''); + + const { activeOrganization: organization } = useFrontier(); + + // Get organization members using Connect RPC + const { + data: orgMembersData, + isLoading: isOrgMembersLoading, + error: orgMembersError + } = useQuery( + FrontierServiceQueries.listOrganizationUsers, + create(ListOrganizationUsersRequestSchema, { + id: organization?.id || '' + }), + { enabled: !!organization?.id && canUpdateGroup } + ); + + // Handle organization members error + useEffect(() => { + if (orgMembersError) { + toast.error('Something went wrong', { + description: orgMembersError.message + }); + } + }, [orgMembersError]); + + const invitableUser = useMemo( + () => filterUsersfromUsers(orgMembersData?.users || [], members) || [], + [orgMembersData?.users, members] + ); + + const isUserLoading = isOrgMembersLoading; + + const topUsers = useMemo( + () => + invitableUser + .filter(user => + query + ? user.title && + user.title.toLowerCase().includes(query.toLowerCase()) + : true + ) + .slice(0, 7), + [invitableUser, query] + ); + + function onTextChange(e: React.ChangeEvent) { + setQuery(e.target.value); + } + + // Add group user using Connect RPC + const addGroupUserMutation = useMutation( + FrontierServiceQueries.addGroupUsers, + { + onSuccess: () => { + toast.success('member added'); + if (refetchMembers) { + refetchMembers(); + } + }, + onError: error => { + toast.error('Something went wrong', { + description: error.message + }); + } + } + ); + + const addMember = useCallback( + (userId: string) => { + if (!userId || !organization?.id) return; + + const request = create(AddGroupUsersRequestSchema, { + id: teamId, + orgId: organization.id, + userIds: [userId] + }); + + addGroupUserMutation.mutate(request); + }, + [organization?.id, teamId, addGroupUserMutation] + ); + + return ( + + + + + + setQuery('')} + /> + + + {isUserLoading ? ( + + ) : topUsers.length ? ( +
+ {topUsers.map(user => { + const initials = getInitials(user?.title || user.email); + return ( + addMember(user?.id || '')} + className={styles.inviteDropdownItem} + data-test-id={`frontier-sdk-add-member-${user.id}`} + > + + {user?.title || user?.email} + + ); + })} +
+ ) : ( + + No Users found + + )} + +
+ onInviteClick?.()} + className={styles.inviteDropdownItem} + style={{ cursor: 'pointer' }} + > + {' '} + Invite People + +
+
+
+ ); +}; + +const noDataChildren = ( + } + heading="No members found" + subHeading="Get started by adding your first team member." + /> +); + diff --git a/web/sdk/react/views/teams/index.ts b/web/sdk/react/views/teams/index.ts new file mode 100644 index 000000000..6f70a255c --- /dev/null +++ b/web/sdk/react/views/teams/index.ts @@ -0,0 +1,14 @@ +export { TeamsListPage } from './list'; +export type { TeamsListPageProps } from './list'; + +export { AddTeamDialog } from './list'; +export type { AddTeamDialogProps } from './list'; + +export { TeamDetailPage } from './details'; +export type { TeamDetailPageProps } from './details'; + +export { DeleteTeamDialog } from './details'; +export type { DeleteTeamDialogProps } from './details'; + +export { InviteTeamMemberDialog } from './details'; +export type { InviteTeamMemberDialogProps } from './details'; diff --git a/web/sdk/react/views/teams/list/add-team-dialog.tsx b/web/sdk/react/views/teams/list/add-team-dialog.tsx new file mode 100644 index 000000000..c011b67eb --- /dev/null +++ b/web/sdk/react/views/teams/list/add-team-dialog.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { + Button, + toast, + Image, + Text, + Flex, + Dialog, + InputField +} from '@raystack/apsara'; + +import { yupResolver } from '@hookform/resolvers/yup'; +import { useForm } from 'react-hook-form'; +import * as yup from 'yup'; +import cross from '~/react/assets/cross.svg'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + CreateGroupRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import orgStyles from '../../../components/organization/organization.module.css'; + +const teamSchema = yup + .object({ + title: yup.string().required(), + name: yup + .string() + .required('name is a required field') + .min(3, 'name is not valid, Min 3 characters allowed') + .max(50, 'name is not valid, Max 50 characters allowed') + .matches( + /^[a-zA-Z0-9_-]{3,50}$/, + "Only numbers, letters, '-', and '_' are allowed. Spaces are not allowed." + ) + }) + .required(); + +type FormData = yup.InferType; + +export interface AddTeamDialogProps { + open: boolean; + onOpenChange: (value: boolean) => void; +} + +export const AddTeamDialog = ({ open, onOpenChange }: AddTeamDialogProps) => { + const { + handleSubmit, + formState: { errors, isSubmitting }, + register, + reset + } = useForm({ + resolver: yupResolver(teamSchema) + }); + const { activeOrganization: organization } = useFrontier(); + + const { mutateAsync: createTeam } = useMutation( + FrontierServiceQueries.createGroup, + { + onSuccess: () => { + toast.success('Team added'); + reset(); + onOpenChange(false); + }, + onError: error => { + toast.error('Something went wrong', { + description: error.message + }); + } + } + ); + + async function onSubmit(data: FormData) { + if (!organization?.id) return; + + const request = create(CreateGroupRequestSchema, { + orgId: organization.id, + body: { + title: data.title, + name: data.name + } + }); + + await createTeam(request); + } + + return ( + + + + + + Add Team + + cross onOpenChange(false)} + style={{ cursor: 'pointer' }} + data-test-id="frontier-sdk-add-team-close-btn" + /> + + +
+ + + + + + + + + + + +
+
+
+ ); +}; + diff --git a/web/sdk/react/views/teams/list/index.ts b/web/sdk/react/views/teams/list/index.ts new file mode 100644 index 000000000..d308fa288 --- /dev/null +++ b/web/sdk/react/views/teams/list/index.ts @@ -0,0 +1,6 @@ +export { TeamsListPage } from './teams-list-page'; +export type { TeamsListPageProps } from './teams-list-page'; + +export { AddTeamDialog } from './add-team-dialog'; +export type { AddTeamDialogProps } from './add-team-dialog'; + diff --git a/web/sdk/react/views/teams/list/teams-columns.tsx b/web/sdk/react/views/teams/list/teams-columns.tsx new file mode 100644 index 000000000..1615de843 --- /dev/null +++ b/web/sdk/react/views/teams/list/teams-columns.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { + DotsHorizontalIcon, + Pencil1Icon, + TrashIcon +} from '@radix-ui/react-icons'; +import { Text, DropdownMenu, DataTableColumnDef } from '@raystack/apsara'; +import type { Group } from '@raystack/proton/frontier'; +import orgStyles from '../../../components/organization/organization.module.css'; + +export const getColumns = ( + userAccessOnTeam: Record, + onTeamClick?: (teamId: string) => void, + onDeleteTeamClick?: (teamId: string) => void +): DataTableColumnDef[] => [ + { + header: 'Title', + accessorKey: 'title', + cell: ({ row, getValue }) => ( + onTeamClick?.(row.original.id || '')} + style={{ + textDecoration: 'none', + color: 'var(--rs-color-foreground-base-primary)', + fontSize: 'var(--rs-font-size-small)', + cursor: 'pointer' + }} + > + {getValue() as string} + + ) + }, + { + header: 'Members', + accessorKey: 'members_count', + cell: ({ row, getValue }) => { + const value = getValue() as string; + return value ? {value} members : null; + } + }, + { + header: '', + accessorKey: 'id', + enableSorting: false, + cell: ({ row, getValue }) => ( + + ) + } + ]; + +const TeamActions = ({ + team, + userAccessOnTeam, + onTeamClick, + onDeleteTeamClick +}: { + team: Group; + userAccessOnTeam: Record; + onTeamClick?: (teamId: string) => void; + onDeleteTeamClick?: (teamId: string) => void; +}) => { + const canUpdateTeam = (userAccessOnTeam[team.id!] ?? []).includes('update'); + const canDeleteTeam = (userAccessOnTeam[team.id!] ?? []).includes('delete'); + const canDoActions = canUpdateTeam || canDeleteTeam; + + return canDoActions ? ( + + + + + {/* @ts-ignore */} + + {canUpdateTeam ? ( + onTeamClick?.(team.id || '')} + className={orgStyles.dropdownActionItem} + > + Rename + + ) : null} + {canDeleteTeam ? ( + onDeleteTeamClick?.(team.id || '')} + className={orgStyles.dropdownActionItem} + > + Delete team + + ) : null} + + + ) : null; +}; + diff --git a/web/sdk/react/views/teams/list/teams-list-page.tsx b/web/sdk/react/views/teams/list/teams-list-page.tsx new file mode 100644 index 000000000..ea57235c3 --- /dev/null +++ b/web/sdk/react/views/teams/list/teams-list-page.tsx @@ -0,0 +1,272 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import { + Tooltip, + Skeleton, + EmptyState, + Flex, + Button, + Select, + DataTable +} from '@raystack/apsara'; +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { useFrontier } from '~/react/contexts/FrontierContext'; + +import { useOrganizationTeams } from '~/react/hooks/useOrganizationTeams'; +import { usePermissions } from '~/react/hooks/usePermissions'; +import type { Group } from '@raystack/proton/frontier'; +import { PERMISSIONS, shouldShowComponent } from '~/utils'; +import { getColumns } from './teams-columns'; +import { AuthTooltipMessage } from '~/react/utils'; +import { PageHeader } from '~/react/components/common/page-header'; +import { AddTeamDialog } from './add-team-dialog'; +import { DeleteTeamDialog } from '../details/delete-team-dialog'; +import sharedStyles from '../../../components/organization/styles.module.css'; +import styles from './teams.module.css'; +import { useTerminology } from '~/react/hooks/useTerminology'; + +const teamsSelectOptions = [ + { value: 'my-teams', label: 'My Teams' }, + { value: 'all-teams', label: 'All Teams' } +]; + +interface TeamsTableProps { + teams: Group[]; + isLoading?: boolean; + canCreateGroup?: boolean; + userAccessOnTeam: Record; + canListOrgGroups?: boolean; + onOrgTeamsFilterChange: (value: string) => void; + onTeamClick?: (teamId: string) => void; + onDeleteTeamClick?: (teamId: string) => void; + onAddTeamClick?: () => void; +} + +export interface TeamsListPageProps { + title?: string; + description?: string; + onTeamClick?: (teamId: string) => void; +} + +export function TeamsListPage({ + title, + description, + onTeamClick +}: TeamsListPageProps = {}) { + const [showOrgTeams, setShowOrgTeams] = useState(false); + const t = useTerminology(); + + const { + isFetching: isTeamsLoading, + teams, + userAccessOnTeam, + refetch + } = useOrganizationTeams({ + withPermissions: ['update', 'delete'], + showOrgTeams, + withMemberCount: true + }); + const { activeOrganization: organization } = useFrontier(); + + const resource = `app/organization:${organization?.id}`; + const listOfPermissionsToCheck = useMemo( + () => [ + { + permission: PERMISSIONS.GroupCreatePermission, + resource + }, + { + permission: PERMISSIONS.GroupListPermission, + resource + } + ], + [resource] + ); + + const { permissions, isFetching: isPermissionsFetching } = usePermissions( + listOfPermissionsToCheck, + !!organization?.id + ); + + const { canCreateGroup, canListOrgGroups } = useMemo(() => { + return { + canCreateGroup: shouldShowComponent( + permissions, + `${PERMISSIONS.GroupCreatePermission}::${resource}` + ), + canListOrgGroups: shouldShowComponent( + permissions, + `${PERMISSIONS.GroupCreatePermission}::${resource}` + ) + }; + }, [permissions, resource]); + + const onOrgTeamsFilterChange = useCallback((value: string) => { + if (value === 'all-teams') { + setShowOrgTeams(true); + } else { + setShowOrgTeams(false); + } + }, []); + + const isLoading = isPermissionsFetching || isTeamsLoading; + + const [showAddTeamDialog, setShowAddTeamDialog] = useState(false); + const [deleteTeamState, setDeleteTeamState] = useState<{ + open: boolean; + teamId: string; + }>({ open: false, teamId: '' }); + + const handleAddTeamOpenChange = (value: boolean) => { + setShowAddTeamDialog(value); + refetch(); + }; + + const handleDeleteTeamOpenChange = (value: boolean) => { + setDeleteTeamState({ open: value, teamId: '' }); + refetch(); + }; + + const handleDeleteTeamClick = (teamId: string) => { + setDeleteTeamState({ open: true, teamId }); + }; + + return ( + + + + + + + setShowAddTeamDialog(true)} + /> + + + + refetch()} + /> + + ); +} + +const TeamsTable = ({ + teams, + isLoading, + canCreateGroup, + userAccessOnTeam, + canListOrgGroups, + onOrgTeamsFilterChange, + onTeamClick, + onDeleteTeamClick, + onAddTeamClick +}: TeamsTableProps) => { + const columns = useMemo( + () => getColumns(userAccessOnTeam, onTeamClick, onDeleteTeamClick), + [userAccessOnTeam, onTeamClick, onDeleteTeamClick] + ); + + return ( + + + + + {isLoading ? ( + + ) : ( + + )} + {canListOrgGroups ? ( + + ) : null} + + {isLoading ? ( + + ) : ( + + + + )} + + + + + ); +}; + +const noDataChildren = ( + } + heading="No teams found" + subHeading="Get started by creating your first team." + /> +); + diff --git a/web/sdk/react/components/organization/teams/teams.module.css b/web/sdk/react/views/teams/list/teams.module.css similarity index 89% rename from web/sdk/react/components/organization/teams/teams.module.css rename to web/sdk/react/views/teams/list/teams.module.css index ad3613f12..b53cd1f4f 100644 --- a/web/sdk/react/components/organization/teams/teams.module.css +++ b/web/sdk/react/views/teams/list/teams.module.css @@ -22,6 +22,3 @@ flex: 1; } -.tabContent { - height: 100%; -} From 6e051a0241b131358c114a7c7e5f56b5981b7427 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 26 Feb 2026 02:22:42 +0530 Subject: [PATCH 5/6] featL move sdk teams --- .../teams/details/team-member-columns.tsx | 464 +++++++++--------- 1 file changed, 232 insertions(+), 232 deletions(-) diff --git a/web/sdk/react/views/teams/details/team-member-columns.tsx b/web/sdk/react/views/teams/details/team-member-columns.tsx index b1c37e87f..27743e3d6 100644 --- a/web/sdk/react/views/teams/details/team-member-columns.tsx +++ b/web/sdk/react/views/teams/details/team-member-columns.tsx @@ -1,269 +1,269 @@ 'use client'; import { - DotsHorizontalIcon, - TrashIcon, - UpdateIcon + DotsHorizontalIcon, + TrashIcon, + UpdateIcon } from '@radix-ui/react-icons'; import { - toast, - Label, - Text, - Flex, - Avatar, - DropdownMenu, - type DataTableColumnDef, - getAvatarColor + toast, + Label, + Text, + Flex, + Avatar, + DropdownMenu, + type DataTableColumnDef, + getAvatarColor } from '@raystack/apsara'; import { differenceWith, getInitials, isEqualById } from '~/utils'; import { useMutation, useQuery } from '@connectrpc/connect-query'; import { - FrontierServiceQueries, - RemoveGroupUserRequestSchema, - DeletePolicyRequestSchema, - CreatePolicyRequestSchema, - Policy, - Role, - User, - ListPoliciesRequestSchema + FrontierServiceQueries, + RemoveGroupUserRequestSchema, + DeletePolicyRequestSchema, + CreatePolicyRequestSchema, + Policy, + Role, + User, + ListPoliciesRequestSchema } from '@raystack/proton/frontier'; import { create } from '@bufbuild/protobuf'; interface getColumnsOptions { - roles: Role[]; - organizationId: string; - teamId: string; - canUpdateGroup?: boolean; - memberRoles?: Record; - refetchMembers: () => void; + roles: Role[]; + organizationId: string; + teamId: string; + canUpdateGroup?: boolean; + memberRoles?: Record; + refetchMembers: () => void; } export const getColumns = ({ - roles = [], - organizationId, - teamId, - canUpdateGroup = false, - memberRoles = {}, - refetchMembers + roles = [], + organizationId, + teamId, + canUpdateGroup = false, + memberRoles = {}, + refetchMembers }: getColumnsOptions): DataTableColumnDef[] => [ - { - header: '', - accessorKey: 'avatar', - enableSorting: false, - styles: { - cell: { - width: 'var(--rs-space-5)' - } - }, - cell: ({ row, getValue }) => { - const color = getAvatarColor(row?.original?.id || ''); - return ( - - ); - } - }, - { - header: 'Title', - accessorKey: 'title', - cell: ({ row, getValue }) => { - return ( - - - {row.original.email} - - ); - } - }, - { - header: 'Roles', - accessorKey: 'email', - cell: ({ row }) => { - return ( - - {(row.original?.id && - memberRoles[row.original?.id] && - memberRoles[row.original?.id] - .map((r: any) => r.title || r.name) - .join(', ')) ?? - 'Inherited role'} - - ); - } - }, - { - header: '', - accessorKey: 'id', - enableSorting: false, - cell: ({ row }) => ( - ( - isEqualById, - roles, - row.original?.id && memberRoles[row.original?.id] - ? memberRoles[row.original?.id] - : [] - )} - /> - ) - } -]; + { + header: '', + accessorKey: 'avatar', + enableSorting: false, + styles: { + cell: { + width: 'var(--rs-space-5)' + } + }, + cell: ({ row, getValue }) => { + const color = getAvatarColor(row?.original?.id || ''); + return ( + + ); + } + }, + { + header: 'Title', + accessorKey: 'title', + cell: ({ row, getValue }) => { + return ( + + + {row.original.email} + + ); + } + }, + { + header: 'Roles', + accessorKey: 'email', + cell: ({ row }) => { + return ( + + {(row.original?.id && + memberRoles[row.original?.id] && + memberRoles[row.original?.id] + .map((r: any) => r.title || r.name) + .join(', ')) ?? + 'Inherited role'} + + ); + } + }, + { + header: '', + accessorKey: 'id', + enableSorting: false, + cell: ({ row }) => ( + ( + isEqualById, + roles, + row.original?.id && memberRoles[row.original?.id] + ? memberRoles[row.original?.id] + : [] + )} + /> + ) + } + ]; const MembersActions = ({ - member, - organizationId, - teamId, - canUpdateGroup, - excludedRoles = [], - refetch = () => null + member, + organizationId, + teamId, + canUpdateGroup, + excludedRoles = [], + refetch = () => null }: { - member: User; - canUpdateGroup?: boolean; - organizationId: string; - teamId: string; - excludedRoles: Role[]; - refetch: () => void; + member: User; + canUpdateGroup?: boolean; + organizationId: string; + teamId: string; + excludedRoles: Role[]; + refetch: () => void; }) => { - // Remove group user using Connect RPC - const removeGroupUserMutation = useMutation( - FrontierServiceQueries.removeGroupUser, - { - onSuccess: () => { - refetch(); - toast.success('Member deleted'); - }, - onError: error => { - toast.error('Something went wrong', { - description: error.message + // Remove group user using Connect RPC + const removeGroupUserMutation = useMutation( + FrontierServiceQueries.removeGroupUser, + { + onSuccess: () => { + refetch(); + toast.success('Member deleted'); + }, + onError: error => { + toast.error('Something went wrong', { + description: error.message + }); + } + } + ); + + function deleteMember() { + const request = create(RemoveGroupUserRequestSchema, { + id: teamId, + orgId: organizationId, + userId: member?.id as string }); - } - } - ); - function deleteMember() { - const request = create(RemoveGroupUserRequestSchema, { - id: teamId, - orgId: organizationId, - userId: member?.id as string - }); + removeGroupUserMutation.mutate(request); + } - removeGroupUserMutation.mutate(request); - } + // Get policies using Connect RPC + const { refetch: refetchPolicies } = useQuery( + FrontierServiceQueries.listPolicies, + create(ListPoliciesRequestSchema, { + groupId: teamId, + userId: member?.id as string + }), + { enabled: false } // Only fetch when needed + ); - // Get policies using Connect RPC - const { refetch: refetchPolicies } = useQuery( - FrontierServiceQueries.listPolicies, - create(ListPoliciesRequestSchema, { - groupId: teamId, - userId: member?.id as string - }), - { enabled: false } // Only fetch when needed - ); + // Delete policy using Connect RPC + const deletePolicyMutation = useMutation( + FrontierServiceQueries.deletePolicy, + { + onError: error => { + toast.error('Something went wrong', { + description: error.message + }); + } + } + ); - // Delete policy using Connect RPC - const deletePolicyMutation = useMutation( - FrontierServiceQueries.deletePolicy, - { - onError: error => { - toast.error('Something went wrong', { - description: error.message - }); - } - } - ); + // Create policy using Connect RPC + const createPolicyMutation = useMutation( + FrontierServiceQueries.createPolicy, + { + onSuccess: () => { + refetch(); + toast.success('Team member role updated'); + }, + onError: error => { + toast.error('Something went wrong', { + description: error.message + }); + } + } + ); - // Create policy using Connect RPC - const createPolicyMutation = useMutation( - FrontierServiceQueries.createPolicy, - { - onSuccess: () => { - refetch(); - toast.success('Team member role updated'); - }, - onError: error => { - toast.error('Something went wrong', { - description: error.message - }); - } - } - ); + async function updateRole(role: Role) { + try { + const resource = `app/group:${teamId}`; + const principal = `app/user:${member?.id}`; - async function updateRole(role: Role) { - try { - const resource = `app/group:${teamId}`; - const principal = `app/user:${member?.id}`; + // Get policies using Connect RPC + const policiesResponse = await refetchPolicies(); + const policies = policiesResponse?.data?.policies || []; - // Get policies using Connect RPC - const policiesResponse = await refetchPolicies(); - const policies = policiesResponse?.data?.policies || []; + // Delete existing policies + const deletePromises = policies.map((p: Policy) => { + const deleteRequest = create(DeletePolicyRequestSchema, { + id: p.id as string + }); + return deletePolicyMutation.mutateAsync(deleteRequest); + }); - // Delete existing policies - const deletePromises = policies.map((p: Policy) => { - const deleteRequest = create(DeletePolicyRequestSchema, { - id: p.id as string - }); - return deletePolicyMutation.mutateAsync(deleteRequest); - }); + await Promise.all(deletePromises); - await Promise.all(deletePromises); + // Create new policy + const createRequest = create(CreatePolicyRequestSchema, { + body: { + roleId: role.id as string, + title: role.name as string, + resource: resource, + principal: principal + } + }); - // Create new policy - const createRequest = create(CreatePolicyRequestSchema, { - body: { - roleId: role.id as string, - title: role.name as string, - resource: resource, - principal: principal + await createPolicyMutation.mutateAsync(createRequest); + } catch (error: any) { + toast.error('Something went wrong', { + description: error?.message + }); } - }); - - await createPolicyMutation.mutateAsync(createRequest); - } catch (error: any) { - toast.error('Something went wrong', { - description: error?.message - }); } - } - return canUpdateGroup ? ( - - - - - {/* @ts-ignore */} - - - {excludedRoles.map((role: Role) => ( - updateRole(role)} - data-test-id="frontier-sdk-update-team-member-role-btn" - > - - Make {role.title} - - ))} - - - Remove from team - - - - - ) : null; + return canUpdateGroup ? ( + + + + + {/* @ts-ignore */} + + + {excludedRoles.map((role: Role) => ( + updateRole(role)} + data-test-id="frontier-sdk-update-team-member-role-btn" + > + + Make {role.title} + + ))} + + + Remove from team + + + + + ) : null; }; From 19dabf49a29a64cf66a31d7169493fa5f43ce58b Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 26 Feb 2026 03:09:32 +0530 Subject: [PATCH 6/6] feat: move sdk projects --- .../components/organization/project/add.tsx | 146 ------- .../organization/project/delete.tsx | 164 ------- .../organization/project/general/index.tsx | 218 ---------- .../components/organization/project/index.tsx | 243 +---------- .../organization/project/members/index.tsx | 396 ----------------- .../project/members/member.columns.tsx | 253 ----------- .../project/members/members.module.css | 46 -- .../organization/project/members/remove.tsx | 146 ------- .../organization/project/project.module.css | 27 -- .../organization/project/project.tsx | 213 +-------- .../organization/project/projects.columns.tsx | 135 ------ .../react/components/organization/routes.tsx | 28 +- .../details/delete-project-dialog.tsx | 189 ++++++++ web/sdk/react/views/projects/details/index.ts | 9 + .../projects/details/project-detail-page.tsx | 264 ++++++++++++ .../details/project-detail.module.css | 8 + .../projects/details/project-general.tsx | 227 ++++++++++ .../details/project-member-columns.tsx | 255 +++++++++++ .../details/project-members.module.css | 47 ++ .../projects/details/project-members.tsx | 404 ++++++++++++++++++ .../details/remove-project-member-dialog.tsx | 165 +++++++ web/sdk/react/views/projects/index.ts | 15 + .../projects/list/add-project-dialog.tsx | 164 +++++++ web/sdk/react/views/projects/list/index.ts | 6 + .../views/projects/list/projects-columns.tsx | 113 +++++ .../projects/list/projects-list-page.tsx | 283 ++++++++++++ .../views/projects/list/projects.module.css | 24 ++ 27 files changed, 2194 insertions(+), 1994 deletions(-) delete mode 100644 web/sdk/react/components/organization/project/add.tsx delete mode 100644 web/sdk/react/components/organization/project/delete.tsx delete mode 100644 web/sdk/react/components/organization/project/general/index.tsx delete mode 100644 web/sdk/react/components/organization/project/members/index.tsx delete mode 100644 web/sdk/react/components/organization/project/members/member.columns.tsx delete mode 100644 web/sdk/react/components/organization/project/members/members.module.css delete mode 100644 web/sdk/react/components/organization/project/members/remove.tsx delete mode 100644 web/sdk/react/components/organization/project/project.module.css delete mode 100644 web/sdk/react/components/organization/project/projects.columns.tsx create mode 100644 web/sdk/react/views/projects/details/delete-project-dialog.tsx create mode 100644 web/sdk/react/views/projects/details/index.ts create mode 100644 web/sdk/react/views/projects/details/project-detail-page.tsx create mode 100644 web/sdk/react/views/projects/details/project-detail.module.css create mode 100644 web/sdk/react/views/projects/details/project-general.tsx create mode 100644 web/sdk/react/views/projects/details/project-member-columns.tsx create mode 100644 web/sdk/react/views/projects/details/project-members.module.css create mode 100644 web/sdk/react/views/projects/details/project-members.tsx create mode 100644 web/sdk/react/views/projects/details/remove-project-member-dialog.tsx create mode 100644 web/sdk/react/views/projects/index.ts create mode 100644 web/sdk/react/views/projects/list/add-project-dialog.tsx create mode 100644 web/sdk/react/views/projects/list/index.ts create mode 100644 web/sdk/react/views/projects/list/projects-columns.tsx create mode 100644 web/sdk/react/views/projects/list/projects-list-page.tsx create mode 100644 web/sdk/react/views/projects/list/projects.module.css diff --git a/web/sdk/react/components/organization/project/add.tsx b/web/sdk/react/components/organization/project/add.tsx deleted file mode 100644 index a93b246dd..000000000 --- a/web/sdk/react/components/organization/project/add.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useEffect } from 'react'; -import { - Button, - toast, - Image, - Text, - Flex, - Dialog, - InputField -} from '@raystack/apsara'; -import * as yup from 'yup'; - -import { yupResolver } from '@hookform/resolvers/yup'; -import { useNavigate } from '@tanstack/react-router'; -import { useForm } from 'react-hook-form'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import { useMutation } from '@connectrpc/connect-query'; -import { - FrontierServiceQueries, - CreateProjectRequestSchema -} from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import cross from '~/react/assets/cross.svg'; -import styles from '../organization.module.css'; -import slugify from 'slugify'; -import { generateHashFromString } from '~/react/utils'; -import { ConnectError, Code } from '@connectrpc/connect'; - -const projectSchema = yup - .object({ - title: yup.string().required(), - org_id: yup.string().required() - }) - .required(); - -type FormData = yup.InferType; - -export const AddProject = () => { - const { - reset, - handleSubmit, - setError, - formState: { errors, isSubmitting }, - register - } = useForm({ - resolver: yupResolver(projectSchema) - }); - const navigate = useNavigate({ from: '/projects/modal' }); - const { activeOrganization: organization } = useFrontier(); - - useEffect(() => { - reset({ org_id: organization?.id }); - }, [organization, reset]); - - const { mutateAsync: createProject } = useMutation( - FrontierServiceQueries.createProject, - { - onSuccess: () => { - toast.success('Project added'); - navigate({ to: '/projects' }); - } - } - ); - - async function onSubmit(data: FormData) { - if (!organization?.id) return; - const slug = slugify(data.title, { lower: true, strict: true }); - const suffix = generateHashFromString(organization.id); - const name = `${slug}-${suffix}`; - try { - await createProject( - create(CreateProjectRequestSchema, { - body: { - title: data.title, - name, - orgId: organization.id - } - }) - ); - } catch (error) { - if (error instanceof ConnectError && error.code === Code.AlreadyExists) { - setError('title', { - message: - 'A project with a similar title already exist. Please tweak the title and try again.' - }); - } else { - toast.error('Something went wrong', { - description: - error instanceof Error ? error.message : 'Failed to create project' - }); - } - } - } - - return ( - - - - - - Add Project - - cross navigate({ to: '/projects' })} - data-test-id="frontier-sdk-new-project-close-btn" - style={{ cursor: 'pointer' }} - /> - - -
- - -
- -
- -
-
- - - - - -
-
-
- ); -}; diff --git a/web/sdk/react/components/organization/project/delete.tsx b/web/sdk/react/components/organization/project/delete.tsx deleted file mode 100644 index 7a71efd7d..000000000 --- a/web/sdk/react/components/organization/project/delete.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { - Button, - Checkbox, - toast, - Skeleton, - Image, - Text, - Flex, - Dialog, - InputField -} from '@raystack/apsara'; - -import { yupResolver } from '@hookform/resolvers/yup'; -import { useNavigate, useParams } from '@tanstack/react-router'; -import { useEffect, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import * as yup from 'yup'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import { useQuery, useMutation } from '@connectrpc/connect-query'; -import { FrontierServiceQueries, GetProjectRequestSchema, DeleteProjectRequestSchema } from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import cross from '~/react/assets/cross.svg'; -import styles from '../organization.module.css'; - -const projectSchema = yup - .object({ - title: yup.string() - }) - .required(); - -export const DeleteProject = () => { - const { - watch, - setError, - handleSubmit, - formState: { errors, isSubmitting }, - register - } = useForm({ - resolver: yupResolver(projectSchema) - }); - let { projectId } = useParams({ from: '/projects/$projectId/delete' }); - const navigate = useNavigate({ from: '/projects/$projectId/delete' }); - const { activeOrganization: organization } = useFrontier(); - const [isAcknowledged, setIsAcknowledged] = useState(false); - - const { - data: project, - isLoading: isProjectQueryLoading, - error: projectError - } = useQuery( - FrontierServiceQueries.getProject, - create(GetProjectRequestSchema, { id: projectId || '' }), - { - enabled: !!projectId, - select: (d) => d?.project - } - ); - - useEffect(() => { - if (projectError) { - toast.error('Something went wrong', { description: projectError.message }); - } - }, [projectError]); - - const { mutateAsync: deleteProject } = useMutation( - FrontierServiceQueries.deleteProject, - { - onSuccess: () => { - toast.success('project deleted'); - navigate({ to: '/projects' }); - }, - onError: (err: Error) => - toast.error('Something went wrong', { description: err.message }) - } - ); - - async function onSubmit(data: { title?: string }) { - if (!organization?.id || !projectId) return; - if (data.title !== project?.title) - return setError('title', { message: 'Project title does not match' }); - await deleteProject(create(DeleteProjectRequestSchema, { id: projectId })); - } - - const title = watch('title', ''); - return ( - - - - - - Verify project deletion - - cross - navigate({ to: '/projects/$projectId', params: { projectId } }) - } - style={{ cursor: 'pointer' }} - data-test-id="frontier-sdk-delete-project-close-btn" - /> - - - -
- - {isProjectQueryLoading ? ( - <> - - - - - - - ) : ( - <> - - This action can not be undone. This will permanently delete - project {project?.title}. - - - - - - setIsAcknowledged(v === true)} - data-test-id="frontier-sdk-delete-project-checkbox" - /> - - I acknowledge and understand that all of the project data will be deleted - and want to proceed. - - - - - )} - -
-
-
-
- ); -}; diff --git a/web/sdk/react/components/organization/project/general/index.tsx b/web/sdk/react/components/organization/project/general/index.tsx deleted file mode 100644 index 2f5a5a54f..000000000 --- a/web/sdk/react/components/organization/project/general/index.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { - Button, - Separator, - toast, - Tooltip, - Skeleton, - Text, - Flex, - InputField -} from '@raystack/apsara'; - -import { yupResolver } from '@hookform/resolvers/yup'; -import { useNavigate, useParams } from '@tanstack/react-router'; -import { useEffect, useMemo } from 'react'; -import { useForm } from 'react-hook-form'; -import * as yup from 'yup'; -import { usePermissions } from '~/react/hooks/usePermissions'; -import { useMutation } from '@connectrpc/connect-query'; -import { FrontierServiceQueries, UpdateProjectRequestSchema, Project, Organization } from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import { PERMISSIONS, shouldShowComponent } from '~/utils'; -import { AuthTooltipMessage } from '~/react/utils'; - -const projectSchema = yup - .object({ - title: yup.string().required(), - name: yup.string().required() - }) - .required(); - -type FormData = yup.InferType; - -interface GeneralProjectProps { - project?: Project; - organization?: Organization; - isLoading?: boolean; -} - -export const General = ({ - organization, - project, - isLoading: isProjectLoading -}: GeneralProjectProps) => { - const { - reset, - handleSubmit, - formState: { errors, isSubmitting }, - register - } = useForm({ - resolver: yupResolver(projectSchema) - }); - let { projectId } = useParams({ from: '/projects/$projectId' }); - const { mutateAsync: updateProject } = useMutation(FrontierServiceQueries.updateProject, { - onSuccess: () => toast.success('Project updated successfully'), - onError: (error: Error) => - toast.error('Something went wrong', { description: error.message }) - }); - - useEffect(() => { - reset(project); - }, [reset, project]); - - const resource = `app/project:${projectId}`; - const listOfPermissionsToCheck = useMemo( - () => [ - { - permission: PERMISSIONS.UpdatePermission, - resource - }, - { - permission: PERMISSIONS.DeletePermission, - resource - } - ], - [resource] - ); - - const { permissions, isFetching: isPermissionsFetching } = usePermissions( - listOfPermissionsToCheck, - !!projectId - ); - - const { canUpdateProject, canDeleteProject } = useMemo(() => { - return { - canUpdateProject: shouldShowComponent( - permissions, - `${PERMISSIONS.UpdatePermission}::${resource}` - ), - canDeleteProject: shouldShowComponent( - permissions, - `${PERMISSIONS.DeletePermission}::${resource}` - ) - }; - }, [permissions, resource]); - - async function onSubmit(data: FormData) { - if (!organization?.id) return; - if (!projectId) return; - - await updateProject( - create(UpdateProjectRequestSchema, { - id: projectId, - body: { - name: data.name, - title: data.title, - orgId: organization.id - } - }) - ); - } - - const isLoading = isPermissionsFetching || isProjectLoading; - - return ( - -
- - {isLoading ? ( -
- - -
- ) : ( - - )} - {isLoading ? ( -
- - -
- ) : ( - - )} - {isLoading ? ( - - ) : ( - - - - )} -
-
- - - -
- ); -}; - -interface GeneralDeleteProjectProps extends GeneralProjectProps { - canDeleteProject?: boolean; -} - -export const GeneralDeleteProject = ({ - canDeleteProject, - isLoading -}: GeneralDeleteProjectProps) => { - let { projectId } = useParams({ from: '/projects/$projectId' }); - const navigate = useNavigate({ from: '/projects/$projectId' }); - - return ( - - {isLoading ? ( - - ) : ( - - If you want to permanently delete this project and all of its data. - - )}{' '} - {isLoading ? ( - - ) : ( - - - - )} - - ); -}; diff --git a/web/sdk/react/components/organization/project/index.tsx b/web/sdk/react/components/organization/project/index.tsx index 4e5c1dfc5..95cdf0a80 100644 --- a/web/sdk/react/components/organization/project/index.tsx +++ b/web/sdk/react/components/organization/project/index.tsx @@ -1,243 +1,16 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { - Tooltip, - EmptyState, - Skeleton, - Flex, - Button, - Select, - DataTable, - toast -} from '@raystack/apsara'; -import { Outlet, useNavigate, useRouterState } from '@tanstack/react-router'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import { useOrganizationProjects } from '~/react/hooks/useOrganizationProjects'; -import { usePermissions } from '~/react/hooks/usePermissions'; -import { AuthTooltipMessage } from '~/react/utils'; -import { PERMISSIONS, shouldShowComponent } from '~/utils'; -import { getColumns } from './projects.columns'; -import { Project } from '@raystack/proton/frontier'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; -import { PageHeader } from '~/react/components/common/page-header'; -import sharedStyles from '../styles.module.css'; -import styles from './project.module.css'; -import { useTerminology } from '~/react/hooks/useTerminology'; - -const projectsSelectOptions = [ - { value: 'my-projects', label: 'My Projects' }, - { value: 'all-projects', label: 'All Projects' } -]; +import { useNavigate } from '@tanstack/react-router'; +import { ProjectsListPage } from '~/react/views/projects'; export default function WorkspaceProjects() { - const [showOrgProjects, setShowOrgProjects] = useState(false); - const { - isFetching: isProjectsLoading, - projects, - userAccessOnProject, - refetch, - error: projectsError - } = useOrganizationProjects({ - allProjects: showOrgProjects, - withMemberCount: true - }); - const { activeOrganization: organization } = useFrontier(); - const t = useTerminology(); - const routerState = useRouterState(); - - const isListRoute = useMemo(() => { - return routerState.location.pathname === '/projects'; - }, [routerState.location.pathname]); - - const resource = `app/organization:${organization?.id}`; - const listOfPermissionsToCheck = useMemo( - () => [ - { - permission: PERMISSIONS.ProjectCreatePermission, - resource - }, - { - permission: PERMISSIONS.UpdatePermission, - resource - } - ], - [resource] - ); - - const { permissions, isFetching: isPermissionsFetching } = usePermissions( - listOfPermissionsToCheck, - !!organization?.id - ); - - const { canCreateProject, canUpdateOrganization } = useMemo(() => { - return { - canCreateProject: shouldShowComponent( - permissions, - `${PERMISSIONS.ProjectCreatePermission}::${resource}` - ), - canUpdateOrganization: shouldShowComponent( - permissions, - `${PERMISSIONS.UpdatePermission}::${resource}` - ) - }; - }, [permissions, resource]); - - const onOrgProjectsFilterChange = useCallback((value: string) => { - if (value === 'all-projects') { - setShowOrgProjects(true); - } else { - setShowOrgProjects(false); - } - }, []); - - useEffect(() => { - if (isListRoute) { - refetch(); - } - }, [isListRoute, refetch, routerState.location.state.key]); - - useEffect(() => { - if (projectsError) { - toast.error('Something went wrong', { - description: projectsError.message - }); - } - }, [projectsError]); - - const isLoading = isPermissionsFetching || isProjectsLoading; - - return ( - - - - - - - - - - - - ); -} - -interface WorkspaceProjectsProps { - projects: Project[]; - isLoading?: boolean; - canCreateProject?: boolean; - userAccessOnProject: Record; - canListOrgProjects?: boolean; - onOrgProjectsFilterChange?: (value: string) => void; -} - -const ProjectsTable = ({ - projects, - isLoading, - canCreateProject, - userAccessOnProject, - canListOrgProjects, - onOrgProjectsFilterChange -}: WorkspaceProjectsProps) => { const navigate = useNavigate({ from: '/projects' }); - const columns = useMemo( - () => getColumns(userAccessOnProject), - [userAccessOnProject] - ); return ( - - - - - {isLoading ? ( - - ) : ( - - )} - {canListOrgProjects ? ( - - ) : null} - - {isLoading ? ( - - ) : ( - - - - )} - - - - + + navigate({ to: '/projects/$projectId', params: { projectId } }) + } + /> ); -}; - -const noDataChildren = ( - } - heading="No projects found" - subHeading="Get started by creating your first project." - /> -); +} diff --git a/web/sdk/react/components/organization/project/members/index.tsx b/web/sdk/react/components/organization/project/members/index.tsx deleted file mode 100644 index d9af1731c..000000000 --- a/web/sdk/react/components/organization/project/members/index.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import { - CardStackPlusIcon, - PlusIcon, - ExclamationTriangleIcon -} from '@radix-ui/react-icons'; -import type React from 'react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { - Button, - EmptyState, - Tooltip, - toast, - Separator, - Avatar, - Skeleton, - Text, - Flex, - DataTable, - Popover, - Search -} from '@raystack/apsara'; -import { useParams } from '@tanstack/react-router'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import { useOrganizationTeams } from '~/react/hooks/useOrganizationTeams'; -import { usePermissions } from '~/react/hooks/usePermissions'; -import { AuthTooltipMessage } from '~/react/utils'; -import { - PERMISSIONS, - filterUsersfromUsers, - getInitials, - shouldShowComponent -} from '~/utils'; -import { getColumns } from './member.columns'; -import { useQuery, useMutation } from '@connectrpc/connect-query'; -import { FrontierServiceQueries, - ListOrganizationUsersRequestSchema, - CreatePolicyForProjectRequestSchema, - type Group, - type User, - type Role, -} from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import styles from './members.module.css'; - -export type MembersProps = { - teams?: Group[]; - members?: User[]; - roles?: Role[]; - memberRoles?: Record; - groupRoles?: Record; - isLoading?: boolean; - refetch: () => void; -}; - -export const Members = ({ - teams = [], - members = [], - roles = [], - memberRoles, - groupRoles, - isLoading: isMemberLoading, - refetch -}: MembersProps) => { - const { projectId } = useParams({ from: '/projects/$projectId' }); - - const resource = `app/project:${projectId}`; - const listOfPermissionsToCheck = useMemo( - () => [ - { - permission: PERMISSIONS.UpdatePermission, - resource - } - ], - [resource] - ); - - const { permissions, isFetching: isPermissionsFetching } = usePermissions( - listOfPermissionsToCheck, - !!projectId - ); - - const { canUpdateProject } = useMemo(() => { - return { - canUpdateProject: shouldShowComponent( - permissions, - `${PERMISSIONS.UpdatePermission}::${resource}` - ) - }; - }, [permissions, resource]); - - const isLoading = isMemberLoading || isPermissionsFetching; - - const columns = useMemo( - () => - getColumns( - memberRoles, - groupRoles, - roles, - canUpdateProject, - projectId, - refetch - ), - [memberRoles, groupRoles, roles, canUpdateProject, projectId, refetch] - ); - - const updatedUsers = useMemo(() => { - const updatedTeams = teams.map(t => ({ ...t, isTeam: true })); - return members?.length || updatedTeams?.length - ? [...updatedTeams, ...members] - : []; - }, [members, teams]); - - return ( - - - - - - - - {isLoading ? ( - - ) : ( - - - - )} - - - - - - ); -}; - -interface AddMemberDropdownProps { - canUpdateProject: boolean; - members?: User[]; - refetch?: () => void; -} - -const AddMemberDropdown = ({ - canUpdateProject, - members, - refetch -}: AddMemberDropdownProps) => { - const { projectId } = useParams({ from: '/projects/$projectId' }); - const [query, setQuery] = useState(''); - const [showTeam, setShowTeam] = useState(false); - - const { activeOrganization: organization } = useFrontier(); - const { isFetching: isTeamsLoading, teams } = useOrganizationTeams({}); - - const toggleShowTeam = (e: React.MouseEvent) => { - e.preventDefault(); - setQuery(''); - setShowTeam(prev => !prev); - }; - - const { data: orgUsersData, isLoading: isOrgUsersLoading, error: orgUsersError } = useQuery( - FrontierServiceQueries.listOrganizationUsers, - create(ListOrganizationUsersRequestSchema, { id: organization?.id || '' }), - { enabled: !!organization?.id && canUpdateProject } - ); - - const orgUsersResp = useMemo(() => orgUsersData?.users ?? [], [orgUsersData]); - - useEffect(() => { - if (orgUsersError) { - toast.error('Something went wrong', { description: orgUsersError.message }); - } - }, [orgUsersError]); - - const invitableUser = useMemo( - () => filterUsersfromUsers(orgUsersResp || [], members) || [], - [orgUsersResp, members] - ); - - const topUsers = useMemo( - () => - invitableUser - .filter(user => - query - ? user.title?.toLowerCase().includes(query.toLowerCase()) || - user.email?.includes(query) - : true - ) - .slice(0, 7), - [invitableUser, query] - ); - - const topTeams = useMemo(() => - teams - .filter(team => - query - ? team.title && team.title.toLowerCase().includes(query.toLowerCase()) - : true - ) - .slice(0, 7), - [query, teams]); - - function onTextChange(e: React.ChangeEvent) { - setQuery(e.target.value); - } - - const { mutate: createPolicyForProject, isPending: isCreatingPolicy } = useMutation( - FrontierServiceQueries.createPolicyForProject, - { - onSuccess: () => { - toast.success('Member added'); - if (refetch) refetch(); - }, - onError: (err: Error) => { - toast.error('Something went wrong', { description: err.message }); - } - } - ); - - const addMember = useCallback( - (userId: string) => { - if (!userId || !organization?.id || !projectId) return; - const principal = `${PERMISSIONS.UserNamespace}:${userId}`; - createPolicyForProject( - create(CreatePolicyForProjectRequestSchema, { - projectId: projectId, - body: { roleId: PERMISSIONS.RoleProjectViewer, principal } - }) - ); - }, - [createPolicyForProject, organization?.id, projectId] - ); - - const addTeam = useCallback( - (teamId: string) => { - if (!teamId || !organization?.id || !projectId) return; - const principal = `${PERMISSIONS.GroupNamespace}:${teamId}`; - createPolicyForProject( - create(CreatePolicyForProjectRequestSchema, { - projectId: projectId, - body: { roleId: PERMISSIONS.RoleProjectViewer, principal } - }) - ); - }, - [createPolicyForProject, organization?.id, projectId] - ); - - return ( - - - - - - setQuery('')} - /> - - - {showTeam ? ( - isTeamsLoading ? ( - - ) : topTeams.length ? ( -
- {topTeams.map((team, i) => { - const initals = getInitials(team?.title || team.name); - return ( - addTeam(team?.id || '')} - className={styles.inviteDropdownItem} - data-test-id={`frontier-sdk-add-team-to-project-dropdown-item-${i}`} - > - - {team?.title || team?.name} - - ); - })} -
- ) : ( - - No Teams found - - ) - ) : isOrgUsersLoading ? ( - - ) : topUsers.length ? ( -
- {topUsers.map((user, i) => { - const initals = getInitials(user?.title || user.email); - return ( - addMember(user?.id || '')} - data-test-id={`frontier-sdk-add-user-to-project-dropdown-item-${i}`} - > - - {user?.title || user?.email} - - ); - })} -
- ) : ( - - No Users found - - )} - - -
- - {showTeam ? ( - <> - {' '} - Add project member - - ) : ( - <> - {' '} - Add team to project - - )} - -
-
-
- ); -}; - -const noDataChildren = ( - } - heading="No members found" - subHeading="Get started by adding your first member." - /> -); diff --git a/web/sdk/react/components/organization/project/members/member.columns.tsx b/web/sdk/react/components/organization/project/members/member.columns.tsx deleted file mode 100644 index c5da07e30..000000000 --- a/web/sdk/react/components/organization/project/members/member.columns.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { useEffect, useMemo } from 'react'; -import { - DotsHorizontalIcon, - TrashIcon, - UpdateIcon -} from '@radix-ui/react-icons'; -import { - Avatar, - Label, - Text, - Flex, - toast, - DropdownMenu, - type DataTableColumnDef, - getAvatarColor -} from '@raystack/apsara'; -import { useNavigate } from '@tanstack/react-router'; -import { useQuery, useMutation } from '@connectrpc/connect-query'; -import { - FrontierServiceQueries, - ListPoliciesRequestSchema, - DeletePolicyRequestSchema, - CreatePolicyRequestSchema, - type Role, - type User, - type Group -} from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import { differenceWith, getInitials, isEqualById } from '~/utils'; - -import teamIcon from '~/react/assets/users.svg'; - -type RowMember = (User & { isTeam?: false }) | (Group & { isTeam: true }); - -export const getColumns = ( - memberRoles: Record = {}, - groupRoles: Record = {}, - roles: Role[] = [], - canUpdateProject: boolean, - projectId: string, - refetch: () => void -): DataTableColumnDef[] => [ - { - header: '', - accessorKey: 'avatar', - enableSorting: false, - styles: { - cell: { - width: 'var(--rs-space-5)' - } - }, - cell: ({ row, getValue }) => { - const avatarSrc = row.original?.isTeam ? teamIcon : getValue(); - const fallback = row.original?.isTeam - ? '' - : getInitials(row.original?.title || row.original?.email); - const color = getAvatarColor(row?.original?.id || ''); - return ( - - ); - } - }, - { - header: 'Title', - accessorKey: 'title', - cell: ({ row, getValue }) => { - const label = row.original?.isTeam - ? row.original.title - : (getValue() as string); - const subLabel = row.original?.isTeam - ? row.original.name - : row.original.email; - - return ( - - - {subLabel} - - ); - } - }, - { - header: 'Roles', - accessorKey: 'email', - cell: ({ row }) => { - return ( - - {row.original?.isTeam - ? // hardcoding roles as we dont have team roles and team are invited as viewer and we dont allow role change - (row.original?.id && - groupRoles[row.original?.id] && - groupRoles[row.original?.id] - .map((r: Role) => r.title || r.name) - .join(', ')) ?? - 'Project Viewer' - : (row.original?.id && - memberRoles[row.original?.id] && - memberRoles[row.original?.id] - .map((r: Role) => r.title || r.name) - .join(', ')) ?? - 'Inherited role'} - - ); - } - }, - { - header: '', - accessorKey: 'id', - enableSorting: false, - cell: ({ row }) => ( - ( - isEqualById, - roles, - row.original.isTeam - ? row.original?.id && groupRoles[row.original?.id] - ? groupRoles[row.original?.id] - : [] - : row.original?.id && memberRoles[row.original?.id] - ? memberRoles[row.original?.id] - : [] - )} - /> - ) - } -]; - -const MembersActions = ({ - projectId, - member, - canUpdateProject, - excludedRoles = [], - refetch = () => null -}: { - projectId: string; - member: RowMember; - canUpdateProject?: boolean; - excludedRoles: Role[]; - refetch: () => void; -}) => { - const navigate = useNavigate({ from: '/projects' }); - - function removeMember() { - navigate({ - to: '/projects/$projectId/$membertype/$memberId/remove', - params: { - projectId: projectId, - membertype: member?.isTeam ? 'team' : 'user', - memberId: member?.id as string - } - }); - } - - const { data: policiesData, refetch: refetchPolicies, error: policiesError } = useQuery( - FrontierServiceQueries.listPolicies, - create(ListPoliciesRequestSchema, { - projectId: projectId, - userId: member.isTeam ? undefined : (member.id as string), - groupId: member.isTeam ? (member.id as string) : undefined - }), - { enabled: !!projectId && !!member?.id } - ); - - const policies = useMemo(() => policiesData?.policies ?? [], [policiesData]); - - const { mutateAsync: deletePolicy } = useMutation(FrontierServiceQueries.deletePolicy, { - onError: (err: Error) => - toast.error('Something went wrong', { description: err.message }) - }); - const { mutateAsync: createPolicy } = useMutation(FrontierServiceQueries.createPolicy, { - onError: (err: Error) => - toast.error('Something went wrong', { description: err.message }) - }); - - useEffect(() => { - if (policiesError) { - toast.error('Something went wrong', { description: (policiesError as Error).message }); - } - }, [policiesError]); - - async function updateRole(role: Role) { - try { - const resource = `app/project:${projectId}`; - const principal = member.isTeam - ? `app/group:${member?.id}` - : `app/user:${member?.id}`; - - await Promise.all( - (policies || []).map(p => - deletePolicy(create(DeletePolicyRequestSchema, { id: p.id || '' })) - ) - ); - - await createPolicy( - create(CreatePolicyRequestSchema, { - body: { - roleId: role.id as string, - title: (role.title || role.name) as string, - resource, - principal - } - }) - ); - await refetchPolicies(); - refetch(); - toast.success('Project member role updated'); - } catch (err) { - const message = (err as Error)?.message || 'Failed to update role'; - toast.error('Something went wrong', { description: message }); - } - } - - return canUpdateProject ? ( - - - - - {/* @ts-ignore */} - - - {excludedRoles.map((role: Role) => ( - updateRole(role)} - data-test-id="frontier-sdk-update-project-member-role-btn" - > - - Make {role.title} - - ))} - removeMember()} - > - - Remove from project - - - - - ) : null; -}; diff --git a/web/sdk/react/components/organization/project/members/members.module.css b/web/sdk/react/components/organization/project/members/members.module.css deleted file mode 100644 index a5ecc9574..000000000 --- a/web/sdk/react/components/organization/project/members/members.module.css +++ /dev/null @@ -1,46 +0,0 @@ -.inviteDropdownItem { - padding: var(--rs-space-3); - user-select: none; - cursor: pointer; - background-color: var(--rs-color-background-base-primary); - color: var(--rs-color-foreground-base-primary); -} - -.inviteDropdownItem:hover { - background-color: var(--rs-color-background-base-primary-hover); -} - -.popoverContent { - padding: 0; - min-width: 300px; - pointer-events: auto; -} - -.tableWrapper { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - overflow: hidden; -} - -.tableRoot { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - min-height: 0; -} - -.tableHeader { - z-index: 1; -} - -.tableSearchWrapper { - max-width: 50%; - flex: 1; -} - -.container { - margin-top: var(--rs-space-5); - height: calc(100% - var(--rs-space-5)); -} diff --git a/web/sdk/react/components/organization/project/members/remove.tsx b/web/sdk/react/components/organization/project/members/remove.tsx deleted file mode 100644 index 1b8acdfa9..000000000 --- a/web/sdk/react/components/organization/project/members/remove.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { Button, Flex, Text, toast, Image, Dialog } from '@raystack/apsara'; -import { useNavigate, useParams } from '@tanstack/react-router'; -import { useEffect, useMemo } from 'react'; -import { useQuery, useMutation, createConnectQueryKey, useTransport } from '@connectrpc/connect-query'; -import { useQueryClient } from '@tanstack/react-query'; -import { FrontierServiceQueries, ListPoliciesRequestSchema, DeletePolicyRequestSchema, ListProjectUsersRequestSchema, ListProjectGroupsRequestSchema } from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import cross from '~/react/assets/cross.svg'; -import styles from '../../organization.module.css'; - -export const RemoveProjectMember = () => { - const navigate = useNavigate({ - from: '/projects/$projectId/$membertype/$memberId/remove' - }); - const queryClient = useQueryClient(); - const transport = useTransport(); - - const { projectId, memberId } = useParams({ - from: '/projects/$projectId/$membertype/$memberId/remove' - }); - - const { data: policiesData, error: policiesError } = useQuery( - FrontierServiceQueries.listPolicies, - create(ListPoliciesRequestSchema, { - projectId: projectId || '', - userId: memberId || '' - }), - { enabled: !!projectId && !!memberId } - ); - - const policies = useMemo(() => policiesData?.policies ?? [], [policiesData]); - - useEffect(() => { - if (policiesError) { - toast.error('Something went wrong', { description: (policiesError as Error).message }); - } - }, [policiesError]); - - const { mutateAsync: deletePolicy, isPending } = useMutation(FrontierServiceQueries.deletePolicy, { - onError: (err: Error) => - toast.error('Something went wrong', { description: err.message }) - }); - - async function onConfirm() { - try { - await Promise.all( - (policies || []).map(p => deletePolicy(create(DeletePolicyRequestSchema, { id: p.id || '' }))) - ); - // Invalidate and refetch project users and groups queries after all policies are deleted - if (projectId) { - await queryClient.invalidateQueries({ - queryKey: createConnectQueryKey({ - schema: FrontierServiceQueries.listProjectUsers, - transport, - input: create(ListProjectUsersRequestSchema, { - id: projectId, - withRoles: true - }), - cardinality: 'finite' - }) - }); - await queryClient.invalidateQueries({ - queryKey: createConnectQueryKey({ - schema: FrontierServiceQueries.listProjectGroups, - transport, - input: create(ListProjectGroupsRequestSchema, { - id: projectId, - withRoles: true - }), - cardinality: 'finite' - }) - }); - } - navigate({ to: '/projects/$projectId', params: { projectId } }); - toast.success('Member removed'); - } catch (error) { - // Error is already handled by mutation's onError callback - // This catch prevents unhandled promise rejection - console.error('Failed to delete policies:', error); - } - } - - return ( - - - - - - Remove project member - - cross - navigate({ - to: '/projects/$projectId', - params: { projectId } - }) - } - style={{ cursor: 'pointer' }} - /> - - - - - - - Are you sure you want to remove this member from the project? - - - - - - - - - - - - - ); -}; diff --git a/web/sdk/react/components/organization/project/project.module.css b/web/sdk/react/components/organization/project/project.module.css deleted file mode 100644 index ad3613f12..000000000 --- a/web/sdk/react/components/organization/project/project.module.css +++ /dev/null @@ -1,27 +0,0 @@ -.tableWrapper { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - overflow: hidden; -} - -.tableRoot { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - min-height: 0; -} - -.tableHeader { - z-index: 1; -} - -.tableSearchWrapper { - max-width: 500px; - flex: 1; -} - -.tabContent { - height: 100%; -} diff --git a/web/sdk/react/components/organization/project/project.tsx b/web/sdk/react/components/organization/project/project.tsx index 20b51c066..74b066ad3 100644 --- a/web/sdk/react/components/organization/project/project.tsx +++ b/web/sdk/react/components/organization/project/project.tsx @@ -1,207 +1,16 @@ -import { Tabs, Image, toast, Flex } from '@raystack/apsara'; -import { - Outlet, - useNavigate, - useParams, - useRouterState -} from '@tanstack/react-router'; -import { useCallback, useEffect, useMemo } from 'react'; -import backIcon from '~/react/assets/chevron-left.svg'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import { useQuery } from '@connectrpc/connect-query'; -import { FrontierServiceQueries, - ListProjectGroupsRequestSchema, - ListProjectUsersRequestSchema, - GetProjectRequestSchema, - ListRolesRequestSchema, - type Role as ProtoRole, - type Organization -} from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import { PERMISSIONS } from '~/utils'; -import { General } from './general'; -import { Members } from './members'; -import { PageHeader } from '~/react/components/common/page-header'; -import sharedStyles from '../styles.module.css'; -import styles from './project.module.css'; +'use client'; -interface ProjectGroupRolePair { - groupId?: string; - roles: ProtoRole[]; -} - -interface ProjectUserRolePair { - userId?: string; - roles: ProtoRole[]; -} +import { useNavigate, useParams } from '@tanstack/react-router'; +import { ProjectDetailPage } from '~/react/views/projects'; export const ProjectPage = () => { - let { projectId } = useParams({ from: '/projects/$projectId' }); - - const { activeOrganization: organization } = useFrontier(); - let navigate = useNavigate({ from: '/projects/$projectId' }); - const routeState = useRouterState(); - - const isDeleteRoute = useMemo(() => { - return routeState.matches.some( - route => route.routeId === '/projects/$projectId/delete' + const { projectId } = useParams({ from: '/projects/$projectId' }); + const navigate = useNavigate({ from: '/projects/$projectId' }); + + return ( + navigate({ to: '/projects' })} + /> ); - }, [routeState.matches]); - - const { - data: projectGroupsData, - isLoading: isTeamsLoading, - error: projectGroupsError, - refetch: refetchProjectGroups - } = useQuery( - FrontierServiceQueries.listProjectGroups, - create(ListProjectGroupsRequestSchema, { - id: projectId || '', - withRoles: true - }), - { - enabled: !!organization?.id && !!projectId && !isDeleteRoute - } - ); - - const projectGroups = useMemo(() => ({ - groups: projectGroupsData?.groups ?? [], - groupRoles: (projectGroupsData?.rolePairs ?? []).reduce((acc: Record, gr: ProjectGroupRolePair) => { - const key = gr.groupId; - if (key) acc[key] = gr.roles; - return acc; - }, {}) - }), [projectGroupsData]); - - useEffect(() => { - if (projectGroupsError) { - toast.error('Something went wrong', { - description: projectGroupsError.message - }); - } - }, [projectGroupsError]); - - const { - data: projectUsersData, - isLoading: isMembersLoadingQuery, - refetch: refetchProjectUsers - } = useQuery( - FrontierServiceQueries.listProjectUsers, - create(ListProjectUsersRequestSchema, { - id: projectId || '', - withRoles: true - }), - { - enabled: !!organization?.id && !!projectId && !isDeleteRoute - } - ); - - const projectUsers = useMemo(() => ({ - users: projectUsersData?.users ?? [], - memberRoles: (projectUsersData?.rolePairs ?? []).reduce((acc: Record, mr: ProjectUserRolePair) => { - const key = mr.userId; - if (key) acc[key] = mr.roles; - return acc; - }, {}) - }), [projectUsersData]); - - const { - data: project, - isLoading: isProjectLoadingQuery, - error: projectError - } = useQuery( - FrontierServiceQueries.getProject, - create(GetProjectRequestSchema, { id: projectId || '' }), - { - enabled: !!organization?.id && !!projectId && !isDeleteRoute, - select: d => d?.project - } - ); - - useEffect(() => { - if (projectError) { - toast.error('Something went wrong', { description: projectError.message }); - } - }, [projectError]); - - const { - data: rolesData, - isLoading: isProjectRoleLoadingQuery, - error: rolesError - } = useQuery( - FrontierServiceQueries.listRoles, - create(ListRolesRequestSchema, { - state: 'enabled', - scopes: [PERMISSIONS.ProjectNamespace] - }), - { - enabled: !!organization?.id && !!projectId && !isDeleteRoute - } - ); - - const roles = useMemo(() => rolesData?.roles ?? [], [rolesData]); - - useEffect(() => { - if (rolesError) { - toast.error('Something went wrong', { description: rolesError.message }); - } - }, [rolesError]); - - const isLoading = - isProjectLoadingQuery || - isTeamsLoading || - isMembersLoadingQuery || - isProjectRoleLoadingQuery; - - const refetchTeamAndMembers = useCallback(() => { - refetchProjectUsers(); - refetchProjectGroups(); - }, [refetchProjectUsers, refetchProjectGroups]); - - return ( - - - - - back-icon navigate({ to: '/projects' })} - data-test-id="frontier-sdk-projects-page-back-link" - /> - - - - - - General - Members - - - - - - - - - - - - ); }; diff --git a/web/sdk/react/components/organization/project/projects.columns.tsx b/web/sdk/react/components/organization/project/projects.columns.tsx deleted file mode 100644 index e4db0f803..000000000 --- a/web/sdk/react/components/organization/project/projects.columns.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { - DotsHorizontalIcon, - Pencil1Icon, - TrashIcon -} from '@radix-ui/react-icons'; -import { Text, DropdownMenu } from '@raystack/apsara'; -import { Link } from '@tanstack/react-router'; -import type { Project } from '@raystack/proton/frontier'; -import type { DataTableColumnDef } from '@raystack/apsara'; - -export const getColumns: ( - userAccessOnProject: Record -) => DataTableColumnDef[] = userAccessOnProject => [ - { - header: 'Title', - accessorKey: 'title', - cell: ({ row, getValue }) => { - return ( - - {getValue() as string} - - ); - } - }, - // { - // header: 'Privacy', - // accessorKey: 'privacy', - // cell: isLoading - // ? () => - // : info => {info.getValue() ?? 'Public'} - // }, - { - header: 'Members', - accessorKey: 'membersCount', - cell: ({ row, getValue }) => { - const value = getValue() as string; - return value ? {value} members : null; - } - }, - { - header: '', - accessorKey: 'id', - meta: { - style: { - textAlign: 'end' - } - }, - enableSorting: false, - cell: ({ row, getValue }) => ( - - ) - } -]; - -const ProjectActions = ({ - project, - userAccessOnProject -}: { - project: Project; - userAccessOnProject: Record; -}) => { - const canUpdateProject = (userAccessOnProject[project.id!] ?? []).includes( - 'update' - ); - const canDeleteProject = (userAccessOnProject[project.id!] ?? []).includes( - 'delete' - ); - const canDoActions = canUpdateProject || canDeleteProject; - - return canDoActions ? ( - - - - - {/* @ts-ignore */} - - - {canUpdateProject ? ( - - - Rename - - - ) : null} - {canDeleteProject ? ( - - - Delete project - - - ) : null} - - - - ) : null; -}; diff --git a/web/sdk/react/components/organization/routes.tsx b/web/sdk/react/components/organization/routes.tsx index cee0dd637..b4d158d6c 100644 --- a/web/sdk/react/components/organization/routes.tsx +++ b/web/sdk/react/components/organization/routes.tsx @@ -17,10 +17,7 @@ import WorkspaceMembers from './members'; import UserPreferences from './preferences'; import { default as WorkspaceProjects } from './project'; -import { AddProject } from './project/add'; -import { DeleteProject } from './project/delete'; import { ProjectPage } from './project/project'; -import { RemoveProjectMember } from './project/members/remove'; import WorkspaceSecurity from './security'; import { Sidebar } from './sidebar'; @@ -196,30 +193,12 @@ const projectsRoute = createRoute({ component: WorkspaceProjects }); -const addProjectRoute = createRoute({ - getParentRoute: () => projectsRoute, - path: '/modal', - component: AddProject -}); - const projectPageRoute = createRoute({ getParentRoute: () => rootRoute, path: '/projects/$projectId', component: ProjectPage }); -const deleteProjectRoute = createRoute({ - getParentRoute: () => projectPageRoute, - path: '/delete', - component: DeleteProject -}); - -const removeProjectMemberRoute = createRoute({ - getParentRoute: () => projectPageRoute, - path: '/$membertype/$memberId/remove', - component: RemoveProjectMember -}); - const profileRoute = createRoute({ getParentRoute: () => rootRoute, path: '/profile', @@ -336,11 +315,8 @@ export function getRootTree({ customScreens = [] }: getRootTreeOptions) { deleteDomainRoute ]), teamRoute, - projectsRoute.addChildren([addProjectRoute]), - projectPageRoute.addChildren([ - deleteProjectRoute, - removeProjectMemberRoute - ]), + projectsRoute, + projectPageRoute, profileRoute, preferencesRoute, billingRoute.addChildren([switchBillingCycleModalRoute]), diff --git a/web/sdk/react/views/projects/details/delete-project-dialog.tsx b/web/sdk/react/views/projects/details/delete-project-dialog.tsx new file mode 100644 index 000000000..d5c05e3ec --- /dev/null +++ b/web/sdk/react/views/projects/details/delete-project-dialog.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { + Button, + Checkbox, + toast, + Skeleton, + Image, + Text, + Flex, + Dialog, + InputField +} from '@raystack/apsara'; + +import { yupResolver } from '@hookform/resolvers/yup'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import * as yup from 'yup'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { useQuery, useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + GetProjectRequestSchema, + DeleteProjectRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import cross from '~/react/assets/cross.svg'; +import orgStyles from '../../../components/organization/organization.module.css'; + +const projectSchema = yup + .object({ + title: yup.string() + }) + .required(); + +export interface DeleteProjectDialogProps { + open: boolean; + onOpenChange?: (value: boolean) => void; + projectId: string; + onDeleteSuccess?: () => void; +} + +export const DeleteProjectDialog = ({ + open, + onOpenChange, + projectId, + onDeleteSuccess +}: DeleteProjectDialogProps) => { + const { + watch, + setError, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + register + } = useForm({ + resolver: yupResolver(projectSchema) + }); + const { activeOrganization: organization } = useFrontier(); + const [isAcknowledged, setIsAcknowledged] = useState(false); + + const handleOpenChange = (value: boolean) => { + if (!value) { + reset(); + setIsAcknowledged(false); + } + onOpenChange?.(value); + }; + + const { + data: project, + isLoading: isProjectQueryLoading, + error: projectError + } = useQuery( + FrontierServiceQueries.getProject, + create(GetProjectRequestSchema, { id: projectId || '' }), + { + enabled: !!projectId && open, + select: (d) => d?.project + } + ); + + useEffect(() => { + if (projectError) { + toast.error('Something went wrong', { description: projectError.message }); + } + }, [projectError]); + + const { mutateAsync: deleteProject } = useMutation( + FrontierServiceQueries.deleteProject, + { + onSuccess: () => { + toast.success('Project deleted'); + onDeleteSuccess?.(); + handleOpenChange(false); + }, + onError: (err: Error) => + toast.error('Something went wrong', { description: err.message }) + } + ); + + async function onSubmit(data: { title?: string }) { + if (!organization?.id || !projectId) return; + if (data.title !== project?.title) + return setError('title', { message: 'Project title does not match' }); + await deleteProject(create(DeleteProjectRequestSchema, { id: projectId })); + } + + const title = watch('title', ''); + + return ( + + + + + + Verify project deletion + + cross handleOpenChange(false)} + style={{ cursor: 'pointer' }} + data-test-id="frontier-sdk-delete-project-close-btn" + /> + + + +
+ + {isProjectQueryLoading ? ( + <> + + + + + + + ) : ( + <> + + This action can not be undone. This will permanently delete + project {project?.title}. + + + + + + setIsAcknowledged(v === true)} + data-test-id="frontier-sdk-delete-project-checkbox" + /> + + I acknowledge and understand that all of the project data will be deleted + and want to proceed. + + + + + )} + +
+
+
+
+ ); +}; + diff --git a/web/sdk/react/views/projects/details/index.ts b/web/sdk/react/views/projects/details/index.ts new file mode 100644 index 000000000..5700826a8 --- /dev/null +++ b/web/sdk/react/views/projects/details/index.ts @@ -0,0 +1,9 @@ +export { ProjectDetailPage } from './project-detail-page'; +export type { ProjectDetailPageProps } from './project-detail-page'; + +export { DeleteProjectDialog } from './delete-project-dialog'; +export type { DeleteProjectDialogProps } from './delete-project-dialog'; + +export { RemoveProjectMemberDialog } from './remove-project-member-dialog'; +export type { RemoveProjectMemberDialogProps } from './remove-project-member-dialog'; + diff --git a/web/sdk/react/views/projects/details/project-detail-page.tsx b/web/sdk/react/views/projects/details/project-detail-page.tsx new file mode 100644 index 000000000..b5cca8daf --- /dev/null +++ b/web/sdk/react/views/projects/details/project-detail-page.tsx @@ -0,0 +1,264 @@ +'use client'; + +import { Tabs, Image, toast, Flex } from '@raystack/apsara'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import backIcon from '~/react/assets/chevron-left.svg'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { useQuery } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + ListProjectGroupsRequestSchema, + ListProjectUsersRequestSchema, + GetProjectRequestSchema, + ListRolesRequestSchema, + type Role as ProtoRole, + type Organization +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import { PERMISSIONS } from '~/utils'; +import { ProjectGeneral } from './project-general'; +import { ProjectMembers } from './project-members'; +import { DeleteProjectDialog } from './delete-project-dialog'; +import { RemoveProjectMemberDialog } from './remove-project-member-dialog'; +import { PageHeader } from '~/react/components/common/page-header'; +import sharedStyles from '../../../components/organization/styles.module.css'; +import styles from './project-detail.module.css'; + +interface ProjectGroupRolePair { + groupId?: string; + roles: ProtoRole[]; +} + +interface ProjectUserRolePair { + userId?: string; + roles: ProtoRole[]; +} + +export interface ProjectDetailPageProps { + projectId: string; + onBack?: () => void; +} + +export const ProjectDetailPage = ({ + projectId, + onBack +}: ProjectDetailPageProps) => { + const { activeOrganization: organization } = useFrontier(); + + const [deleteProjectState, setDeleteProjectState] = useState({ open: false }); + const [removeMemberState, setRemoveMemberState] = useState({ + open: false, + memberId: '' + }); + + const { + data: projectGroupsData, + isLoading: isTeamsLoading, + error: projectGroupsError, + refetch: refetchProjectGroups + } = useQuery( + FrontierServiceQueries.listProjectGroups, + create(ListProjectGroupsRequestSchema, { + id: projectId || '', + withRoles: true + }), + { + enabled: !!organization?.id && !!projectId + } + ); + + const projectGroups = useMemo( + () => ({ + groups: projectGroupsData?.groups ?? [], + groupRoles: (projectGroupsData?.rolePairs ?? []).reduce( + (acc: Record, gr: ProjectGroupRolePair) => { + const key = gr.groupId; + if (key) acc[key] = gr.roles; + return acc; + }, + {} + ) + }), + [projectGroupsData] + ); + + useEffect(() => { + if (projectGroupsError) { + toast.error('Something went wrong', { + description: projectGroupsError.message + }); + } + }, [projectGroupsError]); + + const { + data: projectUsersData, + isLoading: isMembersLoadingQuery, + refetch: refetchProjectUsers + } = useQuery( + FrontierServiceQueries.listProjectUsers, + create(ListProjectUsersRequestSchema, { + id: projectId || '', + withRoles: true + }), + { + enabled: !!organization?.id && !!projectId + } + ); + + const projectUsers = useMemo( + () => ({ + users: projectUsersData?.users ?? [], + memberRoles: (projectUsersData?.rolePairs ?? []).reduce( + (acc: Record, mr: ProjectUserRolePair) => { + const key = mr.userId; + if (key) acc[key] = mr.roles; + return acc; + }, + {} + ) + }), + [projectUsersData] + ); + + const { + data: project, + isLoading: isProjectLoadingQuery, + error: projectError + } = useQuery( + FrontierServiceQueries.getProject, + create(GetProjectRequestSchema, { id: projectId || '' }), + { + enabled: !!organization?.id && !!projectId, + select: d => d?.project + } + ); + + useEffect(() => { + if (projectError) { + toast.error('Something went wrong', { + description: projectError.message + }); + } + }, [projectError]); + + const { + data: rolesData, + isLoading: isProjectRoleLoadingQuery, + error: rolesError + } = useQuery( + FrontierServiceQueries.listRoles, + create(ListRolesRequestSchema, { + state: 'enabled', + scopes: [PERMISSIONS.ProjectNamespace] + }), + { + enabled: !!organization?.id && !!projectId + } + ); + + const roles = useMemo(() => rolesData?.roles ?? [], [rolesData]); + + useEffect(() => { + if (rolesError) { + toast.error('Something went wrong', { + description: rolesError.message + }); + } + }, [rolesError]); + + const isLoading = + isProjectLoadingQuery || + isTeamsLoading || + isMembersLoadingQuery || + isProjectRoleLoadingQuery; + + const refetchTeamAndMembers = useCallback(() => { + refetchProjectUsers(); + refetchProjectGroups(); + }, [refetchProjectUsers, refetchProjectGroups]); + + const handleDeleteProjectOpenChange = (value: boolean) => { + setDeleteProjectState({ open: value }); + }; + + const handleRemoveMemberOpenChange = (value: boolean) => { + if (!value) { + setRemoveMemberState({ open: false, memberId: '' }); + refetchTeamAndMembers(); + } else { + setRemoveMemberState(prev => ({ ...prev, open: value })); + } + }; + + const onRemoveMember = (memberId: string) => { + setRemoveMemberState({ open: true, memberId }); + }; + + return ( + + + + + back-icon + + + + + + General + Members + + + handleDeleteProjectOpenChange(true)} + /> + + + + + + + + + + ); +}; + diff --git a/web/sdk/react/views/projects/details/project-detail.module.css b/web/sdk/react/views/projects/details/project-detail.module.css new file mode 100644 index 000000000..fcbc7c82e --- /dev/null +++ b/web/sdk/react/views/projects/details/project-detail.module.css @@ -0,0 +1,8 @@ +.tabContent { + height: 100%; +} + +.container { + box-sizing: border-box; +} + diff --git a/web/sdk/react/views/projects/details/project-general.tsx b/web/sdk/react/views/projects/details/project-general.tsx new file mode 100644 index 000000000..83682df3e --- /dev/null +++ b/web/sdk/react/views/projects/details/project-general.tsx @@ -0,0 +1,227 @@ +'use client'; + +import { + Button, + Separator, + toast, + Tooltip, + Skeleton, + Text, + Flex, + InputField +} from '@raystack/apsara'; + +import { yupResolver } from '@hookform/resolvers/yup'; +import { useEffect, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import * as yup from 'yup'; +import { usePermissions } from '~/react/hooks/usePermissions'; +import { useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + UpdateProjectRequestSchema, + Project, + Organization +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import { PERMISSIONS, shouldShowComponent } from '~/utils'; +import { AuthTooltipMessage } from '~/react/utils'; + +const projectSchema = yup + .object({ + title: yup.string().required(), + name: yup.string().required() + }) + .required(); + +type FormData = yup.InferType; + +interface ProjectGeneralProps { + projectId: string; + project?: Project; + organization?: Organization; + isLoading?: boolean; + onDeleteClick?: () => void; +} + +export const ProjectGeneral = ({ + projectId, + organization, + project, + isLoading: isProjectLoading, + onDeleteClick +}: ProjectGeneralProps) => { + const { + reset, + handleSubmit, + formState: { errors, isSubmitting }, + register + } = useForm({ + resolver: yupResolver(projectSchema) + }); + + const { mutateAsync: updateProject } = useMutation( + FrontierServiceQueries.updateProject, + { + onSuccess: () => toast.success('Project updated successfully'), + onError: (error: Error) => + toast.error('Something went wrong', { description: error.message }) + } + ); + + useEffect(() => { + reset(project); + }, [reset, project]); + + const resource = `app/project:${projectId}`; + const listOfPermissionsToCheck = useMemo( + () => [ + { + permission: PERMISSIONS.UpdatePermission, + resource + }, + { + permission: PERMISSIONS.DeletePermission, + resource + } + ], + [resource] + ); + + const { permissions, isFetching: isPermissionsFetching } = usePermissions( + listOfPermissionsToCheck, + !!projectId + ); + + const { canUpdateProject, canDeleteProject } = useMemo(() => { + return { + canUpdateProject: shouldShowComponent( + permissions, + `${PERMISSIONS.UpdatePermission}::${resource}` + ), + canDeleteProject: shouldShowComponent( + permissions, + `${PERMISSIONS.DeletePermission}::${resource}` + ) + }; + }, [permissions, resource]); + + async function onSubmit(data: FormData) { + if (!organization?.id) return; + if (!projectId) return; + + await updateProject( + create(UpdateProjectRequestSchema, { + id: projectId, + body: { + name: data.name, + title: data.title, + orgId: organization.id + } + }) + ); + } + + const isLoading = isPermissionsFetching || isProjectLoading; + + return ( + +
+ + {isLoading ? ( +
+ + +
+ ) : ( + + )} + {isLoading ? ( +
+ + +
+ ) : ( + + )} + {isLoading ? ( + + ) : ( + + + + )} +
+
+ + + +
+ ); +}; + +interface GeneralDeleteProjectProps { + canDeleteProject?: boolean; + isLoading?: boolean; + onDeleteClick?: () => void; +} + +const GeneralDeleteProject = ({ + canDeleteProject, + isLoading, + onDeleteClick +}: GeneralDeleteProjectProps) => { + return ( + + {isLoading ? ( + + ) : ( + + If you want to permanently delete this project and all of its data. + + )}{' '} + {isLoading ? ( + + ) : ( + + + + )} + + ); +}; + diff --git a/web/sdk/react/views/projects/details/project-member-columns.tsx b/web/sdk/react/views/projects/details/project-member-columns.tsx new file mode 100644 index 000000000..8d77a4a06 --- /dev/null +++ b/web/sdk/react/views/projects/details/project-member-columns.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import { + DotsHorizontalIcon, + TrashIcon, + UpdateIcon +} from '@radix-ui/react-icons'; +import { + Avatar, + Label, + Text, + Flex, + toast, + DropdownMenu, + type DataTableColumnDef, + getAvatarColor +} from '@raystack/apsara'; +import { useQuery, useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + ListPoliciesRequestSchema, + DeletePolicyRequestSchema, + CreatePolicyRequestSchema, + type Role, + type User, + type Group +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import { differenceWith, getInitials, isEqualById } from '~/utils'; + +import teamIcon from '~/react/assets/users.svg'; + +type RowMember = (User & { isTeam?: false }) | (Group & { isTeam: true }); + +export const getColumns = ( + memberRoles: Record = {}, + groupRoles: Record = {}, + roles: Role[] = [], + canUpdateProject: boolean, + projectId: string, + refetch: () => void, + onRemoveMember?: (memberId: string) => void +): DataTableColumnDef[] => [ + { + header: '', + accessorKey: 'avatar', + enableSorting: false, + styles: { + cell: { + width: 'var(--rs-space-5)' + } + }, + cell: ({ row, getValue }) => { + const avatarSrc = row.original?.isTeam ? teamIcon : getValue(); + const fallback = row.original?.isTeam + ? '' + : getInitials(row.original?.title || row.original?.email); + const color = getAvatarColor(row?.original?.id || ''); + return ( + + ); + } + }, + { + header: 'Title', + accessorKey: 'title', + cell: ({ row, getValue }) => { + const label = row.original?.isTeam + ? row.original.title + : (getValue() as string); + const subLabel = row.original?.isTeam + ? row.original.name + : row.original.email; + + return ( + + + {subLabel} + + ); + } + }, + { + header: 'Roles', + accessorKey: 'email', + cell: ({ row }) => { + return ( + + {row.original?.isTeam + ? (row.original?.id && + groupRoles[row.original?.id] && + groupRoles[row.original?.id] + .map((r: Role) => r.title || r.name) + .join(', ')) ?? + 'Project Viewer' + : (row.original?.id && + memberRoles[row.original?.id] && + memberRoles[row.original?.id] + .map((r: Role) => r.title || r.name) + .join(', ')) ?? + 'Inherited role'} + + ); + } + }, + { + header: '', + accessorKey: 'id', + enableSorting: false, + cell: ({ row }) => ( + ( + isEqualById, + roles, + row.original.isTeam + ? row.original?.id && groupRoles[row.original?.id] + ? groupRoles[row.original?.id] + : [] + : row.original?.id && memberRoles[row.original?.id] + ? memberRoles[row.original?.id] + : [] + )} + onRemoveMember={onRemoveMember} + /> + ) + } +]; + +const MembersActions = ({ + projectId, + member, + canUpdateProject, + excludedRoles = [], + refetch = () => null, + onRemoveMember +}: { + projectId: string; + member: RowMember; + canUpdateProject?: boolean; + excludedRoles: Role[]; + refetch: () => void; + onRemoveMember?: (memberId: string) => void; +}) => { + function removeMember() { + onRemoveMember?.(member?.id as string); + } + + const { data: policiesData, refetch: refetchPolicies, error: policiesError } = useQuery( + FrontierServiceQueries.listPolicies, + create(ListPoliciesRequestSchema, { + projectId: projectId, + userId: member.isTeam ? undefined : (member.id as string), + groupId: member.isTeam ? (member.id as string) : undefined + }), + { enabled: !!projectId && !!member?.id } + ); + + useEffect(() => { + if (policiesError) { + toast.error('Something went wrong', { description: (policiesError as Error).message }); + } + }, [policiesError]); + + const policies = useMemo(() => policiesData?.policies ?? [], [policiesData]); + + const { mutateAsync: deletePolicy } = useMutation( + FrontierServiceQueries.deletePolicy, + { + onError: (err: Error) => + toast.error('Something went wrong', { description: err.message }) + } + ); + const { mutateAsync: createPolicy } = useMutation( + FrontierServiceQueries.createPolicy, + { + onError: (err: Error) => + toast.error('Something went wrong', { description: err.message }) + } + ); + + async function updateRole(role: Role) { + try { + const resource = `app/project:${projectId}`; + const principal = member.isTeam + ? `app/group:${member?.id}` + : `app/user:${member?.id}`; + + await Promise.all( + (policies || []).map(p => + deletePolicy(create(DeletePolicyRequestSchema, { id: p.id || '' })) + ) + ); + + await createPolicy( + create(CreatePolicyRequestSchema, { + body: { + roleId: role.id as string, + title: (role.title || role.name) as string, + resource, + principal + } + }) + ); + await refetchPolicies(); + refetch(); + toast.success('Project member role updated'); + } catch (err) { + const message = (err as Error)?.message || 'Failed to update role'; + toast.error('Something went wrong', { description: message }); + } + } + + return canUpdateProject ? ( + + + + + {/* @ts-ignore */} + + + {excludedRoles.map((role: Role) => ( + updateRole(role)} + data-test-id="frontier-sdk-update-project-member-role-btn" + > + + Make {role.title} + + ))} + removeMember()} + > + + Remove from project + + + + + ) : null; +}; + diff --git a/web/sdk/react/views/projects/details/project-members.module.css b/web/sdk/react/views/projects/details/project-members.module.css new file mode 100644 index 000000000..abe7d3c2f --- /dev/null +++ b/web/sdk/react/views/projects/details/project-members.module.css @@ -0,0 +1,47 @@ +.inviteDropdownItem { + padding: var(--rs-space-3); + user-select: none; + cursor: pointer; + background-color: var(--rs-color-background-base-primary); + color: var(--rs-color-foreground-base-primary); +} + +.inviteDropdownItem:hover { + background-color: var(--rs-color-background-base-primary-hover); +} + +.popoverContent { + padding: 0; + min-width: 300px; + pointer-events: auto; +} + +.tableWrapper { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.tableRoot { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; +} + +.tableHeader { + z-index: 1; +} + +.tableSearchWrapper { + max-width: 50%; + flex: 1; +} + +.container { + margin-top: var(--rs-space-5); + height: calc(100% - var(--rs-space-5)); +} + diff --git a/web/sdk/react/views/projects/details/project-members.tsx b/web/sdk/react/views/projects/details/project-members.tsx new file mode 100644 index 000000000..a3c273f8b --- /dev/null +++ b/web/sdk/react/views/projects/details/project-members.tsx @@ -0,0 +1,404 @@ +'use client'; + +import { + CardStackPlusIcon, + PlusIcon, + ExclamationTriangleIcon +} from '@radix-ui/react-icons'; +import type React from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Button, + EmptyState, + Tooltip, + toast, + Separator, + Avatar, + Skeleton, + Text, + Flex, + DataTable, + Popover, + Search +} from '@raystack/apsara'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { useOrganizationTeams } from '~/react/hooks/useOrganizationTeams'; +import { usePermissions } from '~/react/hooks/usePermissions'; +import { AuthTooltipMessage } from '~/react/utils'; +import { + PERMISSIONS, + filterUsersfromUsers, + getInitials, + shouldShowComponent +} from '~/utils'; +import { getColumns } from './project-member-columns'; +import { useQuery, useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + ListOrganizationUsersRequestSchema, + CreatePolicyForProjectRequestSchema, + type Group, + type User, + type Role +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import styles from './project-members.module.css'; + +export type ProjectMembersProps = { + projectId: string; + teams?: Group[]; + members?: User[]; + roles?: Role[]; + memberRoles?: Record; + groupRoles?: Record; + isLoading?: boolean; + refetch: () => void; + onRemoveMember?: (memberId: string) => void; +}; + +export const ProjectMembers = ({ + projectId, + teams = [], + members = [], + roles = [], + memberRoles, + groupRoles, + isLoading: isMemberLoading, + refetch, + onRemoveMember +}: ProjectMembersProps) => { + const resource = `app/project:${projectId}`; + const listOfPermissionsToCheck = useMemo( + () => [ + { + permission: PERMISSIONS.UpdatePermission, + resource + } + ], + [resource] + ); + + const { permissions, isFetching: isPermissionsFetching } = usePermissions( + listOfPermissionsToCheck, + !!projectId + ); + + const { canUpdateProject } = useMemo(() => { + return { + canUpdateProject: shouldShowComponent( + permissions, + `${PERMISSIONS.UpdatePermission}::${resource}` + ) + }; + }, [permissions, resource]); + + const isLoading = isMemberLoading || isPermissionsFetching; + + const columns = useMemo( + () => + getColumns( + memberRoles, + groupRoles, + roles, + canUpdateProject, + projectId, + refetch, + onRemoveMember + ), + [memberRoles, groupRoles, roles, canUpdateProject, projectId, refetch, onRemoveMember] + ); + + const updatedUsers = useMemo(() => { + const updatedTeams = teams.map(t => ({ ...t, isTeam: true })); + return members?.length || updatedTeams?.length + ? [...updatedTeams, ...members] + : []; + }, [members, teams]); + + return ( + + + + + + + + {isLoading ? ( + + ) : ( + + + + )} + + + + + + ); +}; + +interface AddMemberDropdownProps { + projectId: string; + canUpdateProject: boolean; + members?: User[]; + refetch?: () => void; +} + +const AddMemberDropdown = ({ + projectId, + canUpdateProject, + members, + refetch +}: AddMemberDropdownProps) => { + const [query, setQuery] = useState(''); + const [showTeam, setShowTeam] = useState(false); + + const { activeOrganization: organization } = useFrontier(); + const { isFetching: isTeamsLoading, teams } = useOrganizationTeams({}); + + const toggleShowTeam = (e: React.MouseEvent) => { + e.preventDefault(); + setQuery(''); + setShowTeam(prev => !prev); + }; + + const { data: orgUsersData, isLoading: isOrgUsersLoading, error: orgUsersError } = useQuery( + FrontierServiceQueries.listOrganizationUsers, + create(ListOrganizationUsersRequestSchema, { id: organization?.id || '' }), + { enabled: !!organization?.id && canUpdateProject } + ); + + const orgUsersResp = useMemo(() => orgUsersData?.users ?? [], [orgUsersData]); + + useEffect(() => { + if (orgUsersError) { + toast.error('Something went wrong', { description: orgUsersError.message }); + } + }, [orgUsersError]); + + const invitableUser = useMemo( + () => filterUsersfromUsers(orgUsersResp || [], members) || [], + [orgUsersResp, members] + ); + + const topUsers = useMemo( + () => + invitableUser + .filter(user => + query + ? user.title?.toLowerCase().includes(query.toLowerCase()) || + user.email?.includes(query) + : true + ) + .slice(0, 7), + [invitableUser, query] + ); + + const topTeams = useMemo(() => + teams + .filter(team => + query + ? team.title && team.title.toLowerCase().includes(query.toLowerCase()) + : true + ) + .slice(0, 7), + [query, teams]); + + function onTextChange(e: React.ChangeEvent) { + setQuery(e.target.value); + } + + const { mutate: createPolicyForProject, isPending: isCreatingPolicy } = useMutation( + FrontierServiceQueries.createPolicyForProject, + { + onSuccess: () => { + toast.success('Member added'); + if (refetch) refetch(); + }, + onError: (err: Error) => { + toast.error('Something went wrong', { description: err.message }); + } + } + ); + + const addMember = useCallback( + (userId: string) => { + if (!userId || !organization?.id || !projectId) return; + const principal = `${PERMISSIONS.UserNamespace}:${userId}`; + createPolicyForProject( + create(CreatePolicyForProjectRequestSchema, { + projectId: projectId, + body: { roleId: PERMISSIONS.RoleProjectViewer, principal } + }) + ); + }, + [createPolicyForProject, organization?.id, projectId] + ); + + const addTeam = useCallback( + (teamId: string) => { + if (!teamId || !organization?.id || !projectId) return; + const principal = `${PERMISSIONS.GroupNamespace}:${teamId}`; + createPolicyForProject( + create(CreatePolicyForProjectRequestSchema, { + projectId: projectId, + body: { roleId: PERMISSIONS.RoleProjectViewer, principal } + }) + ); + }, + [createPolicyForProject, organization?.id, projectId] + ); + + return ( + + + + + + setQuery('')} + /> + + + {showTeam ? ( + isTeamsLoading ? ( + + ) : topTeams.length ? ( +
+ {topTeams.map((team, i) => { + const initals = getInitials(team?.title || team.name); + return ( + addTeam(team?.id || '')} + className={styles.inviteDropdownItem} + data-test-id={`frontier-sdk-add-team-to-project-dropdown-item-${i}`} + > + + {team?.title || team?.name} + + ); + })} +
+ ) : ( + + No Teams found + + ) + ) : isOrgUsersLoading ? ( + + ) : topUsers.length ? ( +
+ {topUsers.map((user, i) => { + const initals = getInitials(user?.title || user.email); + return ( + addMember(user?.id || '')} + data-test-id={`frontier-sdk-add-user-to-project-dropdown-item-${i}`} + > + + {user?.title || user?.email} + + ); + })} +
+ ) : ( + + No Users found + + )} + + +
+ + {showTeam ? ( + <> + {' '} + Add project member + + ) : ( + <> + {' '} + Add team to project + + )} + +
+
+
+ ); +}; + +const noDataChildren = ( + } + heading="No members found" + subHeading="Get started by adding your first member." + /> +); + diff --git a/web/sdk/react/views/projects/details/remove-project-member-dialog.tsx b/web/sdk/react/views/projects/details/remove-project-member-dialog.tsx new file mode 100644 index 000000000..6e6685f38 --- /dev/null +++ b/web/sdk/react/views/projects/details/remove-project-member-dialog.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { Button, Flex, Text, toast, Image, Dialog } from '@raystack/apsara'; +import { useEffect, useMemo } from 'react'; +import { + useQuery, + useMutation, + createConnectQueryKey, + useTransport +} from '@connectrpc/connect-query'; +import { useQueryClient } from '@tanstack/react-query'; +import { + FrontierServiceQueries, + ListPoliciesRequestSchema, + DeletePolicyRequestSchema, + ListProjectUsersRequestSchema, + ListProjectGroupsRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import cross from '~/react/assets/cross.svg'; +import orgStyles from '../../../components/organization/organization.module.css'; + +export interface RemoveProjectMemberDialogProps { + open: boolean; + onOpenChange?: (value: boolean) => void; + projectId: string; + memberId: string; +} + +export const RemoveProjectMemberDialog = ({ + open, + onOpenChange, + projectId, + memberId +}: RemoveProjectMemberDialogProps) => { + const queryClient = useQueryClient(); + const transport = useTransport(); + + const handleOpenChange = (value: boolean) => { + onOpenChange?.(value); + }; + + const { data: policiesData, error: policiesError } = useQuery( + FrontierServiceQueries.listPolicies, + create(ListPoliciesRequestSchema, { + projectId: projectId || '', + userId: memberId || '' + }), + { enabled: !!projectId && !!memberId && open } + ); + + useEffect(() => { + if (policiesError) { + toast.error('Something went wrong', { description: (policiesError as Error).message }); + } + }, [policiesError]); + + const policies = useMemo(() => policiesData?.policies ?? [], [policiesData]); + + const { mutateAsync: deletePolicy, isPending } = useMutation( + FrontierServiceQueries.deletePolicy, + { + onError: (err: Error) => + toast.error('Something went wrong', { description: err.message }) + } + ); + + async function onConfirm() { + try { + await Promise.all( + (policies || []).map(p => + deletePolicy(create(DeletePolicyRequestSchema, { id: p.id || '' })) + ) + ); + if (projectId) { + await queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: FrontierServiceQueries.listProjectUsers, + transport, + input: create(ListProjectUsersRequestSchema, { + id: projectId, + withRoles: true + }), + cardinality: 'finite' + }) + }); + await queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: FrontierServiceQueries.listProjectGroups, + transport, + input: create(ListProjectGroupsRequestSchema, { + id: projectId, + withRoles: true + }), + cardinality: 'finite' + }) + }); + } + handleOpenChange(false); + toast.success('Member removed'); + } catch (error) { + console.error('Failed to delete policies:', error); + } + } + + return ( + + + + + + Remove project member + + cross handleOpenChange(false)} + style={{ cursor: 'pointer' }} + /> + + + + + + + Are you sure you want to remove this member from the project? + + + + + + + + + + + + + ); +}; + diff --git a/web/sdk/react/views/projects/index.ts b/web/sdk/react/views/projects/index.ts new file mode 100644 index 000000000..b4408c794 --- /dev/null +++ b/web/sdk/react/views/projects/index.ts @@ -0,0 +1,15 @@ +export { ProjectsListPage } from './list/projects-list-page'; +export type { ProjectsListPageProps } from './list/projects-list-page'; + +export { ProjectDetailPage } from './details/project-detail-page'; +export type { ProjectDetailPageProps } from './details/project-detail-page'; + +export { AddProjectDialog } from './list/add-project-dialog'; +export type { AddProjectDialogProps } from './list/add-project-dialog'; + +export { DeleteProjectDialog } from './details/delete-project-dialog'; +export type { DeleteProjectDialogProps } from './details/delete-project-dialog'; + +export { RemoveProjectMemberDialog } from './details/remove-project-member-dialog'; +export type { RemoveProjectMemberDialogProps } from './details/remove-project-member-dialog'; + diff --git a/web/sdk/react/views/projects/list/add-project-dialog.tsx b/web/sdk/react/views/projects/list/add-project-dialog.tsx new file mode 100644 index 000000000..c0dab4574 --- /dev/null +++ b/web/sdk/react/views/projects/list/add-project-dialog.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useEffect } from 'react'; +import { + Button, + toast, + Image, + Text, + Flex, + Dialog, + InputField +} from '@raystack/apsara'; +import * as yup from 'yup'; + +import { yupResolver } from '@hookform/resolvers/yup'; +import { useForm } from 'react-hook-form'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + CreateProjectRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import cross from '~/react/assets/cross.svg'; +import orgStyles from '../../../components/organization/organization.module.css'; +import slugify from 'slugify'; +import { generateHashFromString } from '~/react/utils'; +import { ConnectError, Code } from '@connectrpc/connect'; + +const projectSchema = yup + .object({ + title: yup.string().required(), + org_id: yup.string().required() + }) + .required(); + +type FormData = yup.InferType; + +export interface AddProjectDialogProps { + open: boolean; + onOpenChange: (value: boolean) => void; +} + +export const AddProjectDialog = ({ + open, + onOpenChange +}: AddProjectDialogProps) => { + const { + reset, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + register + } = useForm({ + resolver: yupResolver(projectSchema) + }); + const { activeOrganization: organization } = useFrontier(); + + const handleOpenChange = (value: boolean) => { + if (!value) { + reset(); + } + onOpenChange?.(value); + }; + + useEffect(() => { + if (open) { + reset({ org_id: organization?.id }); + } + }, [organization, reset, open]); + + const { mutateAsync: createProject } = useMutation( + FrontierServiceQueries.createProject, + { + onSuccess: () => { + toast.success('Project added'); + handleOpenChange(false); + } + } + ); + + async function onSubmit(data: FormData) { + if (!organization?.id) return; + const slug = slugify(data.title, { lower: true, strict: true }); + const suffix = generateHashFromString(organization.id); + const name = `${slug}-${suffix}`; + try { + await createProject( + create(CreateProjectRequestSchema, { + body: { + title: data.title, + name, + orgId: organization.id + } + }) + ); + } catch (error) { + if (error instanceof ConnectError && error.code === Code.AlreadyExists) { + setError('title', { + message: + 'A project with a similar title already exist. Please tweak the title and try again.' + }); + } else { + toast.error('Something went wrong', { + description: + error instanceof Error ? error.message : 'Failed to create project' + }); + } + } + } + + return ( + + + + + + Add Project + + cross handleOpenChange(false)} + data-test-id="frontier-sdk-new-project-close-btn" + style={{ cursor: 'pointer' }} + /> + + +
+ + +
+ +
+ +
+
+ + + + + +
+
+
+ ); +}; + diff --git a/web/sdk/react/views/projects/list/index.ts b/web/sdk/react/views/projects/list/index.ts new file mode 100644 index 000000000..76c03f6ae --- /dev/null +++ b/web/sdk/react/views/projects/list/index.ts @@ -0,0 +1,6 @@ +export { ProjectsListPage } from './projects-list-page'; +export type { ProjectsListPageProps } from './projects-list-page'; + +export { AddProjectDialog } from './add-project-dialog'; +export type { AddProjectDialogProps } from './add-project-dialog'; + diff --git a/web/sdk/react/views/projects/list/projects-columns.tsx b/web/sdk/react/views/projects/list/projects-columns.tsx new file mode 100644 index 000000000..b3ce0cf60 --- /dev/null +++ b/web/sdk/react/views/projects/list/projects-columns.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { + DotsHorizontalIcon, + Pencil1Icon, + TrashIcon +} from '@radix-ui/react-icons'; +import { Text, DropdownMenu } from '@raystack/apsara'; +import type { Project } from '@raystack/proton/frontier'; +import type { DataTableColumnDef } from '@raystack/apsara'; +import orgStyles from '../../../components/organization/organization.module.css'; + +export const getColumns = ( + userAccessOnProject: Record, + onProjectClick?: (projectId: string) => void, + onDeleteProjectClick?: (projectId: string) => void +): DataTableColumnDef[] => [ + { + header: 'Title', + accessorKey: 'title', + cell: ({ row, getValue }) => { + return ( + onProjectClick?.(row.original.id || '')} + style={{ + textDecoration: 'none', + color: 'var(--rs-color-foreground-base-primary)', + fontSize: 'var(--rs-font-size-small)', + cursor: 'pointer' + }} + > + {getValue() as string} + + ); + } + }, + { + header: 'Members', + accessorKey: 'membersCount', + cell: ({ row, getValue }) => { + const value = getValue() as string; + return value ? {value} members : null; + } + }, + { + header: '', + accessorKey: 'id', + meta: { + style: { + textAlign: 'end' + } + }, + enableSorting: false, + cell: ({ row, getValue }) => ( + + ) + } +]; + +const ProjectActions = ({ + project, + userAccessOnProject, + onProjectClick, + onDeleteProjectClick +}: { + project: Project; + userAccessOnProject: Record; + onProjectClick?: (projectId: string) => void; + onDeleteProjectClick?: (projectId: string) => void; +}) => { + const canUpdateProject = (userAccessOnProject[project.id!] ?? []).includes( + 'update' + ); + const canDeleteProject = (userAccessOnProject[project.id!] ?? []).includes( + 'delete' + ); + const canDoActions = canUpdateProject || canDeleteProject; + + return canDoActions ? ( + + + + + {/* @ts-ignore */} + + + {canUpdateProject ? ( + onProjectClick?.(project.id || '')} + className={orgStyles.dropdownActionItem} + > + Rename + + ) : null} + {canDeleteProject ? ( + onDeleteProjectClick?.(project.id || '')} + className={orgStyles.dropdownActionItem} + > + Delete project + + ) : null} + + + + ) : null; +}; + diff --git a/web/sdk/react/views/projects/list/projects-list-page.tsx b/web/sdk/react/views/projects/list/projects-list-page.tsx new file mode 100644 index 000000000..554da501a --- /dev/null +++ b/web/sdk/react/views/projects/list/projects-list-page.tsx @@ -0,0 +1,283 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Tooltip, + EmptyState, + Skeleton, + Flex, + Button, + Select, + DataTable, + toast +} from '@raystack/apsara'; +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { useOrganizationProjects } from '~/react/hooks/useOrganizationProjects'; +import { usePermissions } from '~/react/hooks/usePermissions'; +import { AuthTooltipMessage } from '~/react/utils'; +import { PERMISSIONS, shouldShowComponent } from '~/utils'; +import { getColumns } from './projects-columns'; +import { Project } from '@raystack/proton/frontier'; +import { PageHeader } from '~/react/components/common/page-header'; +import { AddProjectDialog } from './add-project-dialog'; +import { DeleteProjectDialog } from '../details/delete-project-dialog'; +import sharedStyles from '../../../components/organization/styles.module.css'; +import styles from './projects.module.css'; +import { useTerminology } from '~/react/hooks/useTerminology'; + +const projectsSelectOptions = [ + { value: 'my-projects', label: 'My Projects' }, + { value: 'all-projects', label: 'All Projects' } +]; + +interface ProjectsTableProps { + projects: Project[]; + isLoading?: boolean; + canCreateProject?: boolean; + userAccessOnProject: Record; + canListOrgProjects?: boolean; + onOrgProjectsFilterChange?: (value: string) => void; + onProjectClick?: (projectId: string) => void; + onDeleteProjectClick?: (projectId: string) => void; + onAddProjectClick?: () => void; +} + +export interface ProjectsListPageProps { + title?: string; + description?: string; + onProjectClick?: (projectId: string) => void; +} + +export function ProjectsListPage({ + title, + description, + onProjectClick +}: ProjectsListPageProps = {}) { + const [showOrgProjects, setShowOrgProjects] = useState(false); + const t = useTerminology(); + + const { + isFetching: isProjectsLoading, + projects, + userAccessOnProject, + refetch, + error: projectsError + } = useOrganizationProjects({ + allProjects: showOrgProjects, + withMemberCount: true + }); + const { activeOrganization: organization } = useFrontier(); + + const resource = `app/organization:${organization?.id}`; + const listOfPermissionsToCheck = useMemo( + () => [ + { + permission: PERMISSIONS.ProjectCreatePermission, + resource + }, + { + permission: PERMISSIONS.UpdatePermission, + resource + } + ], + [resource] + ); + + const { permissions, isFetching: isPermissionsFetching } = usePermissions( + listOfPermissionsToCheck, + !!organization?.id + ); + + const { canCreateProject, canUpdateOrganization } = useMemo(() => { + return { + canCreateProject: shouldShowComponent( + permissions, + `${PERMISSIONS.ProjectCreatePermission}::${resource}` + ), + canUpdateOrganization: shouldShowComponent( + permissions, + `${PERMISSIONS.UpdatePermission}::${resource}` + ) + }; + }, [permissions, resource]); + + const onOrgProjectsFilterChange = useCallback((value: string) => { + if (value === 'all-projects') { + setShowOrgProjects(true); + } else { + setShowOrgProjects(false); + } + }, []); + + useEffect(() => { + if (projectsError) { + toast.error('Something went wrong', { + description: projectsError.message + }); + } + }, [projectsError]); + + const isLoading = isPermissionsFetching || isProjectsLoading; + + const [showAddProjectDialog, setShowAddProjectDialog] = useState(false); + const [deleteProjectState, setDeleteProjectState] = useState({ + open: false, + projectId: '' + }); + + const handleAddProjectOpenChange = (value: boolean) => { + setShowAddProjectDialog(value); + refetch(); + }; + + const handleDeleteProjectOpenChange = (value: boolean) => { + if (!value) { + setDeleteProjectState({ open: false, projectId: '' }); + refetch(); + } else { + setDeleteProjectState(prev => ({ ...prev, open: value })); + } + }; + + return ( + + + + + + + + setDeleteProjectState({ open: true, projectId }) + } + onAddProjectClick={() => setShowAddProjectDialog(true)} + /> + + + + + + ); +} + +const ProjectsTable = ({ + projects, + isLoading, + canCreateProject, + userAccessOnProject, + canListOrgProjects, + onOrgProjectsFilterChange, + onProjectClick, + onDeleteProjectClick, + onAddProjectClick +}: ProjectsTableProps) => { + const columns = useMemo( + () => getColumns(userAccessOnProject, onProjectClick, onDeleteProjectClick), + [userAccessOnProject, onProjectClick, onDeleteProjectClick] + ); + + return ( + + + + + {isLoading ? ( + + ) : ( + + )} + {canListOrgProjects ? ( + + ) : null} + + {isLoading ? ( + + ) : ( + + + + )} + + + + + ); +}; + +const noDataChildren = ( + } + heading="No projects found" + subHeading="Get started by creating your first project." + /> +); + diff --git a/web/sdk/react/views/projects/list/projects.module.css b/web/sdk/react/views/projects/list/projects.module.css new file mode 100644 index 000000000..0e9e35f40 --- /dev/null +++ b/web/sdk/react/views/projects/list/projects.module.css @@ -0,0 +1,24 @@ +.tableWrapper { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.tableRoot { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; +} + +.tableHeader { + z-index: 1; +} + +.tableSearchWrapper { + max-width: 500px; + flex: 1; +} +