From 395f7eb7eb468f28a202398366970bc66804c842 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 10:59:19 +0000 Subject: [PATCH] Move members into Organization settings tab Remove the standalone People sidebar entry and person detail pages. Membership management now lives under Organization > Members with a simplified email and role table and the same invite, archive, and remove actions as before. Signed-off-by: Cursor Agent Co-authored-by: Bryan FRIMIN --- .../src/components/tasks/TasksCard.tsx | 7 +- .../iam/organizations/_components/Sidebar.tsx | 12 +- .../iam/organizations/people/PersonPage.tsx | 199 ------------ .../people/_components/PersonForm.tsx | 307 ------------------ .../pages/iam/organizations/people/routes.ts | 35 -- .../MembersSettingsPage.tsx} | 53 ++- .../MembersSettingsPageLoader.tsx} | 14 +- .../organizations/settings/SettingsLayout.tsx | 96 ++++-- .../SettingsLayoutLoader.tsx} | 36 +- .../_components/AddMemberDialog.tsx} | 8 +- .../settings/_components/AddMemberForm.tsx | 150 +++++++++ .../_components/MembersList.tsx} | 55 ++-- .../_components/MembersListItem.tsx} | 48 +-- apps/console/src/routes.tsx | 32 +- 14 files changed, 351 insertions(+), 701 deletions(-) delete mode 100644 apps/console/src/pages/iam/organizations/people/PersonPage.tsx delete mode 100644 apps/console/src/pages/iam/organizations/people/_components/PersonForm.tsx delete mode 100644 apps/console/src/pages/iam/organizations/people/routes.ts rename apps/console/src/pages/iam/organizations/{people/PeoplePage.tsx => settings/MembersSettingsPage.tsx} (62%) rename apps/console/src/pages/iam/organizations/{people/PeoplePageLoader.tsx => settings/MembersSettingsPageLoader.tsx} (73%) rename apps/console/src/pages/iam/organizations/{people/PersonPageLoader.tsx => settings/SettingsLayoutLoader.tsx} (55%) rename apps/console/src/pages/iam/organizations/{people/_components/AddPersonDialog.tsx => settings/_components/AddMemberDialog.tsx} (84%) create mode 100644 apps/console/src/pages/iam/organizations/settings/_components/AddMemberForm.tsx rename apps/console/src/pages/iam/organizations/{people/_components/PeopleList.tsx => settings/_components/MembersList.tsx} (64%) rename apps/console/src/pages/iam/organizations/{people/_components/PeopleListItem.tsx => settings/_components/MembersListItem.tsx} (84%) diff --git a/apps/console/src/components/tasks/TasksCard.tsx b/apps/console/src/components/tasks/TasksCard.tsx index b9f8f27f3e..82e0b41281 100644 --- a/apps/console/src/components/tasks/TasksCard.tsx +++ b/apps/console/src/components/tasks/TasksCard.tsx @@ -648,12 +648,7 @@ function TaskRow(props: TaskRowProps) { {task.assignedTo?.fullName && (
- - {task.assignedTo.fullName} - + {task.assignedTo.fullName}
)}
diff --git a/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx b/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx index 1725fbb008..55f1c398ea 100644 --- a/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx +++ b/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx @@ -20,7 +20,6 @@ import { IconBox, IconCircleProgress, IconFire3, - IconGroup1, IconInboxEmpty, IconKey, IconListStack, @@ -122,13 +121,6 @@ export function Sidebar(props: { fKey: SidebarFragment$key }) { to={`${prefix}/frameworks`} /> )} - {organization.canListMembers && ( - - )} {organization.canListThirdParties && ( )} - {organization.canUpdateOrganization && ( + {(organization.canUpdateOrganization || organization.canListMembers) && ( diff --git a/apps/console/src/pages/iam/organizations/people/PersonPage.tsx b/apps/console/src/pages/iam/organizations/people/PersonPage.tsx deleted file mode 100644 index 6dbfbc3575..0000000000 --- a/apps/console/src/pages/iam/organizations/people/PersonPage.tsx +++ /dev/null @@ -1,199 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -// PERFORMANCE OF THIS SOFTWARE. - -import { sprintf } from "@probo/helpers"; -import { useTranslate } from "@probo/i18n"; -import { ActionDropdown, Avatar, Badge, Breadcrumb, Card, DropdownItem, IconArchive, IconTrashCan, useConfirm } from "@probo/ui"; -import { type PreloadedQuery, usePreloadedQuery } from "react-relay"; -import { useNavigate } from "react-router"; -import { graphql } from "relay-runtime"; - -import type { PersonPageQuery } from "#/__generated__/iam/PersonPageQuery.graphql"; -import { useMutationWithToasts } from "#/hooks/useMutationWithToasts"; -import { useOrganizationId } from "#/hooks/useOrganizationId"; - -import { PersonFormLoader } from "./_components/PersonForm"; - -export const personPageQuery = graphql` - query PersonPageQuery($personId: ID!) { - person: node(id: $personId) @required(action: THROW) { - __typename - ... on Profile { - id - fullName - emailAddress - source - state - canDelete: permission(action: "iam:membership-profile:delete") - ...PersonFormFragment - } - } - } -`; - -const removeUserMutation = graphql` - mutation PersonPage_removeMutation( - $input: RemoveUserInput! - ) { - removeUser(input: $input) { - deletedProfileId - } - } -`; - -const archiveUserMutation = graphql` - mutation PersonPage_archiveMutation( - $input: ArchiveUserInput! - ) { - archiveUser(input: $input) { - archivedProfileId - } - } -`; - -export function PersonPage(props: { queryRef: PreloadedQuery }) { - const { queryRef } = props; - - const organizationId = useOrganizationId(); - const { __ } = useTranslate(); - const confirm = useConfirm(); - const navigate = useNavigate(); - - const { person } = usePreloadedQuery(personPageQuery, queryRef); - if (person.__typename !== "Profile") { - throw new Error("invalid type for node"); - } - - const [archiveUser, isArchiving] = useMutationWithToasts( - archiveUserMutation, - { - successMessage: __("Person archived successfully"), - errorMessage: __("Failed to archive person"), - }, - ); - const [removeUser, isRemoving] = useMutationWithToasts( - removeUserMutation, - { - successMessage: __("Person removed successfully"), - errorMessage: __("Failed to remove person"), - }, - ); - const isMutating = isArchiving || isRemoving; - - const handleArchive = () => { - confirm( - () => { - return archiveUser({ - variables: { - input: { - profileId: person.id, - organizationId: organizationId, - }, - }, - onCompleted: () => { - void navigate(`/organizations/${organizationId}/people`); - }, - }); - }, - { - message: sprintf( - __("Are you sure you want to archive %s?"), - person.fullName, - ), - }, - ); - }; - - const handleRemove = () => { - confirm( - () => { - return removeUser({ - variables: { - input: { - profileId: person.id, - organizationId: organizationId, - }, - }, - onCompleted: () => { - void navigate(`/organizations/${organizationId}/people`); - }, - }); - }, - { - message: sprintf( - __("Are you sure you want to remove %s?"), - person.fullName, - ), - }, - ); - }; - - const canArchive = person.canDelete && person.source !== "SCIM" && person.state !== "INACTIVE"; - const canRemove = person.canDelete && person.source !== "SCIM"; - - return ( -
- -
-
- -
-
- {person.fullName} - {person.source} -
-
{person.emailAddress}
-
-
- {(canArchive || canRemove) && ( - - {canArchive && ( - - {__("Archive")} - - )} - {canRemove && ( - - {__("Remove")} - - )} - - )} -
- - - - -
- ); -}; diff --git a/apps/console/src/pages/iam/organizations/people/_components/PersonForm.tsx b/apps/console/src/pages/iam/organizations/people/_components/PersonForm.tsx deleted file mode 100644 index 45a86d8cf8..0000000000 --- a/apps/console/src/pages/iam/organizations/people/_components/PersonForm.tsx +++ /dev/null @@ -1,307 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -// PERFORMANCE OF THIS SOFTWARE. - -import { formatDatetime, getAssignableRoles, getRoles } from "@probo/helpers"; -import { roles } from "@probo/helpers/src/roles"; -import { useTranslate } from "@probo/i18n"; -import { Button, Field, Input, Option } from "@probo/ui"; -import { use } from "react"; -import { useWatch } from "react-hook-form"; -import { useFragment } from "react-relay"; -import { type DataID, graphql } from "relay-runtime"; -import { z } from "zod"; - -import type { PersonForm_createMutation } from "#/__generated__/iam/PersonForm_createMutation.graphql"; -import type { PersonForm_updateMutation } from "#/__generated__/iam/PersonForm_updateMutation.graphql"; -import type { PersonFormFragment$key } from "#/__generated__/iam/PersonFormFragment.graphql"; -import { ControlledField } from "#/components/form/ControlledField"; -import { EmailsField } from "#/components/form/EmailsField"; -import { useFormWithSchema } from "#/hooks/useFormWithSchema"; -import { useMutationWithToasts } from "#/hooks/useMutationWithToasts"; -import { useOrganizationId } from "#/hooks/useOrganizationId"; -import { CurrentUser } from "#/providers/CurrentUser"; - -const fragment = graphql` - fragment PersonFormFragment on Profile { - id - fullName - emailAddress - source - membership @required(action: THROW) { - role - } - kind - position - additionalEmailAddresses - contractStartDate - contractEndDate - canUpdate: permission(action: "iam:membership-profile:update") - } -`; - -const createPersonMutation = graphql` - mutation PersonForm_createMutation($input: CreateUserInput! $connections: [ID!]!) { - createUser(input: $input) { - profileEdge @prependEdge(connections: $connections) { - node { - ...PeopleListItemFragment - } - } - } - } -`; - -const updatePersonMutation = graphql` - mutation PersonForm_updateMutation($input: UpdateUserInput!) { - updateUser(input: $input) { - profile { - id - } - } - } -`; - -const schema = z.object({ - fullName: z.string().min(1), - emailAddress: z.string().email(), - role: z.enum(roles), - position: z.string().min(1).optional().nullable(), - additionalEmailAddresses: z.preprocess( - // Empty additional emails are skipped - v => (v as string[]).filter(v => !!v), - z.array(z.string().email()), - ), - kind: z.string().min(1).optional().nullable(), - contractStartDate: z.string().optional().nullable(), - contractEndDate: z.string().optional().nullable(), -}); - -export function PersonForm(props: { - id?: string; - connectionId?: DataID; - disabled?: boolean; - scimManaged?: boolean; - defaultValues?: z.infer; - onSubmit?: () => void; -}) { - const { - id, - connectionId = "", - disabled = false, - scimManaged = false, - defaultValues = { - fullName: "", - emailAddress: "", - role: "EMPLOYEE", - additionalEmailAddresses: [], - kind: "EMPLOYEE", - position: null, - contractStartDate: null, - contractEndDate: null, - }, - onSubmit, - } = props; - - const organizationId = useOrganizationId(); - const { __ } = useTranslate(); - const { role } = use(CurrentUser); - const availableRoles = getAssignableRoles(role); - - const { control, formState, handleSubmit: handleSubmitWrapper, register, reset } - = useFormWithSchema(schema, { defaultValues }); - const watchedRole = useWatch({ - control, - name: "role", - defaultValue: "EMPLOYEE", - }); - const [createPerson, isCreating] = useMutationWithToasts( - createPersonMutation, - { - successMessage: __("Person created successfully."), - errorMessage: __("Failed to create person"), - }, - ); - const [updatePerson, isUpdating] = useMutationWithToasts( - updatePersonMutation, - { - successMessage: __("Person updated successfully."), - errorMessage: __("Failed to update person"), - }, - ); - const handleSubmit = handleSubmitWrapper(async (data: z.infer) => { - const sharedInput = { - fullName: data.fullName, - additionalEmailAddresses: data.additionalEmailAddresses, - kind: data.kind, - position: data.position, - contractStartDate: formatDatetime(data.contractStartDate) ?? null, - contractEndDate: formatDatetime(data.contractEndDate) ?? null, - }; - - if (id) { - await updatePerson({ - variables: { input: { ...sharedInput, id } }, - onCompleted: () => { - reset(data); - onSubmit?.(); - }, - }); - } else { - await createPerson({ - variables: { - input: { - ...sharedInput, - emailAddress: data.emailAddress, - role: data.role, - organizationId, - }, - connections: [connectionId], - }, - onCompleted: () => { - reset(data); - onSubmit?.(); - }, - }); - } - }); - - return ( -
void handleSubmit(e)} className="space-y-4"> - - {id - ? ( - <> - - - - ) - : ( - <> - - - {availableRoles.includes("OWNER") && ( - - )} - {availableRoles.includes("ADMIN") && ( - - )} - {availableRoles.includes("VIEWER") && ( - - )} - {availableRoles.includes("AUDITOR") && ( - - )} - {availableRoles.includes("EMPLOYEE") && ( - - )} - - -
- {watchedRole === "OWNER" && ( -

{__("Full access to everything")}

- )} - {watchedRole === "ADMIN" && ( -

- {__("Full access except organization setup and API keys")} -

- )} - {watchedRole === "VIEWER" &&

{__("Read-only access")}

} - {watchedRole === "AUDITOR" && ( -

- {__("Read-only access without settings and tasks")} -

- )} - {watchedRole === "EMPLOYEE" && ( -

{__("Access to employee page")}

- )} -
- - )} - - {getRoles(__).map(role => ( - - ))} - - - - - - - - - -
- {(!id || formState.isDirty) && !disabled && ( - - )} -
- - ); -} - -export function PersonFormLoader(props: { fragmentRef: PersonFormFragment$key }) { - const { fragmentRef } = props; - - const person = useFragment(fragment, fragmentRef); - - return ( - - ); -} diff --git a/apps/console/src/pages/iam/organizations/people/routes.ts b/apps/console/src/pages/iam/organizations/people/routes.ts deleted file mode 100644 index cb21363324..0000000000 --- a/apps/console/src/pages/iam/organizations/people/routes.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -// PERFORMANCE OF THIS SOFTWARE. - -import { lazy } from "@probo/react-lazy"; - -import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton"; - -export const peopleRoutes = [ - { - path: "people", - children: [ - { - index: true, - Component: lazy(() => import("#/pages/iam/organizations/people/PeoplePageLoader")), - Fallback: LinkCardSkeleton, - }, - { - path: ":personId", - Component: lazy(() => import("#/pages/iam/organizations/people/PersonPageLoader")), - Fallback: LinkCardSkeleton, - }, - ], - }, -]; diff --git a/apps/console/src/pages/iam/organizations/people/PeoplePage.tsx b/apps/console/src/pages/iam/organizations/settings/MembersSettingsPage.tsx similarity index 62% rename from apps/console/src/pages/iam/organizations/people/PeoplePage.tsx rename to apps/console/src/pages/iam/organizations/settings/MembersSettingsPage.tsx index a1df1cd76e..46d646dad0 100644 --- a/apps/console/src/pages/iam/organizations/people/PeoplePage.tsx +++ b/apps/console/src/pages/iam/organizations/settings/MembersSettingsPage.tsx @@ -13,32 +13,32 @@ // PERFORMANCE OF THIS SOFTWARE. import { useTranslate } from "@probo/i18n"; -import { Button, PageHeader } from "@probo/ui"; +import { Button } from "@probo/ui"; import { useState } from "react"; import { type PreloadedQuery, usePreloadedQuery } from "react-relay"; import { ConnectionHandler, type DataID, graphql } from "relay-runtime"; -import type { PeoplePageQuery } from "#/__generated__/iam/PeoplePageQuery.graphql"; +import type { MembersSettingsPageQuery } from "#/__generated__/iam/MembersSettingsPageQuery.graphql"; import { useOrganizationId } from "#/hooks/useOrganizationId"; -import { AddPersonDialog } from "./_components/AddPersonDialog"; -import { PeopleList } from "./_components/PeopleList"; +import { AddMemberDialog } from "./_components/AddMemberDialog"; +import { MembersList } from "./_components/MembersList"; -export const peoplePageQuery = graphql` - query PeoplePageQuery($organizationId: ID!) { +export const membersSettingsPageQuery = graphql` + query MembersSettingsPageQuery($organizationId: ID!) { organization: node(id: $organizationId) @required(action: THROW) { __typename ... on Organization { canCreateUser: permission(action: "iam:membership-profile:create") - ...PeopleListFragment - @arguments(first: 20, order: { direction: ASC, field: FULL_NAME }) + ...MembersListFragment + @arguments(first: 20, order: { direction: ASC, field: EMAIL_ADDRESS }) } } } `; -export function PeoplePage(props: { - queryRef: PreloadedQuery; +export function MembersSettingsPage(props: { + queryRef: PreloadedQuery; }) { const { queryRef } = props; @@ -48,13 +48,13 @@ export function PeoplePage(props: { const [connectionId, setConnectionId] = useState( ConnectionHandler.getConnectionID( organizationId, - "PeopleListFragment_profiles", - { orderBy: { direction: "ASC", field: "FULL_NAME" } }, + "MembersListFragment_profiles", + { orderBy: { direction: "ASC", field: "EMAIL_ADDRESS" } }, ), ); - const { organization } = usePreloadedQuery( - peoplePageQuery, + const { organization } = usePreloadedQuery( + membersSettingsPageQuery, queryRef, ); if (organization.__typename !== "Organization") { @@ -63,21 +63,18 @@ export function PeoplePage(props: { return (
- - {organization.canCreateUser - && ( - - - - )} - + {organization.canCreateUser && ( +
+ + + +
+ )} -
- -
+
); } diff --git a/apps/console/src/pages/iam/organizations/people/PeoplePageLoader.tsx b/apps/console/src/pages/iam/organizations/settings/MembersSettingsPageLoader.tsx similarity index 73% rename from apps/console/src/pages/iam/organizations/people/PeoplePageLoader.tsx rename to apps/console/src/pages/iam/organizations/settings/MembersSettingsPageLoader.tsx index c96a791214..76967d39f9 100644 --- a/apps/console/src/pages/iam/organizations/people/PeoplePageLoader.tsx +++ b/apps/console/src/pages/iam/organizations/settings/MembersSettingsPageLoader.tsx @@ -15,16 +15,16 @@ import { useEffect } from "react"; import { useQueryLoader } from "react-relay"; -import type { PeoplePageQuery } from "#/__generated__/iam/PeoplePageQuery.graphql"; +import type { MembersSettingsPageQuery } from "#/__generated__/iam/MembersSettingsPageQuery.graphql"; import { useOrganizationId } from "#/hooks/useOrganizationId"; import { IAMRelayProvider } from "#/providers/IAMRelayProvider"; -import { PeoplePage, peoplePageQuery } from "./PeoplePage"; +import { MembersSettingsPage, membersSettingsPageQuery } from "./MembersSettingsPage"; -function PeoplePageQueryLoader() { +function MembersSettingsPageQueryLoader() { const organizationId = useOrganizationId(); const [queryRef, loadQuery] - = useQueryLoader(peoplePageQuery); + = useQueryLoader(membersSettingsPageQuery); useEffect(() => { loadQuery({ @@ -36,13 +36,13 @@ function PeoplePageQueryLoader() { return null; } - return ; + return ; } -export default function PeoplePageLoader() { +export default function MembersSettingsPageLoader() { return ( - + ); } diff --git a/apps/console/src/pages/iam/organizations/settings/SettingsLayout.tsx b/apps/console/src/pages/iam/organizations/settings/SettingsLayout.tsx index ea06a8c19f..46163fae73 100644 --- a/apps/console/src/pages/iam/organizations/settings/SettingsLayout.tsx +++ b/apps/console/src/pages/iam/organizations/settings/SettingsLayout.tsx @@ -14,6 +14,7 @@ import { useTranslate } from "@probo/i18n"; import { + IconGroup1, IconKey, IconListStack, IconLock, @@ -23,39 +24,90 @@ import { TabLink, Tabs, } from "@probo/ui"; +import { type PreloadedQuery, useFragment, usePreloadedQuery } from "react-relay"; import { Outlet } from "react-router"; +import { graphql } from "relay-runtime"; +import type { SettingsLayoutFragment$key } from "#/__generated__/iam/SettingsLayoutFragment.graphql"; +import type { SettingsLayoutQuery } from "#/__generated__/iam/SettingsLayoutQuery.graphql"; import { useOrganizationId } from "#/hooks/useOrganizationId"; -export default function SettingsLayout() { +export const settingsLayoutQuery = graphql` + query SettingsLayoutQuery($organizationId: ID!) { + organization: node(id: $organizationId) @required(action: THROW) { + __typename + ... on Organization { + ...SettingsLayoutFragment + } + } + } +`; + +const fragment = graphql` + fragment SettingsLayoutFragment on Organization { + canListMembers: permission(action: "iam:membership:list") + canUpdateOrganization: permission(action: "iam:organization:update") + } +`; + +export function SettingsLayout(props: { + queryRef: PreloadedQuery; +}) { + const { queryRef } = props; + const organizationId = useOrganizationId(); const { __ } = useTranslate(); + const { organization } = usePreloadedQuery( + settingsLayoutQuery, + queryRef, + ); + if (organization.__typename !== "Organization") { + throw new Error("node is of invalid type"); + } + + const permissions = useFragment( + fragment, + organization, + ); + + const prefix = `/organizations/${organizationId}/settings`; + return (
- + - - - {__("General")} - - - - {__("SAML SSO")} - - - - {__("SCIM")} - - - - {__("Webhooks")} - - - - {__("Audit Log")} - + {permissions.canListMembers && ( + + + {__("Members")} + + )} + {permissions.canUpdateOrganization && ( + <> + + + {__("General")} + + + + {__("SAML SSO")} + + + + {__("SCIM")} + + + + {__("Webhooks")} + + + + {__("Audit Log")} + + + )} diff --git a/apps/console/src/pages/iam/organizations/people/PersonPageLoader.tsx b/apps/console/src/pages/iam/organizations/settings/SettingsLayoutLoader.tsx similarity index 55% rename from apps/console/src/pages/iam/organizations/people/PersonPageLoader.tsx rename to apps/console/src/pages/iam/organizations/settings/SettingsLayoutLoader.tsx index 03b5e63f3a..7a08d93ab2 100644 --- a/apps/console/src/pages/iam/organizations/people/PersonPageLoader.tsx +++ b/apps/console/src/pages/iam/organizations/settings/SettingsLayoutLoader.tsx @@ -1,4 +1,4 @@ -// Copyright (c) 2026 Probo Inc . +// Copyright (c) 2025-2026 Probo Inc . // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above @@ -14,34 +14,36 @@ import { useEffect } from "react"; import { useQueryLoader } from "react-relay"; -import { useParams } from "react-router"; -import type { PersonPageQuery } from "#/__generated__/iam/PersonPageQuery.graphql"; -import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton"; +import type { SettingsLayoutQuery } from "#/__generated__/iam/SettingsLayoutQuery.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; import { IAMRelayProvider } from "#/providers/IAMRelayProvider"; -import { PersonPage, personPageQuery } from "./PersonPage"; +import { SettingsLayout, settingsLayoutQuery } from "./SettingsLayout"; -function PersonPageQueryLoader() { - const { personId } = useParams(); - if (!personId) { - throw new Error(":personId missing in route params"); - } - const [queryRef, loadQuery] = useQueryLoader(personPageQuery); +function SettingsLayoutQueryLoader() { + const organizationId = useOrganizationId(); + const [queryRef, loadQuery] = useQueryLoader( + settingsLayoutQuery, + ); useEffect(() => { - loadQuery({ personId }); - }, [personId, loadQuery]); + loadQuery({ + organizationId, + }); + }, [loadQuery, organizationId]); - if (!queryRef) return ; + if (!queryRef) { + return null; + } - return ; + return ; } -export default function PersonPageLoader() { +export default function SettingsLayoutLoader() { return ( - + ); } diff --git a/apps/console/src/pages/iam/organizations/people/_components/AddPersonDialog.tsx b/apps/console/src/pages/iam/organizations/settings/_components/AddMemberDialog.tsx similarity index 84% rename from apps/console/src/pages/iam/organizations/people/_components/AddPersonDialog.tsx rename to apps/console/src/pages/iam/organizations/settings/_components/AddMemberDialog.tsx index 344bc385b8..01baa462af 100644 --- a/apps/console/src/pages/iam/organizations/people/_components/AddPersonDialog.tsx +++ b/apps/console/src/pages/iam/organizations/settings/_components/AddMemberDialog.tsx @@ -17,9 +17,9 @@ import { Dialog, DialogContent, useDialogRef } from "@probo/ui"; import { type PropsWithChildren } from "react"; import type { DataID } from "relay-runtime"; -import { PersonForm } from "./PersonForm"; +import { AddMemberForm } from "./AddMemberForm"; -export function AddPersonDialog(props: PropsWithChildren<{ +export function AddMemberDialog(props: PropsWithChildren<{ connectionId: DataID; }>) { const { children, connectionId } = props; @@ -28,13 +28,13 @@ export function AddPersonDialog(props: PropsWithChildren<{ return ( - dialogRef.current?.close()} /> + dialogRef.current?.close()} /> ); diff --git a/apps/console/src/pages/iam/organizations/settings/_components/AddMemberForm.tsx b/apps/console/src/pages/iam/organizations/settings/_components/AddMemberForm.tsx new file mode 100644 index 0000000000..1eb60205af --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/_components/AddMemberForm.tsx @@ -0,0 +1,150 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { getAssignableRoles } from "@probo/helpers"; +import { roles } from "@probo/helpers/src/roles"; +import { useTranslate } from "@probo/i18n"; +import { Button, Field, Option } from "@probo/ui"; +import { use } from "react"; +import { useWatch } from "react-hook-form"; +import { type DataID, graphql } from "relay-runtime"; +import { z } from "zod"; + +import type { AddMemberForm_createMutation } from "#/__generated__/iam/AddMemberForm_createMutation.graphql"; +import { ControlledField } from "#/components/form/ControlledField"; +import { useFormWithSchema } from "#/hooks/useFormWithSchema"; +import { useMutationWithToasts } from "#/hooks/useMutationWithToasts"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; +import { CurrentUser } from "#/providers/CurrentUser"; + +const createMemberMutation = graphql` + mutation AddMemberForm_createMutation($input: CreateUserInput! $connections: [ID!]!) { + createUser(input: $input) { + profileEdge @prependEdge(connections: $connections) { + node { + ...MembersListItemFragment + } + } + } + } +`; + +const schema = z.object({ + emailAddress: z.string().email(), + role: z.enum(roles), +}); + +export function AddMemberForm(props: { + connectionId: DataID; + onSubmit?: () => void; +}) { + const { connectionId, onSubmit } = props; + + const organizationId = useOrganizationId(); + const { __ } = useTranslate(); + const { role } = use(CurrentUser); + const availableRoles = getAssignableRoles(role); + + const { control, formState, handleSubmit: handleSubmitWrapper, register, reset } + = useFormWithSchema(schema, { + defaultValues: { + emailAddress: "", + role: "EMPLOYEE" as const, + }, + }); + const watchedRole = useWatch({ + control, + name: "role", + defaultValue: "EMPLOYEE", + }); + const [createMember, isCreating] = useMutationWithToasts( + createMemberMutation, + { + successMessage: __("Member added successfully."), + errorMessage: __("Failed to add member"), + }, + ); + + const handleSubmit = handleSubmitWrapper(async (data: z.infer) => { + await createMember({ + variables: { + input: { + fullName: data.emailAddress.split("@")[0] ?? data.emailAddress, + emailAddress: data.emailAddress, + role: data.role, + organizationId, + }, + connections: [connectionId], + }, + onCompleted: () => { + reset(); + onSubmit?.(); + }, + }); + }); + + return ( +
void handleSubmit(e)} className="space-y-4"> + + + {availableRoles.includes("OWNER") && ( + + )} + {availableRoles.includes("ADMIN") && ( + + )} + {availableRoles.includes("VIEWER") && ( + + )} + {availableRoles.includes("AUDITOR") && ( + + )} + {availableRoles.includes("EMPLOYEE") && ( + + )} + + +
+ {watchedRole === "OWNER" && ( +

{__("Full access to everything")}

+ )} + {watchedRole === "ADMIN" && ( +

+ {__("Full access except organization setup and API keys")} +

+ )} + {watchedRole === "VIEWER" &&

{__("Read-only access")}

} + {watchedRole === "AUDITOR" && ( +

+ {__("Read-only access without settings and tasks")} +

+ )} + {watchedRole === "EMPLOYEE" && ( +

{__("Access to employee page")}

+ )} +
+ +
+ +
+ + ); +} diff --git a/apps/console/src/pages/iam/organizations/people/_components/PeopleList.tsx b/apps/console/src/pages/iam/organizations/settings/_components/MembersList.tsx similarity index 64% rename from apps/console/src/pages/iam/organizations/people/_components/PeopleList.tsx rename to apps/console/src/pages/iam/organizations/settings/_components/MembersList.tsx index 9fc50ca22c..7dc4c939b7 100644 --- a/apps/console/src/pages/iam/organizations/people/_components/PeopleList.tsx +++ b/apps/console/src/pages/iam/organizations/settings/_components/MembersList.tsx @@ -19,22 +19,22 @@ import type { ComponentProps } from "react"; import { use } from "react"; import { ConnectionHandler, graphql, usePaginationFragment } from "react-relay"; -import type { PeopleListFragment$key } from "#/__generated__/iam/PeopleListFragment.graphql"; -import type { PeopleListFragment_RefetchQuery } from "#/__generated__/iam/PeopleListFragment_RefetchQuery.graphql"; +import type { MembersListFragment$key } from "#/__generated__/iam/MembersListFragment.graphql"; +import type { MembersListFragment_RefetchQuery } from "#/__generated__/iam/MembersListFragment_RefetchQuery.graphql"; import { type Order, SortableTable, SortableTh } from "#/components/SortableTable"; import { useOrganizationId } from "#/hooks/useOrganizationId"; import { CurrentUser } from "#/providers/CurrentUser"; -import { PeopleListItem } from "./PeopleListItem"; +import { MembersListItem } from "./MembersListItem"; const fragment = graphql` - fragment PeopleListFragment on Organization - @refetchable(queryName: "PeopleListFragment_RefetchQuery") + fragment MembersListFragment on Organization + @refetchable(queryName: "MembersListFragment_RefetchQuery") @argumentDefinitions( first: { type: "Int", defaultValue: 20 } order: { type: "ProfileOrder" - defaultValue: { direction: ASC, field: FULL_NAME } + defaultValue: { direction: ASC, field: EMAIL_ADDRESS } } after: { type: "CursorKey", defaultValue: null } before: { type: "CursorKey", defaultValue: null } @@ -46,21 +46,21 @@ const fragment = graphql` last: $last before: $before orderBy: $order - ) @connection(key: "PeopleListFragment_profiles", filters: ["orderBy"]) @required(action: THROW) { + ) @connection(key: "MembersListFragment_profiles", filters: ["orderBy"]) @required(action: THROW) { __id totalCount edges @required(action: THROW) { node { id - ...PeopleListItemFragment + ...MembersListItemFragment } } } } `; -export function PeopleList(props: { - fKey: PeopleListFragment$key; +export function MembersList(props: { + fKey: MembersListFragment$key; onConnectionIdChange: (connectionId: string) => void; }) { const { fKey, onConnectionIdChange } = props; @@ -70,30 +70,32 @@ export function PeopleList(props: { const { role } = use(CurrentUser); const canManageRoles = getAssignableRoles(role).length > 0; - const peoplePagination = usePaginationFragment< - PeopleListFragment_RefetchQuery, - PeopleListFragment$key + const membersPagination = usePaginationFragment< + MembersListFragment_RefetchQuery, + MembersListFragment$key >(fragment, fKey); - const refetchPeople = () => { - peoplePagination.refetch({}, { fetchPolicy: "network-only" }); + const refetchMembers = () => { + membersPagination.refetch({}, { fetchPolicy: "network-only" }); }; const handleOrderChange = (order: Order) => { onConnectionIdChange( ConnectionHandler.getConnectionID( organizationId, - "PeopleListFragment_profiles", + "MembersListFragment_profiles", { orderBy: order }, ), ); }; + const columnCount = canManageRoles ? 3 : 2; + return ( ["refetch"] } @@ -101,30 +103,27 @@ export function PeopleList(props: { > - {__("Name")} - {__("Status")} {__("Email")} {canManageRoles && {__("Role")}} - {__("Created on")} - {peoplePagination.data.profiles.totalCount === 0 + {membersPagination.data.profiles.totalCount === 0 ? ( - - {__("No people")} + + {__("No members")} ) : ( - peoplePagination.data.profiles.edges.map(({ node: profile }) => ( - ( + )) )} diff --git a/apps/console/src/pages/iam/organizations/people/_components/PeopleListItem.tsx b/apps/console/src/pages/iam/organizations/settings/_components/MembersListItem.tsx similarity index 84% rename from apps/console/src/pages/iam/organizations/people/_components/PeopleListItem.tsx rename to apps/console/src/pages/iam/organizations/settings/_components/MembersListItem.tsx index 93d4075e1d..580338357c 100644 --- a/apps/console/src/pages/iam/organizations/people/_components/PeopleListItem.tsx +++ b/apps/console/src/pages/iam/organizations/settings/_components/MembersListItem.tsx @@ -32,14 +32,14 @@ import { use } from "react"; import { useFragment } from "react-relay"; import { type DataID, graphql } from "relay-runtime"; -import type { PeopleListItem_inviteMutation } from "#/__generated__/iam/PeopleListItem_inviteMutation.graphql"; -import type { PeopleListItemFragment$key } from "#/__generated__/iam/PeopleListItemFragment.graphql"; +import type { MembersListItem_inviteMutation } from "#/__generated__/iam/MembersListItem_inviteMutation.graphql"; +import type { MembersListItemFragment$key } from "#/__generated__/iam/MembersListItemFragment.graphql"; import { useMutationWithToasts } from "#/hooks/useMutationWithToasts"; import { useOrganizationId } from "#/hooks/useOrganizationId"; import { CurrentUser } from "#/providers/CurrentUser"; const fragment = graphql` - fragment PeopleListItemFragment on Profile { + fragment MembersListItemFragment on Profile { id source state @@ -52,7 +52,7 @@ const fragment = graphql` } lastInvitation: pendingInvitations(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) @required(action: THROW) - @connection(key: "PeopleListItem_lastInvitation") { + @connection(key: "MembersListItem_lastInvitation") { __id edges { node { @@ -61,15 +61,13 @@ const fragment = graphql` } } } - createdAt - canUpdate: permission(action: "iam:membership-profile:update") canInvite: permission(action: "iam:invitation:create") canDelete: permission(action: "iam:membership-profile:delete") } `; const inviteUserMutation = graphql` - mutation PeopleListItem_inviteMutation( + mutation MembersListItem_inviteMutation( $input: InviteUserInput! $connections: [ID!]! ) { @@ -87,7 +85,7 @@ const inviteUserMutation = graphql` `; const updateRoleMutation = graphql` - mutation PeopleListItem_updateRoleMutation($input: UpdateMembershipInput!) { + mutation MembersListItem_updateRoleMutation($input: UpdateMembershipInput!) { updateMembership(input: $input) { membership { id @@ -98,7 +96,7 @@ const updateRoleMutation = graphql` `; const removeUserMutation = graphql` - mutation PeopleListItem_removeMutation( + mutation MembersListItem_removeMutation( $input: RemoveUserInput! $connections: [ID!]! ) { @@ -109,16 +107,16 @@ const removeUserMutation = graphql` `; const archiveUserMutation = graphql` - mutation PeopleListItem_archiveMutation($input: ArchiveUserInput!) { + mutation MembersListItem_archiveMutation($input: ArchiveUserInput!) { archiveUser(input: $input) { archivedProfileId } } `; -export function PeopleListItem(props: { +export function MembersListItem(props: { connectionId: DataID; - fKey: PeopleListItemFragment$key; + fKey: MembersListItemFragment$key; onRefetch: () => void; }) { const { fKey, connectionId, onRefetch } = props; @@ -130,7 +128,7 @@ export function PeopleListItem(props: { const { role } = use(CurrentUser); const availableRoles = getAssignableRoles(role); - const profile = useFragment(fragment, fKey); + const profile = useFragment(fragment, fKey); const lastInvitation = profile.lastInvitation.edges[0]?.node; const isInactive = profile.state === "INACTIVE"; @@ -140,7 +138,7 @@ export function PeopleListItem(props: { const canRemove = profile.canDelete && profile.source !== "SCIM"; const [inviteUser] - = useMutationWithToasts(inviteUserMutation, { + = useMutationWithToasts(inviteUserMutation, { successMessage: __("Invitation sent successfully"), errorMessage: __("Failed to send invitation"), }); @@ -250,17 +248,7 @@ export function PeopleListItem(props: { }; return ( - - - {profile.fullName} - - - {profile.state} - + {availableRoles.length > 0 && ( )} - - {new Date(profile.createdAt).toLocaleDateString()} - - + {(canSendActivationMail || canArchive || canRemove) && ( {canSendActivationMail && ( diff --git a/apps/console/src/routes.tsx b/apps/console/src/routes.tsx index 50d45852b1..4f6726f8b4 100644 --- a/apps/console/src/routes.tsx +++ b/apps/console/src/routes.tsx @@ -28,7 +28,6 @@ import { PageError } from "./components/PageError"; import { RootErrorBoundary } from "./components/RootErrorBoundary"; import { PageSkeleton } from "./components/skeletons/PageSkeleton"; import { ViewerLayoutLoading } from "./pages/iam/memberships/ViewerLayoutLoading"; -import { peopleRoutes } from "./pages/iam/organizations/people/routes"; import { compliancePageRoutes } from "./pages/organizations/compliance-page/routes"; import { cookieBannerRoutes } from "./pages/organizations/cookie-banners/routes"; import { riskAssessmentRoutes } from "./pages/organizations/risk-assessments/routes"; @@ -253,16 +252,23 @@ const routes = [ path: "settings", Fallback: PageSkeleton, Component: lazy( - () => import("./pages/iam/organizations/settings/SettingsLayout"), + () => import("./pages/iam/organizations/settings/SettingsLayoutLoader"), ), children: [ { index: true, loader: () => { // eslint-disable-next-line - throw redirect("general"); + throw redirect("members"); }, }, + { + path: "members", + Component: lazy( + () => + import("./pages/iam/organizations/settings/MembersSettingsPageLoader"), + ), + }, { path: "general", Component: lazy( @@ -300,7 +306,25 @@ const routes = [ }, ], }, - ...peopleRoutes, + { + path: "people", + children: [ + { + index: true, + loader: () => { + // eslint-disable-next-line + throw redirect("../settings/members"); + }, + }, + { + path: "*", + loader: () => { + // eslint-disable-next-line + throw redirect("../settings/members"); + }, + }, + ], + }, ...riskRoutes, ...riskAssessmentRoutes, ...measureRoutes,