diff --git a/web/apps/client-demo/src/Router.tsx b/web/apps/client-demo/src/Router.tsx index 785d7ce47..8e1519499 100644 --- a/web/apps/client-demo/src/Router.tsx +++ b/web/apps/client-demo/src/Router.tsx @@ -16,6 +16,8 @@ import Members from './pages/settings/Members'; import Security from './pages/settings/Security'; import Projects from './pages/settings/Projects'; import ProjectDetails from './pages/settings/ProjectDetails'; +import Teams from './pages/settings/Teams'; +import TeamDetails from './pages/settings/TeamDetails'; function Router() { return ( @@ -38,6 +40,8 @@ function Router() { } /> } /> } /> + } /> + } /> } /> diff --git a/web/apps/client-demo/src/pages/Settings.tsx b/web/apps/client-demo/src/pages/Settings.tsx index 076986669..98b15bba9 100644 --- a/web/apps/client-demo/src/pages/Settings.tsx +++ b/web/apps/client-demo/src/pages/Settings.tsx @@ -10,7 +10,8 @@ const NAV_ITEMS = [ { label: 'Sessions', path: 'sessions' }, { label: 'Members', path: 'members' }, { label: 'Security', path: 'security' }, - { label: 'Projects', path: 'projects' } + { label: 'Projects', path: 'projects' }, + { label: 'Teams', path: 'teams' } ]; export default function Settings() { diff --git a/web/apps/client-demo/src/pages/settings/TeamDetails.tsx b/web/apps/client-demo/src/pages/settings/TeamDetails.tsx new file mode 100644 index 000000000..926eb6e7e --- /dev/null +++ b/web/apps/client-demo/src/pages/settings/TeamDetails.tsx @@ -0,0 +1,17 @@ +import { useParams, useNavigate } from 'react-router-dom'; +import { TeamDetailsView } from '@raystack/frontier/react'; + +export default function TeamDetails() { + const { orgId, teamId } = useParams<{ orgId: string; teamId: string }>(); + const navigate = useNavigate(); + + if (!teamId) return null; + + return ( + navigate(`/${orgId}/settings/teams`)} + onDeleteSuccess={() => navigate(`/${orgId}/settings/teams`)} + /> + ); +} diff --git a/web/apps/client-demo/src/pages/settings/Teams.tsx b/web/apps/client-demo/src/pages/settings/Teams.tsx new file mode 100644 index 000000000..b9c1f4226 --- /dev/null +++ b/web/apps/client-demo/src/pages/settings/Teams.tsx @@ -0,0 +1,13 @@ +import { useParams, useNavigate } from 'react-router-dom'; +import { TeamsView } from '@raystack/frontier/react'; + +export default function Teams() { + const { orgId } = useParams<{ orgId: string }>(); + const navigate = useNavigate(); + + return ( + navigate(`/${orgId}/settings/teams/${teamId}`)} + /> + ); +} diff --git a/web/sdk/react/index.ts b/web/sdk/react/index.ts index f53635636..0720e1594 100644 --- a/web/sdk/react/index.ts +++ b/web/sdk/react/index.ts @@ -37,6 +37,7 @@ export { SessionsView } from './views-new/sessions'; export { MembersView } from './views-new/members'; export { SecurityView } from './views-new/security'; export { ProjectsView, ProjectDetailsView } from './views-new/projects'; +export { TeamsView, TeamDetailsView } from './views-new/teams'; export type { FrontierClientOptions, diff --git a/web/sdk/react/views-new/teams/components/add-member-menu.tsx b/web/sdk/react/views-new/teams/components/add-member-menu.tsx new file mode 100644 index 000000000..893672348 --- /dev/null +++ b/web/sdk/react/views-new/teams/components/add-member-menu.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { useCallback, useEffect, useMemo } from 'react'; +import { + Avatar, + Button, + Flex, + Menu, + Skeleton, + Tooltip +} from '@raystack/apsara-v1'; +import { toastManager } from '@raystack/apsara-v1'; +import { useQuery, useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + ListOrganizationUsersRequestSchema, + AddGroupUsersRequestSchema, + type User +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import { useFrontier } from '../../../contexts/FrontierContext'; +import { AuthTooltipMessage } from '~/react/utils'; +import { filterUsersfromUsers, getInitials } from '~/utils'; +import styles from '../team-details-view.module.css'; + +interface AddMemberMenuProps { + teamId: string; + canUpdateGroup: boolean; + members: User[]; + refetch: () => void; +} + +export function AddMemberMenu({ + teamId, + canUpdateGroup, + members, + refetch +}: AddMemberMenuProps) { + const { activeOrganization: organization } = useFrontier(); + + const { + data: orgUsersData, + isLoading: isOrgUsersLoading, + error: orgUsersError + } = useQuery( + FrontierServiceQueries.listOrganizationUsers, + create(ListOrganizationUsersRequestSchema, { + id: organization?.id || '' + }), + { enabled: !!organization?.id && canUpdateGroup } + ); + + const orgUsers = useMemo( + () => orgUsersData?.users ?? [], + [orgUsersData] + ); + + useEffect(() => { + if (orgUsersError) { + toastManager.add({ + title: 'Something went wrong', + description: orgUsersError.message, + type: 'error' + }); + } + }, [orgUsersError]); + + const invitableUsers = useMemo( + () => filterUsersfromUsers(orgUsers, members), + [orgUsers, members] + ); + + const { mutate: addGroupUsers } = useMutation( + FrontierServiceQueries.addGroupUsers, + { + onSuccess: () => { + toastManager.add({ + title: 'Member added', + type: 'success' + }); + refetch(); + }, + onError: (err: Error) => { + toastManager.add({ + title: 'Something went wrong', + description: err.message, + type: 'error' + }); + } + } + ); + + const addMember = useCallback( + (userId: string) => { + if (!userId || !organization?.id || !teamId) return; + addGroupUsers( + create(AddGroupUsersRequestSchema, { + id: teamId, + orgId: organization.id, + userIds: [userId] + }) + ); + }, + [addGroupUsers, organization?.id, teamId] + ); + + if (!canUpdateGroup) { + return ( + + }> + + + {AuthTooltipMessage} + + ); + } + + return ( + + + } + > + Add a member + + +
+ {isOrgUsersLoading ? ( + + {Array.from({ length: 6 }, (_, i) => ( + + ))} + + ) : ( + invitableUsers.map(user => ( + + } + onClick={() => addMember(user.id || '')} + data-test-id={`frontier-sdk-add-user-to-team-item-${user.id}`} + > + + {user.title || user.email} + + + )) + )} + + {!isOrgUsersLoading && !invitableUsers.length && ( + + No users found + + )} +
+
+
+ ); +} diff --git a/web/sdk/react/views-new/teams/components/add-team-dialog.tsx b/web/sdk/react/views-new/teams/components/add-team-dialog.tsx new file mode 100644 index 000000000..d2a935738 --- /dev/null +++ b/web/sdk/react/views-new/teams/components/add-team-dialog.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { + Button, + Flex, + Dialog, + InputField +} from '@raystack/apsara-v1'; +import { toastManager } from '@raystack/apsara-v1'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useForm } from 'react-hook-form'; +import { useFrontier } from '../../../contexts/FrontierContext'; +import { useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + CreateGroupRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; + +const teamSchema = yup + .object({ + title: yup.string().required('Team title is required'), + name: yup + .string() + .required('Team name is required') + .min(3, 'Name must be at least 3 characters') + .max(50, 'Name must be at most 50 characters') + .matches( + /^[a-zA-Z0-9_-]{3,50}$/, + "Only numbers, letters, '-', and '_' are allowed. Spaces are not allowed." + ) + }) + .required(); + +type FormData = yup.InferType; + +type DialogHandle = ReturnType; + +export interface AddTeamDialogProps { + handle: DialogHandle; + refetch: () => void; +} + +export function AddTeamDialog({ handle, refetch }: AddTeamDialogProps) { + const { + reset, + handleSubmit, + formState: { errors, isSubmitting }, + register + } = useForm({ + resolver: yupResolver(teamSchema) + }); + const { activeOrganization: organization } = useFrontier(); + + const { mutateAsync: createTeam } = useMutation( + FrontierServiceQueries.createGroup + ); + + const handleOpenChange = (open: boolean) => { + if (!open) reset(); + }; + + async function onSubmit(data: FormData) { + if (!organization?.id) return; + + try { + await createTeam( + create(CreateGroupRequestSchema, { + orgId: organization.id, + body: { + title: data.title, + name: data.name + } + }) + ); + toastManager.add({ title: 'Team added', type: 'success' }); + refetch(); + handle.close(); + } catch (error) { + toastManager.add({ + title: 'Something went wrong', + description: + error instanceof Error ? error.message : 'Failed to create team', + type: 'error' + }); + } + } + + return ( + + + + Add Team + +
+ + + + + + + + + + + + +
+
+
+ ); +} diff --git a/web/sdk/react/views-new/teams/components/delete-team-dialog.tsx b/web/sdk/react/views-new/teams/components/delete-team-dialog.tsx new file mode 100644 index 000000000..889c83cb8 --- /dev/null +++ b/web/sdk/react/views-new/teams/components/delete-team-dialog.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { useState } from 'react'; +import { + Button, + Flex, + Text, + AlertDialog +} from '@raystack/apsara-v1'; +import { toastManager } from '@raystack/apsara-v1'; +import { useFrontier } from '../../../contexts/FrontierContext'; +import { useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + DeleteGroupRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; + +export interface DeleteTeamPayload { + teamId: string; +} + +type AlertDialogHandle = ReturnType>; + +export interface DeleteTeamDialogProps { + handle: AlertDialogHandle; + refetch: () => void; +} + +export function DeleteTeamDialog({ handle, refetch }: DeleteTeamDialogProps) { + const { activeOrganization: organization } = useFrontier(); + + const { mutateAsync: deleteTeam, isPending } = useMutation( + FrontierServiceQueries.deleteGroup + ); + + const handleDelete = async (teamId: string) => { + if (!organization?.id || !teamId) return; + try { + await deleteTeam( + create(DeleteGroupRequestSchema, { + id: teamId, + orgId: organization.id + }) + ); + toastManager.add({ title: 'Team deleted', type: 'success' }); + refetch(); + handle.close(); + } catch (error) { + toastManager.add({ + title: 'Something went wrong', + description: + error instanceof Error ? error.message : 'Failed to delete team', + type: 'error' + }); + } + } + + return ( + + {({ payload: rawPayload }) => { + const payload = rawPayload as DeleteTeamPayload | undefined; + return ( + + + Delete Team + + + + This action is irreversible. All team data including member + assignments will be permanently deleted. Are you sure you want + to proceed? + + + + + + + + + + ); + }} + + ); +} diff --git a/web/sdk/react/views-new/teams/components/edit-team-dialog.tsx b/web/sdk/react/views-new/teams/components/edit-team-dialog.tsx new file mode 100644 index 000000000..41a47ccf5 --- /dev/null +++ b/web/sdk/react/views-new/teams/components/edit-team-dialog.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { useEffect } from 'react'; +import { + Button, + Flex, + Dialog, + InputField +} from '@raystack/apsara-v1'; +import { toastManager } from '@raystack/apsara-v1'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useForm } from 'react-hook-form'; +import { useFrontier } from '../../../contexts/FrontierContext'; +import { useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + UpdateGroupRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; + +const editTeamSchema = yup + .object({ + title: yup.string().required('Team title is required') + }) + .required(); + +type FormData = yup.InferType; + +export interface EditTeamPayload { + teamId: string; + title: string; + name: string; +} + +type DialogHandle = ReturnType>; + +export interface EditTeamDialogProps { + handle: DialogHandle; + refetch: () => void; +} + +export function EditTeamDialog({ handle, refetch }: EditTeamDialogProps) { + return ( + + {({ payload }) => { + const p = payload as EditTeamPayload | undefined; + return ( + + {p ? ( + + ) : null} + + ); + }} + + ); +} + +interface EditTeamFormProps { + payload: EditTeamPayload; + handle: DialogHandle; + refetch: () => void; +} + +function EditTeamForm({ payload, handle, refetch }: EditTeamFormProps) { + const { + reset, + handleSubmit, + formState: { errors, isSubmitting, isDirty }, + register + } = useForm({ + resolver: yupResolver(editTeamSchema), + defaultValues: { + title: payload.title + } + }); + + const { activeOrganization: organization } = useFrontier(); + + const { mutateAsync: updateTeam } = useMutation( + FrontierServiceQueries.updateGroup + ); + + useEffect(() => { + reset({ title: payload.title }); + }, [payload.teamId, payload.title, reset]); + + async function onSubmit(data: FormData) { + if (!organization?.id || !payload.teamId) return; + + try { + await updateTeam( + create(UpdateGroupRequestSchema, { + id: payload.teamId, + orgId: organization.id, + body: { + title: data.title, + name: payload.name + } + }) + ); + toastManager.add({ title: 'Team updated', type: 'success' }); + refetch(); + handle.close(); + } catch (error) { + toastManager.add({ + title: 'Something went wrong', + description: + error instanceof Error ? error.message : 'Failed to update team', + type: 'error' + }); + } + } + + return ( +
+ + Edit team + + + + + + + + + + + + + +
+ ); +} diff --git a/web/sdk/react/views-new/teams/components/member-columns.tsx b/web/sdk/react/views-new/teams/components/member-columns.tsx new file mode 100644 index 000000000..9a1badd09 --- /dev/null +++ b/web/sdk/react/views-new/teams/components/member-columns.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { DotsVerticalIcon } from '@radix-ui/react-icons'; +import { + Flex, + Text, + Avatar, + Menu, + IconButton, + DataTableColumnDef, + getAvatarColor +} from '@raystack/apsara-v1'; +import type { User, Role } from '@raystack/proton/frontier'; +import { getInitials } from '~/utils'; + +export interface MemberMenuPayload { + memberId: string; + excludedRoles: Role[]; +} + +type MenuHandle = ReturnType>; + +interface GetColumnsOptions { + memberRoles: Record; + roles: Role[]; + canUpdateGroup: boolean; + menuHandle: MenuHandle; +} + +export function getColumns({ + memberRoles, + roles, + canUpdateGroup, + menuHandle +}: GetColumnsOptions): DataTableColumnDef[] { + return [ + { + header: 'Name', + accessorKey: 'title', + cell: ({ row }) => { + const member = row.original; + const fallback = getInitials(member.title || member.email); + const color = getAvatarColor(member.id || ''); + return ( + + + + + {member.title} + + + {member.email} + + + + ); + } + }, + { + header: 'Role', + accessorKey: 'email', + cell: ({ row }) => { + const member = row.original; + const roleList = + (member.id && memberRoles[member.id]) || []; + const roleText = + roleList.map((r: Role) => r.title || r.name).join(', ') || + 'Inherited role'; + return ( + + {roleText} + + ); + } + }, + { + header: '', + accessorKey: 'id', + enableSorting: false, + styles: { + cell: { width: '32px' } + }, + cell: ({ row }) => { + if (!canUpdateGroup) return null; + + const member = row.original; + const currentRoles = + (member.id && memberRoles[member.id]) || []; + const currentRoleIds = new Set(currentRoles.map(r => r.id)); + const excludedRoles = roles.filter(r => !currentRoleIds.has(r.id)); + + return ( + + + } + > + + + + ); + } + } + ]; +} diff --git a/web/sdk/react/views-new/teams/components/remove-member-dialog.tsx b/web/sdk/react/views-new/teams/components/remove-member-dialog.tsx new file mode 100644 index 000000000..e63115826 --- /dev/null +++ b/web/sdk/react/views-new/teams/components/remove-member-dialog.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useState } from 'react'; +import { + Button, + Flex, + Text, + AlertDialog +} from '@raystack/apsara-v1'; +import { toastManager } from '@raystack/apsara-v1'; +import { useFrontier } from '../../../contexts/FrontierContext'; +import { useMutation, useQuery } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + RemoveGroupUserRequestSchema, + ListPoliciesRequestSchema, + DeletePolicyRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; + +export interface RemoveMemberPayload { + memberId: string; + teamId: string; +} + +type AlertDialogHandle = ReturnType< + typeof AlertDialog.createHandle +>; + +export interface RemoveMemberDialogProps { + handle: AlertDialogHandle; + refetch: () => void; +} + +export function RemoveMemberDialog({ + handle, + refetch +}: RemoveMemberDialogProps) { + return ( + + {({ payload }) => { + const p = payload as RemoveMemberPayload | undefined; + return ( + + {p ? ( + + ) : null} + + ); + }} + + ); +} + +interface RemoveMemberFormProps { + payload: RemoveMemberPayload; + handle: AlertDialogHandle; + refetch: () => void; +} + +function RemoveMemberForm({ + payload, + handle, + refetch +}: RemoveMemberFormProps) { + const [isLoading, setIsLoading] = useState(false); + const { activeOrganization: organization } = useFrontier(); + + const { data: policiesData } = useQuery( + FrontierServiceQueries.listPolicies, + create(ListPoliciesRequestSchema, { + groupId: payload.teamId, + userId: payload.memberId + }), + { enabled: !!payload.teamId && !!payload.memberId } + ); + + const policies = policiesData?.policies ?? []; + + const { mutateAsync: deletePolicy } = useMutation( + FrontierServiceQueries.deletePolicy + ); + + const { mutateAsync: removeGroupUser } = useMutation( + FrontierServiceQueries.removeGroupUser + ); + + async function handleRemove() { + if (!organization?.id) return; + setIsLoading(true); + try { + await Promise.all( + policies.map(p => + deletePolicy(create(DeletePolicyRequestSchema, { id: p.id || '' })) + ) + ); + + await removeGroupUser( + create(RemoveGroupUserRequestSchema, { + id: payload.teamId, + orgId: organization.id, + userId: payload.memberId + }) + ); + + toastManager.add({ title: 'Member removed', type: 'success' }); + refetch(); + handle.close(); + } catch (error) { + toastManager.add({ + title: 'Something went wrong', + description: + error instanceof Error ? error.message : 'Failed to remove member', + type: 'error' + }); + } finally { + setIsLoading(false); + } + } + + return ( + <> + + Remove team member + + + + Are you sure you want to remove this member from the team? This + action cannot be undone. + + + + + + + + + + ); +} diff --git a/web/sdk/react/views-new/teams/components/team-columns.tsx b/web/sdk/react/views-new/teams/components/team-columns.tsx new file mode 100644 index 000000000..5e7be2508 --- /dev/null +++ b/web/sdk/react/views-new/teams/components/team-columns.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { DotsVerticalIcon } from '@radix-ui/react-icons'; +import { + Flex, + Text, + Menu, + IconButton, + DataTableColumnDef +} from '@raystack/apsara-v1'; +import type { Group } from '@raystack/proton/frontier'; + +export interface TeamMenuPayload { + teamId: string; + title: string; + name: string; + canUpdate: boolean; + canDelete: boolean; +} + +type MenuHandle = ReturnType; + +export function getColumns({ + userAccessOnTeam, + menuHandle +}: { + userAccessOnTeam: Record; + menuHandle: MenuHandle; +}): DataTableColumnDef[] { + return [ + { + header: 'Title', + accessorKey: 'title', + cell: ({ getValue }) => ( + {getValue() as string} + ) + }, + { + header: 'Members', + accessorKey: 'membersCount', + enableSorting: false, + cell: ({ getValue }) => { + const value = getValue() as number; + return value ? ( + + {value} {value === 1 ? 'member' : 'members'} + + ) : null; + } + }, + { + header: '', + accessorKey: 'id', + enableSorting: false, + styles: { + cell: { width: '48px' } + }, + cell: ({ row }) => { + const team = row.original as Group; + const access = userAccessOnTeam[team.id] ?? []; + const canUpdate = access.includes('update'); + const canDelete = access.includes('delete'); + + if (!canUpdate && !canDelete) return null; + + return ( + + + } + > + + + + ); + } + } + ]; +} diff --git a/web/sdk/react/views-new/teams/index.ts b/web/sdk/react/views-new/teams/index.ts new file mode 100644 index 000000000..ccd5a7252 --- /dev/null +++ b/web/sdk/react/views-new/teams/index.ts @@ -0,0 +1,3 @@ +export { TeamsView } from './teams-view'; +export { TeamDetailsView } from './team-details-view'; +export type { TeamDetailsViewProps } from './team-details-view'; diff --git a/web/sdk/react/views-new/teams/team-details-view.module.css b/web/sdk/react/views-new/teams/team-details-view.module.css new file mode 100644 index 000000000..4f38f483c --- /dev/null +++ b/web/sdk/react/views-new/teams/team-details-view.module.css @@ -0,0 +1,32 @@ +.tableRoot { + border: none; +} + +.menuContent { + min-width: 160px; +} + +.addMemberContent { + width: 228px; +} + +.addMemberMenuList { + max-height: 256px; + overflow-y: auto; +} + +.addMemberMenuItem { + width: 100%; +} + +.addMemberMenuItemText { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1; +} + +.addMemberEmptyState { + min-height: 200px; +} diff --git a/web/sdk/react/views-new/teams/team-details-view.tsx b/web/sdk/react/views-new/teams/team-details-view.tsx new file mode 100644 index 000000000..c345e6d16 --- /dev/null +++ b/web/sdk/react/views-new/teams/team-details-view.tsx @@ -0,0 +1,508 @@ +'use client'; + +import { useCallback, useEffect, useMemo } from 'react'; +import { + DotsHorizontalIcon, + ExclamationTriangleIcon, + Pencil1Icon, + UpdateIcon +} from '@radix-ui/react-icons'; +import { + Breadcrumb, + Skeleton, + Flex, + EmptyState, + DataTable, + Menu, + AlertDialog, + Dialog, + IconButton, + Image +} from '@raystack/apsara-v1'; +import deleteIcon from '../../assets/delete.svg'; +import { toastManager } from '@raystack/apsara-v1'; +import { + useQuery, + useMutation, + createConnectQueryKey, + useTransport +} from '@connectrpc/connect-query'; +import { useQueryClient } from '@tanstack/react-query'; +import { + FrontierServiceQueries, + GetGroupRequestSchema, + ListGroupUsersRequestSchema, + ListRolesRequestSchema, + CreatePolicyRequestSchema, + DeletePolicyRequestSchema, + ListPoliciesRequestSchema, + type Role as ProtoRole +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import { useFrontier } from '../../contexts/FrontierContext'; +import { usePermissions } from '../../hooks/usePermissions'; +import { PERMISSIONS, shouldShowComponent } from '../../../utils'; +import { ViewContainer } from '../../components/view-container'; +import { ViewHeader } from '../../components/view-header'; +import { + EditTeamDialog, + type EditTeamPayload +} from './components/edit-team-dialog'; +import { + DeleteTeamDialog, + type DeleteTeamPayload +} from './components/delete-team-dialog'; +import { + RemoveMemberDialog, + type RemoveMemberPayload +} from './components/remove-member-dialog'; +import { + getColumns, + type MemberMenuPayload +} from './components/member-columns'; +import { AddMemberMenu } from './components/add-member-menu'; +import styles from './team-details-view.module.css'; + +const memberMenuHandle = Menu.createHandle(); +const editTeamDialogHandle = Dialog.createHandle(); +const deleteTeamDialogHandle = AlertDialog.createHandle(); +const removeMemberDialogHandle = + AlertDialog.createHandle(); + +export interface TeamDetailsViewProps { + teamId: string; + teamsLabel?: string; + onNavigateToTeams?: () => void; + onDeleteSuccess?: () => void; +} + +export function TeamDetailsView({ + teamId, + teamsLabel = 'Teams', + onNavigateToTeams, + onDeleteSuccess +}: TeamDetailsViewProps) { + const { activeOrganization: organization } = useFrontier(); + + const { + data: teamData, + isLoading: isTeamLoading, + error: teamError, + refetch: refetchTeam + } = useQuery( + FrontierServiceQueries.getGroup, + create(GetGroupRequestSchema, { + id: teamId || '', + orgId: organization?.id || '' + }), + { + enabled: !!organization?.id && !!teamId, + select: d => d + } + ); + + const team = teamData?.group; + + useEffect(() => { + if (teamError) { + toastManager.add({ + title: 'Something went wrong', + description: teamError.message, + type: 'error' + }); + } + }, [teamError]); + + const { + data: membersData, + isLoading: isMembersLoading, + refetch: refetchMembers + } = useQuery( + FrontierServiceQueries.listGroupUsers, + create(ListGroupUsersRequestSchema, { + id: teamId || '', + orgId: organization?.id || '', + withRoles: true + }), + { enabled: !!organization?.id && !!teamId } + ); + + const members = useMemo(() => membersData?.users ?? [], [membersData]); + const memberRoles = useMemo(() => { + if (!membersData?.rolePairs) return {}; + return membersData.rolePairs.reduce( + (acc: Record, mr: { userId: string; roles: ProtoRole[] }) => { + if (mr.userId) acc[mr.userId] = mr.roles; + return acc; + }, + {} + ); + }, [membersData?.rolePairs]); + + const { + data: rolesData, + isLoading: isRolesLoading, + error: rolesError + } = useQuery( + FrontierServiceQueries.listRoles, + create(ListRolesRequestSchema, { + state: 'enabled', + scopes: [PERMISSIONS.GroupNamespace] + }), + { enabled: !!organization?.id && !!teamId } + ); + + const roles = useMemo(() => rolesData?.roles ?? [], [rolesData]); + + useEffect(() => { + if (rolesError) { + toastManager.add({ + title: 'Something went wrong', + description: rolesError.message, + type: 'error' + }); + } + }, [rolesError]); + + 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 = + !organization?.id || + isTeamLoading || + isMembersLoading || + isRolesLoading || + isPermissionsFetching; + + + const columns = useMemo( + () => + getColumns({ + memberRoles, + roles, + canUpdateGroup, + menuHandle: memberMenuHandle + }), + [memberRoles, roles, canUpdateGroup] + ); + + const queryClient = useQueryClient(); + const transport = useTransport(); + + const { mutateAsync: deletePolicy } = useMutation( + FrontierServiceQueries.deletePolicy + ); + const { mutateAsync: createPolicy } = useMutation( + FrontierServiceQueries.createPolicy + ); + + const updateMemberRole = useCallback( + async (memberId: string, role: ProtoRole) => { + try { + const principal = `${PERMISSIONS.UserNamespace}:${memberId}`; + + const input = create(ListPoliciesRequestSchema, { + groupId: teamId, + userId: memberId + }); + + const policiesData = await queryClient.fetchQuery({ + queryKey: createConnectQueryKey({ + schema: FrontierServiceQueries.listPolicies, + transport, + input, + cardinality: 'finite' + }) + }); + + const policies = + (policiesData as { policies?: { id?: string }[] })?.policies ?? []; + + await Promise.all( + policies.map(p => + deletePolicy( + create(DeletePolicyRequestSchema, { id: p.id || '' }) + ) + ) + ); + + await createPolicy( + create(CreatePolicyRequestSchema, { + body: { + roleId: role.id as string, + resource: `app/group:${teamId}`, + principal + } + }) + ); + refetchMembers(); + toastManager.add({ + title: 'Member role updated', + type: 'success' + }); + } catch (error) { + toastManager.add({ + title: 'Something went wrong', + description: + error instanceof Error + ? error.message + : 'Failed to update member role', + type: 'error' + }); + } + }, + [queryClient, transport, deletePolicy, createPolicy, teamId, refetchMembers] + ); + + const handleDeleteSuccess = useCallback(() => { + onDeleteSuccess?.(); + }, [onDeleteSuccess]); + + const teamTitle = team?.title || ''; + const teamName = team?.name || ''; + + return ( + + + { + e.preventDefault(); + onNavigateToTeams?.(); + }} + > + {teamsLabel} + + + + {isTeamLoading ? ( + + ) : ( + teamTitle + )} + + + } + > + {!isLoading && (canUpdateGroup || canDeleteGroup) && ( + + )} + + + + + + + {isLoading ? ( + + ) : ( + + )} + + {isLoading ? ( + + ) : ( + + )} + + } + heading="No members found" + subHeading="Get started by adding your first team member." + /> + } + classNames={{ + root: styles.tableRoot + }} + /> + + + + + {({ payload: rawPayload }) => { + const payload = rawPayload as MemberMenuPayload | undefined; + return ( + + {payload?.excludedRoles.map((role: ProtoRole) => ( + } + onClick={() => + payload && + updateMemberRole(payload.memberId, role) + } + data-test-id="frontier-sdk-update-team-member-role-btn" + > + Make {role.title} + + ))} + + } + onClick={() => + payload && + removeMemberDialogHandle.openWithPayload({ + memberId: payload.memberId, + teamId + }) + } + data-test-id="frontier-sdk-remove-team-member-btn" + style={{ + color: 'var(--rs-color-foreground-danger-primary)' + }} + > + Remove from team + + + ); + }} + + + { + refetchTeam(); + }} + /> + + + + ); +} + +interface TeamActionsMenuProps { + teamId: string; + teamTitle: string; + teamName: string; + canUpdate: boolean; + canDelete: boolean; +} + +const teamActionsMenuHandle = Menu.createHandle(); + +function TeamActionsMenu({ + teamId, + teamTitle, + teamName, + canUpdate, + canDelete +}: TeamActionsMenuProps) { + return ( + <> + + } + > + + + + + {canUpdate && ( + } + onClick={() => + editTeamDialogHandle.openWithPayload({ + teamId, + title: teamTitle, + name: teamName + }) + } + data-test-id="frontier-sdk-edit-team-details-btn" + > + Edit + + )} + {canDelete && ( + + } + onClick={() => + deleteTeamDialogHandle.openWithPayload({ teamId }) + } + data-test-id="frontier-sdk-delete-team-details-btn" + style={{ + color: 'var(--rs-color-foreground-danger-primary)' + }} + > + Delete team + + )} + + + + ); +} diff --git a/web/sdk/react/views-new/teams/teams-view.module.css b/web/sdk/react/views-new/teams/teams-view.module.css new file mode 100644 index 000000000..dcc38126c --- /dev/null +++ b/web/sdk/react/views-new/teams/teams-view.module.css @@ -0,0 +1,13 @@ +.tableRoot { + border: none; +} + +.menuContent { + min-width: 160px; +} + +.teamsFilter { + width: auto; + min-width: 120px; + box-shadow: none; +} diff --git a/web/sdk/react/views-new/teams/teams-view.tsx b/web/sdk/react/views-new/teams/teams-view.tsx new file mode 100644 index 000000000..56cdad9aa --- /dev/null +++ b/web/sdk/react/views-new/teams/teams-view.tsx @@ -0,0 +1,275 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ExclamationTriangleIcon, Pencil1Icon } from '@radix-ui/react-icons'; +import { + Button, + Tooltip, + Skeleton, + Flex, + EmptyState, + DataTable, + Dialog, + AlertDialog, + Image, + Menu, + Select +} from '@raystack/apsara-v1'; +import deleteIcon from '../../assets/delete.svg'; +import { toastManager } from '@raystack/apsara-v1'; +import { useFrontier } from '../../contexts/FrontierContext'; +import { useOrganizationTeams } from '../../hooks/useOrganizationTeams'; +import { usePermissions } from '../../hooks/usePermissions'; +import { AuthTooltipMessage } from '../../utils'; +import { PERMISSIONS, shouldShowComponent } from '../../../utils'; +import { ViewContainer } from '../../components/view-container'; +import { ViewHeader } from '../../components/view-header'; +import { getColumns, type TeamMenuPayload } from './components/team-columns'; +import { AddTeamDialog } from './components/add-team-dialog'; +import { EditTeamDialog, type EditTeamPayload } from './components/edit-team-dialog'; +import { DeleteTeamDialog, type DeleteTeamPayload } from './components/delete-team-dialog'; +import styles from './teams-view.module.css'; +import { useTerminology } from '~/react/hooks/useTerminology'; + +const teamsFilterOptions = [ + { value: 'my-teams', label: 'My Teams' }, + { value: 'all-teams', label: 'All Teams' } +]; + +const teamMenuHandle = Menu.createHandle(); +const addTeamDialogHandle = Dialog.createHandle(); +const editTeamDialogHandle = Dialog.createHandle(); +const deleteTeamDialogHandle = AlertDialog.createHandle(); + +export interface TeamsViewProps { + title?: string; + description?: string; + onTeamClick?: (teamId: string) => void; +} + +export function TeamsView({ + title = 'Teams', + description, + onTeamClick +}: TeamsViewProps) { + const [showOrgTeams, setShowOrgTeams] = useState(false); + const t = useTerminology(); + + const { + isFetching: isTeamsLoading, + teams, + userAccessOnTeam, + refetch, + error: teamsError + } = 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.GroupListPermission}::${resource}` + ) + }; + }, [permissions, resource]); + + useEffect(() => { + if (teamsError) { + toastManager.add({ + title: 'Something went wrong', + description: + teamsError instanceof Error + ? teamsError.message + : 'Failed to load teams', + type: 'error' + }); + } + }, [teamsError]); + + const onFilterChange = useCallback((value: string) => { + setShowOrgTeams(value === 'all-teams'); + }, []); + + const isLoading = !organization?.id || isPermissionsFetching || isTeamsLoading; + + const columns = useMemo( + () => + getColumns({ + userAccessOnTeam, + menuHandle: teamMenuHandle + }), + [userAccessOnTeam] + ); + + return ( + + + + onTeamClick?.(row.id)} + > + + + + {isLoading ? ( + + ) : ( + <> + + {canListOrgGroups && ( + + )} + + )} + + {isLoading ? ( + + ) : ( + + } + > + + + {!canCreateGroup && ( + {AuthTooltipMessage} + )} + + )} + + } + heading="No teams found" + subHeading="Get started by creating your first team." + /> + } + classNames={{ + root: styles.tableRoot + }} + /> + + + + + {({ payload: rawPayload }) => { + const payload = rawPayload as TeamMenuPayload | undefined; + return ( + + {payload?.canUpdate && ( + } + onClick={() => + editTeamDialogHandle.openWithPayload({ + teamId: payload.teamId, + title: payload.title, + name: payload.name + }) + } + data-test-id="frontier-sdk-edit-team-dropdown-item" + > + Edit + + )} + {payload?.canDelete && ( + + } + onClick={() => + deleteTeamDialogHandle.openWithPayload({ + teamId: payload.teamId + }) + } + data-test-id="frontier-sdk-delete-team-dropdown-item" + style={{ + color: 'var(--rs-color-foreground-danger-primary)' + }} + > + Delete team + + )} + + ); + }} + + + + + + + ); +}