From d4016186c27d5d5949e776707616eacbce3fbe09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Mon, 22 Dec 2025 13:14:03 +0100 Subject: [PATCH] Move page loaders in lazy components + plug viewer membership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- .../console/src/pages/iam/auth/AuthLayout.tsx | 22 ++++ .../console/src/pages/iam/auth/SignInPage.tsx | 2 +- .../iam/memberships/MembershipLayout.tsx | 24 +++- .../memberships/MembershipLayoutLoader.tsx | 38 ++++++ .../iam/memberships/MembershipLayoutQuery.tsx | 49 ------- .../pages/iam/memberships/MembershipsPage.tsx | 39 +++++- .../iam/memberships/MembershipsPageLoader.tsx | 33 +++++ .../iam/memberships/MembershipsPageQuery.tsx | 57 --------- .../MembershipLayoutQuery.graphql.ts | 118 +++++++++-------- .../_components/SessionDropdown.tsx | 27 ++-- .../SessionDropdownFragment.graphql.ts | 120 ++++++++++-------- .../iam/organizations/NewOrganizationPage.tsx | 11 +- .../settings/GeneralSettingsPage.tsx | 14 +- ...uery.tsx => GeneralSettingsPageLoader.tsx} | 23 +--- .../organizations/settings/SettingsLayout.tsx | 2 +- apps/console/src/routes.tsx | 35 ++--- packages/ui/src/Layouts/AuthLayout.tsx | 19 --- packages/ui/src/index.ts | 1 - pkg/iam/account_service.go | 36 ++++++ pkg/server/api/connect/v1/schema.graphql | 2 + pkg/server/api/connect/v1/schema/schema.go | 111 ++++++++++++++++ pkg/server/api/connect/v1/types/types.go | 1 + pkg/server/api/connect/v1/v1_resolver.go | 13 ++ 23 files changed, 496 insertions(+), 301 deletions(-) create mode 100644 apps/console/src/pages/iam/auth/AuthLayout.tsx create mode 100644 apps/console/src/pages/iam/memberships/MembershipLayoutLoader.tsx delete mode 100644 apps/console/src/pages/iam/memberships/MembershipLayoutQuery.tsx create mode 100644 apps/console/src/pages/iam/memberships/MembershipsPageLoader.tsx delete mode 100644 apps/console/src/pages/iam/memberships/MembershipsPageQuery.tsx rename apps/console/src/pages/iam/organizations/settings/{GeneralSettingsPageQuery.tsx => GeneralSettingsPageLoader.tsx} (60%) delete mode 100644 packages/ui/src/Layouts/AuthLayout.tsx diff --git a/apps/console/src/pages/iam/auth/AuthLayout.tsx b/apps/console/src/pages/iam/auth/AuthLayout.tsx new file mode 100644 index 000000000..d352fc61a --- /dev/null +++ b/apps/console/src/pages/iam/auth/AuthLayout.tsx @@ -0,0 +1,22 @@ +import { Logo } from "@probo/ui"; +import { Outlet } from "react-router"; +import { IAMRelayProvider } from "/providers/IAMRelayProvider"; + +export default function () { + return ( +
+
+
+ + + +
+
+
+
+ +
+
+
+ ); +} diff --git a/apps/console/src/pages/iam/auth/SignInPage.tsx b/apps/console/src/pages/iam/auth/SignInPage.tsx index d035c99c3..e48df1fa2 100644 --- a/apps/console/src/pages/iam/auth/SignInPage.tsx +++ b/apps/console/src/pages/iam/auth/SignInPage.tsx @@ -16,7 +16,7 @@ const signInMutation = graphql` } `; -// TODO initial screen + SAML login +// TODO initial screen + SAML login + after sign in URL state export default function SignInPage() { const { __ } = useTranslate(); diff --git a/apps/console/src/pages/iam/memberships/MembershipLayout.tsx b/apps/console/src/pages/iam/memberships/MembershipLayout.tsx index 70ca1504d..22de039a2 100644 --- a/apps/console/src/pages/iam/memberships/MembershipLayout.tsx +++ b/apps/console/src/pages/iam/memberships/MembershipLayout.tsx @@ -1,5 +1,4 @@ -import { usePreloadedQuery, type PreloadedQuery } from "react-relay"; -import { membershipLayoutQuery } from "./MembershipLayoutQuery"; +import { graphql, usePreloadedQuery, type PreloadedQuery } from "react-relay"; import { Link, Outlet } from "react-router"; import { Badge, Button, IconPeopleAdd, Layout, Skeleton } from "@probo/ui"; import { Sidebar } from "./_components/Sidebar"; @@ -11,7 +10,24 @@ import { Suspense } from "react"; import { useTranslate } from "@probo/i18n"; import { CoreRelayProvider } from "/providers/CoreRelayProvider"; -export default function MembershipLayout(props: { +export const membershipLayoutQuery = graphql` + query MembershipLayoutQuery($organizationId: ID!) { + organization: node(id: $organizationId) @required(action: THROW) { + ... on Organization { + ...MembershipsDropdown_organizationFragment + ...SessionDropdownFragment + } + } + viewer @required(action: THROW) { + ...MembershipsDropdown_viewerFragment + pendingInvitations @required(action: THROW) { + totalCount @required(action: THROW) + } + } + } +`; + +export function MembershipLayout(props: { queryRef: PreloadedQuery; }) { const { queryRef } = props; @@ -47,7 +63,7 @@ export default function MembershipLayout(props: { )} }> - + } diff --git a/apps/console/src/pages/iam/memberships/MembershipLayoutLoader.tsx b/apps/console/src/pages/iam/memberships/MembershipLayoutLoader.tsx new file mode 100644 index 000000000..4e7433964 --- /dev/null +++ b/apps/console/src/pages/iam/memberships/MembershipLayoutLoader.tsx @@ -0,0 +1,38 @@ +import { useQueryLoader } from "react-relay"; +import { Suspense, useEffect } from "react"; +import { Skeleton } from "@probo/ui"; +import { useOrganizationId } from "/hooks/useOrganizationId"; +import type { MembershipLayoutQuery } from "./__generated__/MembershipLayoutQuery.graphql"; +import { MembershipLayout, membershipLayoutQuery } from "./MembershipLayout"; +import { IAMRelayProvider } from "/providers/IAMRelayProvider"; + +function MembershipLayoutLoader() { + const organizationId = useOrganizationId(); + const [queryRef, loadQuery] = useQueryLoader( + membershipLayoutQuery, + ); + + useEffect(() => { + loadQuery({ + organizationId, + }); + }, [loadQuery, organizationId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} + +export default function () { + return ( + + + + ); +} diff --git a/apps/console/src/pages/iam/memberships/MembershipLayoutQuery.tsx b/apps/console/src/pages/iam/memberships/MembershipLayoutQuery.tsx deleted file mode 100644 index 471ef0533..000000000 --- a/apps/console/src/pages/iam/memberships/MembershipLayoutQuery.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { lazy } from "@probo/react-lazy"; -import { useQueryLoader } from "react-relay"; -import { graphql } from "relay-runtime"; -import { Suspense, useEffect } from "react"; -import { Skeleton } from "@probo/ui"; -import { useOrganizationId } from "/hooks/useOrganizationId"; -import type { MembershipLayoutQuery } from "./__generated__/MembershipLayoutQuery.graphql"; - -const Layout = lazy(() => import("./MembershipLayout")); - -export const membershipLayoutQuery = graphql` - query MembershipLayoutQuery($organizationId: ID!) { - organization: node(id: $organizationId) @required(action: THROW) { - ... on Organization { - ...MembershipsDropdown_organizationFragment - } - } - viewer @required(action: THROW) { - ...MembershipsDropdown_viewerFragment - pendingInvitations @required(action: THROW) { - totalCount @required(action: THROW) - } - ...SessionDropdownFragment @arguments(organizationId: $organizationId) - } - } -`; - -export function MembershipLayoutQuery() { - const organizationId = useOrganizationId(); - const [queryRef, loadQuery] = useQueryLoader( - membershipLayoutQuery, - ); - - useEffect(() => { - loadQuery({ - organizationId, - }); - }, [loadQuery, organizationId]); - - if (!queryRef) { - return ; - } - - return ( - }> - - - ); -} diff --git a/apps/console/src/pages/iam/memberships/MembershipsPage.tsx b/apps/console/src/pages/iam/memberships/MembershipsPage.tsx index 9876edf82..0fba51228 100644 --- a/apps/console/src/pages/iam/memberships/MembershipsPage.tsx +++ b/apps/console/src/pages/iam/memberships/MembershipsPage.tsx @@ -1,4 +1,4 @@ -import { usePreloadedQuery, type PreloadedQuery } from "react-relay"; +import { graphql, usePreloadedQuery, type PreloadedQuery } from "react-relay"; import { useTranslate } from "@probo/i18n"; import { Button, @@ -9,15 +9,42 @@ import { } from "@probo/ui"; import { useMemo, useState } from "react"; import { InvitationCard } from "./_components/InvitationCard"; -import { membershipsPageQuery } from "./MembershipsPageQuery"; import { MembershipCard } from "./_components/MembershipCard"; import type { MembershipsPageQuery } from "./__generated__/MembershipsPageQuery.graphql"; -interface PageProps { - queryRef: PreloadedQuery; -} +export const membershipsPageQuery = graphql` + query MembershipsPageQuery { + viewer @required(action: THROW) { + memberships(first: 1000, orderBy: { direction: DESC, field: CREATED_AT }) + @required(action: THROW) { + edges @required(action: THROW) { + node @required(action: THROW) { + id + ...MembershipCardFragment + organization @required(action: THROW) { + name + } + } + } + } + pendingInvitations( + first: 1000 + orderBy: { direction: DESC, field: CREATED_AT } + ) @required(action: THROW) { + edges @required(action: THROW) { + node @required(action: THROW) { + id + ...InvitationCardFragment + } + } + } + } + } +`; -export default function MembershipsPage(props: PageProps) { +export function MembershipsPage(props: { + queryRef: PreloadedQuery; +}) { const { __ } = useTranslate(); const [search, setSearch] = useState(""); diff --git a/apps/console/src/pages/iam/memberships/MembershipsPageLoader.tsx b/apps/console/src/pages/iam/memberships/MembershipsPageLoader.tsx new file mode 100644 index 000000000..66695c4e3 --- /dev/null +++ b/apps/console/src/pages/iam/memberships/MembershipsPageLoader.tsx @@ -0,0 +1,33 @@ +import { useQueryLoader } from "react-relay"; +import { Suspense, useEffect } from "react"; +import { CenteredLayoutSkeleton } from "@probo/ui"; +import type { MembershipsPageQuery } from "./__generated__/MembershipsPageQuery.graphql"; +import { MembershipsPage, membershipsPageQuery } from "./MembershipsPage"; +import { IAMRelayProvider } from "/providers/IAMRelayProvider"; + +function MembershipsPageLoader() { + const [queryRef, loadQuery] = + useQueryLoader(membershipsPageQuery); + + useEffect(() => { + loadQuery({}); + }, [loadQuery]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} + +export default function () { + return ( + + + + ); +} diff --git a/apps/console/src/pages/iam/memberships/MembershipsPageQuery.tsx b/apps/console/src/pages/iam/memberships/MembershipsPageQuery.tsx deleted file mode 100644 index cd33d8b6d..000000000 --- a/apps/console/src/pages/iam/memberships/MembershipsPageQuery.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { lazy } from "@probo/react-lazy"; -import { useQueryLoader } from "react-relay"; -import { graphql } from "relay-runtime"; -import { Suspense, useEffect } from "react"; -import { CenteredLayoutSkeleton } from "@probo/ui"; -import type { MembershipsPageQuery } from "./__generated__/MembershipsPageQuery.graphql"; - -const Page = lazy(() => import("./MembershipsPage")); - -export const membershipsPageQuery = graphql` - query MembershipsPageQuery { - viewer @required(action: THROW) { - memberships(first: 1000, orderBy: { direction: DESC, field: CREATED_AT }) - @required(action: THROW) { - edges @required(action: THROW) { - node @required(action: THROW) { - id - ...MembershipCardFragment - organization @required(action: THROW) { - name - } - } - } - } - pendingInvitations( - first: 1000 - orderBy: { direction: DESC, field: CREATED_AT } - ) @required(action: THROW) { - edges @required(action: THROW) { - node @required(action: THROW) { - id - ...InvitationCardFragment - } - } - } - } - } -`; - -export function MembershipsPageQuery() { - const [queryRef, loadQuery] = - useQueryLoader(membershipsPageQuery); - - useEffect(() => { - loadQuery({}); - }, [loadQuery]); - - if (!queryRef) { - return ; - } - - return ( - }> - - - ); -} diff --git a/apps/console/src/pages/iam/memberships/__generated__/MembershipLayoutQuery.graphql.ts b/apps/console/src/pages/iam/memberships/__generated__/MembershipLayoutQuery.graphql.ts index 477ec2648..2f4229d0b 100644 --- a/apps/console/src/pages/iam/memberships/__generated__/MembershipLayoutQuery.graphql.ts +++ b/apps/console/src/pages/iam/memberships/__generated__/MembershipLayoutQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<4b0d5d271f19e23939fc2b392aa79fbc>> + * @generated SignedSource<<5ad02f9e0c07f5e9900005150f93d6fe>> * @lightSyntaxTransform * @nogrep */ @@ -15,13 +15,13 @@ export type MembershipLayoutQuery$variables = { }; export type MembershipLayoutQuery$data = { readonly organization: { - readonly " $fragmentSpreads": FragmentRefs<"MembershipsDropdown_organizationFragment">; + readonly " $fragmentSpreads": FragmentRefs<"MembershipsDropdown_organizationFragment" | "SessionDropdownFragment">; }; readonly viewer: { readonly pendingInvitations: { readonly totalCount: number; }; - readonly " $fragmentSpreads": FragmentRefs<"MembershipsDropdown_viewerFragment" | "SessionDropdownFragment">; + readonly " $fragmentSpreads": FragmentRefs<"MembershipsDropdown_viewerFragment">; }; }; export type MembershipLayoutQuery = { @@ -51,14 +51,7 @@ v2 = { "name": "totalCount", "storageKey": null }, -v3 = [ - { - "kind": "Variable", - "name": "organizationId", - "variableName": "organizationId" - } -], -v4 = { +v3 = { "alias": null, "args": null, "kind": "ScalarField", @@ -89,6 +82,11 @@ return { "args": null, "kind": "FragmentSpread", "name": "MembershipsDropdown_organizationFragment" + }, + { + "args": null, + "kind": "FragmentSpread", + "name": "SessionDropdownFragment" } ], "type": "Organization", @@ -133,11 +131,6 @@ return { "storageKey": null }, "action": "THROW" - }, - { - "args": (v3/*: any*/), - "kind": "FragmentSpread", - "name": "SessionDropdownFragment" } ], "storageKey": null @@ -178,12 +171,62 @@ return { "kind": "ScalarField", "name": "name", "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "Membership", + "kind": "LinkedField", + "name": "viewerMembership", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Identity", + "kind": "LinkedField", + "name": "identity", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "email", + "storageKey": null + }, + (v3/*: any*/) + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "MembershipProfile", + "kind": "LinkedField", + "name": "profile", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "fullName", + "storageKey": null + }, + (v3/*: any*/) + ], + "storageKey": null + }, + (v3/*: any*/) + ], + "storageKey": null } ], "type": "Organization", "abstractKey": null }, - (v4/*: any*/) + (v3/*: any*/) ], "storageKey": null }, @@ -207,56 +250,23 @@ return { ], "storageKey": null }, - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "email", - "storageKey": null - }, - { - "alias": null, - "args": (v3/*: any*/), - "concreteType": "IdentityProfile", - "kind": "LinkedField", - "name": "profileFor", - "plural": false, - "selections": [ - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "firstName", - "storageKey": null - }, - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "lastName", - "storageKey": null - }, - (v4/*: any*/) - ], - "storageKey": null - }, - (v4/*: any*/) + (v3/*: any*/) ], "storageKey": null } ] }, "params": { - "cacheID": "a849dd96b5088ddc9a3bbab50273c411", + "cacheID": "1fcebe9376b82798d829d98e2bdabaf3", "id": null, "metadata": {}, "name": "MembershipLayoutQuery", "operationKind": "query", - "text": "query MembershipLayoutQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n ...MembershipsDropdown_organizationFragment\n }\n id\n }\n viewer {\n ...MembershipsDropdown_viewerFragment\n pendingInvitations {\n totalCount\n }\n ...SessionDropdownFragment_4xMPKw\n id\n }\n}\n\nfragment MembershipsDropdown_organizationFragment on Organization {\n name\n}\n\nfragment MembershipsDropdown_viewerFragment on Identity {\n pendingInvitations {\n totalCount\n }\n}\n\nfragment SessionDropdownFragment_4xMPKw on Identity {\n email\n profileFor(organizationId: $organizationId) {\n firstName\n lastName\n id\n }\n}\n" + "text": "query MembershipLayoutQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n ...MembershipsDropdown_organizationFragment\n ...SessionDropdownFragment\n }\n id\n }\n viewer {\n ...MembershipsDropdown_viewerFragment\n pendingInvitations {\n totalCount\n }\n id\n }\n}\n\nfragment MembershipsDropdown_organizationFragment on Organization {\n name\n}\n\nfragment MembershipsDropdown_viewerFragment on Identity {\n pendingInvitations {\n totalCount\n }\n}\n\nfragment SessionDropdownFragment on Organization {\n viewerMembership {\n identity {\n email\n id\n }\n profile {\n fullName\n id\n }\n id\n }\n}\n" } }; })(); -(node as any).hash = "e28f1f5e510386718b39cd547d4ce59f"; +(node as any).hash = "c703bc4dce3307a22e42b5fa528537b2"; export default node; diff --git a/apps/console/src/pages/iam/memberships/_components/SessionDropdown.tsx b/apps/console/src/pages/iam/memberships/_components/SessionDropdown.tsx index 0792fd3a9..802c814ab 100644 --- a/apps/console/src/pages/iam/memberships/_components/SessionDropdown.tsx +++ b/apps/console/src/pages/iam/memberships/_components/SessionDropdown.tsx @@ -17,12 +17,14 @@ import { useTranslate } from "@probo/i18n"; import type { SessionDropdownFragment$key } from "./__generated__/SessionDropdownFragment.graphql"; export const fragment = graphql` - fragment SessionDropdownFragment on Identity - @argumentDefinitions(organizationId: { type: "ID!" }) { - email - profileFor(organizationId: $organizationId) { - firstName - lastName + fragment SessionDropdownFragment on Organization { + viewerMembership @required(action: THROW) { + identity @required(action: THROW) { + email + } + profile @required(action: THROW) { + fullName + } } } `; @@ -43,8 +45,12 @@ export function SessionDropdown(props: { fKey: SessionDropdownFragment$key }) { const { isAuthorized } = use(PermissionsContext); const { toast } = useToast(); - const { email, profileFor: profile } = - useFragment(fragment, fKey); + const { + viewerMembership: { + identity: { email }, + profile: { fullName }, + }, + } = useFragment(fragment, fKey); const [signOut] = useMutation(signOutMutation); const handleLogout: React.MouseEventHandler = (e) => { @@ -65,11 +71,6 @@ export function SessionDropdown(props: { fKey: SessionDropdownFragment$key }) { }); }; - // FIXME: user should always have a default profile - const fullName = profile?.firstName - ? `${profile.firstName} ${profile.lastName}` - : email; - return ( {isAuthorized("Organization", "deleteOrganization") && ( diff --git a/apps/console/src/pages/iam/memberships/_components/__generated__/SessionDropdownFragment.graphql.ts b/apps/console/src/pages/iam/memberships/_components/__generated__/SessionDropdownFragment.graphql.ts index 7a82b79a8..63504446e 100644 --- a/apps/console/src/pages/iam/memberships/_components/__generated__/SessionDropdownFragment.graphql.ts +++ b/apps/console/src/pages/iam/memberships/_components/__generated__/SessionDropdownFragment.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<0d0906bc941900bb200a6a9824aaacc4>> * @lightSyntaxTransform * @nogrep */ @@ -11,11 +11,14 @@ import { ReaderFragment } from 'relay-runtime'; import { FragmentRefs } from "relay-runtime"; export type SessionDropdownFragment$data = { - readonly email: any; - readonly profileFor: { - readonly firstName: string | null | undefined; - readonly lastName: string | null | undefined; - } | null | undefined; + readonly viewerMembership: { + readonly identity: { + readonly email: any; + }; + readonly profile: { + readonly fullName: string; + }; + }; readonly " $fragmentType": "SessionDropdownFragment"; }; export type SessionDropdownFragment$key = { @@ -24,60 +27,75 @@ export type SessionDropdownFragment$key = { }; const node: ReaderFragment = { - "argumentDefinitions": [ - { - "defaultValue": null, - "kind": "LocalArgument", - "name": "organizationId" - } - ], + "argumentDefinitions": [], "kind": "Fragment", "metadata": null, "name": "SessionDropdownFragment", "selections": [ { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "email", - "storageKey": null - }, - { - "alias": null, - "args": [ - { - "kind": "Variable", - "name": "organizationId", - "variableName": "organizationId" - } - ], - "concreteType": "IdentityProfile", - "kind": "LinkedField", - "name": "profileFor", - "plural": false, - "selections": [ - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "firstName", - "storageKey": null - }, - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "lastName", - "storageKey": null - } - ], - "storageKey": null + "kind": "RequiredField", + "field": { + "alias": null, + "args": null, + "concreteType": "Membership", + "kind": "LinkedField", + "name": "viewerMembership", + "plural": false, + "selections": [ + { + "kind": "RequiredField", + "field": { + "alias": null, + "args": null, + "concreteType": "Identity", + "kind": "LinkedField", + "name": "identity", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "email", + "storageKey": null + } + ], + "storageKey": null + }, + "action": "THROW" + }, + { + "kind": "RequiredField", + "field": { + "alias": null, + "args": null, + "concreteType": "MembershipProfile", + "kind": "LinkedField", + "name": "profile", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "fullName", + "storageKey": null + } + ], + "storageKey": null + }, + "action": "THROW" + } + ], + "storageKey": null + }, + "action": "THROW" } ], - "type": "Identity", + "type": "Organization", "abstractKey": null }; -(node as any).hash = "de1d0f069e5648898cab6a56a394d86f"; +(node as any).hash = "c283fcefcab15b977b44ece82f7cf450"; export default node; diff --git a/apps/console/src/pages/iam/organizations/NewOrganizationPage.tsx b/apps/console/src/pages/iam/organizations/NewOrganizationPage.tsx index 886a1d560..9f15e9ef8 100644 --- a/apps/console/src/pages/iam/organizations/NewOrganizationPage.tsx +++ b/apps/console/src/pages/iam/organizations/NewOrganizationPage.tsx @@ -5,6 +5,7 @@ import type { FormEventHandler } from "react"; import { useNavigate } from "react-router"; import { formatError, type GraphQLError } from "@probo/helpers"; import type { NewOrganizationPageMutation } from "./__generated__/NewOrganizationPageMutation.graphql"; +import { IAMRelayProvider } from "/providers/IAMRelayProvider"; const createOrganizationMutation = graphql` mutation NewOrganizationPageMutation($input: CreateOrganizationInput!) { @@ -17,7 +18,7 @@ const createOrganizationMutation = graphql` } `; -export default function Page() { +function NewOrganizationPage() { const navigate = useNavigate(); const { toast } = useToast(); const { __ } = useTranslate(); @@ -97,3 +98,11 @@ export default function Page() { ); } + +export default function () { + return ( + + + + ); +} diff --git a/apps/console/src/pages/iam/organizations/settings/GeneralSettingsPage.tsx b/apps/console/src/pages/iam/organizations/settings/GeneralSettingsPage.tsx index 9426a1dc2..05f0191ec 100644 --- a/apps/console/src/pages/iam/organizations/settings/GeneralSettingsPage.tsx +++ b/apps/console/src/pages/iam/organizations/settings/GeneralSettingsPage.tsx @@ -7,11 +7,23 @@ import { useMutationWithToasts } from "/hooks/useMutationWithToasts"; import { graphql } from "relay-runtime"; import { usePreloadedQuery, type PreloadedQuery } from "react-relay"; import type { GeneralSettingsPageQuery } from "./__generated__/GeneralSettingsPageQuery.graphql"; -import { generalSettingsPageQuery } from "./GeneralSettingsPageQuery"; import type { GeneralSettingsPage_deleteMutation } from "./__generated__/GeneralSettingsPage_deleteMutation.graphql"; import { DeleteOrganizationDialog } from "/components/organizations/DeleteOrganizationDialog"; import { OrganizationForm } from "./_components/OrganizationForm"; +export const generalSettingsPageQuery = graphql` + query GeneralSettingsPageQuery($organizationId: ID!) { + organization: node(id: $organizationId) @required(action: THROW) { + __typename + ... on Organization { + id + name @required(action: THROW) + ...OrganizationFormFragment + } + } + } +`; + const deleteOrganizationMutation = graphql` mutation GeneralSettingsPage_deleteMutation( $input: DeleteOrganizationInput! diff --git a/apps/console/src/pages/iam/organizations/settings/GeneralSettingsPageQuery.tsx b/apps/console/src/pages/iam/organizations/settings/GeneralSettingsPageLoader.tsx similarity index 60% rename from apps/console/src/pages/iam/organizations/settings/GeneralSettingsPageQuery.tsx rename to apps/console/src/pages/iam/organizations/settings/GeneralSettingsPageLoader.tsx index 0a71d55e7..1c09b9157 100644 --- a/apps/console/src/pages/iam/organizations/settings/GeneralSettingsPageQuery.tsx +++ b/apps/console/src/pages/iam/organizations/settings/GeneralSettingsPageLoader.tsx @@ -1,25 +1,14 @@ -import { graphql } from "relay-runtime"; import { useOrganizationId } from "/hooks/useOrganizationId"; import { useQueryLoader } from "react-relay"; import { useEffect } from "react"; -import { GeneralSettingsPage } from "./GeneralSettingsPage"; +import { + GeneralSettingsPage, + generalSettingsPageQuery, +} from "./GeneralSettingsPage"; import type { GeneralSettingsPageQuery } from "./__generated__/GeneralSettingsPageQuery.graphql"; import { IAMRelayProvider } from "/providers/IAMRelayProvider"; -export const generalSettingsPageQuery = graphql` - query GeneralSettingsPageQuery($organizationId: ID!) { - organization: node(id: $organizationId) @required(action: THROW) { - __typename - ... on Organization { - id - name @required(action: THROW) - ...OrganizationFormFragment - } - } - } -`; - -function GeneralSettingsPageQuery() { +function GeneralSettingsPageLoader() { const organizationId = useOrganizationId(); const [queryRef, loadQuery] = useQueryLoader( generalSettingsPageQuery, @@ -41,7 +30,7 @@ function GeneralSettingsPageQuery() { export default function () { return ( - + ); } diff --git a/apps/console/src/pages/iam/organizations/settings/SettingsLayout.tsx b/apps/console/src/pages/iam/organizations/settings/SettingsLayout.tsx index d5981ebf5..e1e6fbc8d 100644 --- a/apps/console/src/pages/iam/organizations/settings/SettingsLayout.tsx +++ b/apps/console/src/pages/iam/organizations/settings/SettingsLayout.tsx @@ -11,7 +11,7 @@ import { Outlet } from "react-router"; import { useOrganizationId } from "/hooks/useOrganizationId"; import { useTranslate } from "@probo/i18n"; -export default function SettingsLayout() { +export default function () { const organizationId = useOrganizationId(); const { __ } = useTranslate(); diff --git a/apps/console/src/routes.tsx b/apps/console/src/routes.tsx index 129baa987..7f4e9fbf2 100644 --- a/apps/console/src/routes.tsx +++ b/apps/console/src/routes.tsx @@ -5,7 +5,7 @@ import { useRouteError, } from "react-router"; import { EmployeeLayout } from "./layouts/EmployeeLayout"; -import { AuthLayout, CenteredLayout, CenteredLayoutSkeleton } from "@probo/ui"; +import { CenteredLayout, CenteredLayoutSkeleton } from "@probo/ui"; import { PageSkeleton } from "./components/skeletons/PageSkeleton.tsx"; import { loadQuery } from "react-relay"; import { riskRoutes } from "./routes/riskRoutes.ts"; @@ -46,13 +46,6 @@ import { UnAuthenticatedError, UnauthorizedError, } from "@probo/relay"; -import { MembershipsPageQuery } from "./pages/iam/memberships/MembershipsPageQuery.tsx"; -import { MembershipLayoutQuery } from "./pages/iam/memberships/MembershipLayoutQuery.tsx"; -import { IAMRelayProvider } from "./providers/IAMRelayProvider.tsx"; - -const NewOrganizationPage = lazy( - () => import("./pages/iam/organizations/NewOrganizationPage"), -); /** * Top level error boundary @@ -78,11 +71,7 @@ function ErrorBoundary({ error: propsError }: { error?: string }) { const routes = [ { path: "/auth", - Component: () => ( - - - - ), + Component: lazy(() => import("./pages/iam/auth/AuthLayout")), children: [ { path: "login", @@ -118,18 +107,14 @@ const routes = [ children: [ { index: true, - Component: () => ( - - - + Component: lazy( + () => import("./pages/iam/memberships/MembershipsPageLoader"), ), }, { path: "organizations/new", - Component: () => ( - - - + Component: lazy( + () => import("./pages/iam/organizations/NewOrganizationPage"), ), }, { @@ -184,10 +169,8 @@ const routes = [ }, { path: "/organizations/:organizationId", - Component: () => ( - - - + Component: lazy( + () => import("./pages/iam/memberships/MembershipLayoutLoader.tsx"), ), ErrorBoundary: ErrorBoundary, children: [ @@ -222,7 +205,7 @@ const routes = [ path: "general", Component: lazy( () => - import("./pages/iam/organizations/settings/GeneralSettingsPageQuery.tsx"), + import("./pages/iam/organizations/settings/GeneralSettingsPageLoader.tsx"), ), }, // { diff --git a/packages/ui/src/Layouts/AuthLayout.tsx b/packages/ui/src/Layouts/AuthLayout.tsx deleted file mode 100644 index 930b200c3..000000000 --- a/packages/ui/src/Layouts/AuthLayout.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Outlet } from "react-router"; -import { Logo } from "../Atoms/Logo/Logo"; - -export function AuthLayout() { - return ( -
-
-
- -
-
-
-
- -
-
-
- ); -} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 967159602..a8bf59f25 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,6 +1,5 @@ // Layouts export { Layout, Drawer } from "./Layouts/Layout"; -export { AuthLayout } from "./Layouts/AuthLayout"; export { ErrorLayout } from "./Layouts/ErrorLayout"; export { CenteredLayout, diff --git a/pkg/iam/account_service.go b/pkg/iam/account_service.go index a3b20ae13..a96b3c9e4 100644 --- a/pkg/iam/account_service.go +++ b/pkg/iam/account_service.go @@ -16,6 +16,7 @@ package iam import ( "context" + "errors" "fmt" "time" @@ -715,6 +716,41 @@ func (s AccountService) ListOrganizations(ctx context.Context, identityID gid.GI return organizations, nil } +func (s AccountService) GetMembershipForOrganization( + ctx context.Context, + identityID gid.GID, + organizationID gid.GID, +) (*coredata.Membership, error) { + membership := &coredata.Membership{} + + err := s.pg.WithTx( + ctx, + func(tx pg.Conn) error { + identity := &coredata.Identity{} + + if err := identity.LoadByID(ctx, tx, identityID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return NewIdentityNotFoundError(identityID) + } + + return fmt.Errorf("cannot load identity %q: %w", identityID, err) + } + + if err := membership.LoadByIdentityInOrganization(ctx, tx, identityID, organizationID); err != nil { + return fmt.Errorf("cannot load membership: %w", err) + } + + return nil + }, + ) + + if err != nil { + return nil, err + } + + return membership, nil +} + func (s AccountService) GetProfileForMembership(ctx context.Context, membershipID gid.GID) (*coredata.MembershipProfile, error) { var ( scope = coredata.NewScopeFromObjectID(membershipID) diff --git a/pkg/server/api/connect/v1/schema.graphql b/pkg/server/api/connect/v1/schema.graphql index fc6a85aef..f39a2f447 100644 --- a/pkg/server/api/connect/v1/schema.graphql +++ b/pkg/server/api/connect/v1/schema.graphql @@ -208,6 +208,8 @@ type Organization implements Node { last: Int before: CursorKey ): SAMLConfigurationConnection @goField(forceResolver: true) + + viewerMembership: Membership @goField(forceResolver: true) } enum MembershipRole diff --git a/pkg/server/api/connect/v1/schema/schema.go b/pkg/server/api/connect/v1/schema/schema.go index 091e73fb3..fcf80cc07 100644 --- a/pkg/server/api/connect/v1/schema/schema.go +++ b/pkg/server/api/connect/v1/schema/schema.go @@ -237,6 +237,7 @@ type ComplexityRoot struct { Name func(childComplexity int) int SamlConfigurations func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey) int UpdatedAt func(childComplexity int) int + ViewerMembership func(childComplexity int) int WebsiteURL func(childComplexity int) int } @@ -477,6 +478,7 @@ type OrganizationResolver interface { Members(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.MembershipOrderBy) (*types.MembershipConnection, error) Invitations(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, status *coredata.InvitationStatus) (*types.InvitationConnection, error) SamlConfigurations(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.SAMLConfigurationConnection, error) + ViewerMembership(ctx context.Context, obj *types.Organization) (*types.Membership, error) } type PersonalAPIKeyConnectionResolver interface { TotalCount(ctx context.Context, obj *types.PersonalAPIKeyConnection) (*int, error) @@ -1282,6 +1284,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.Organization.UpdatedAt(childComplexity), true + case "Organization.viewerMembership": + if e.complexity.Organization.ViewerMembership == nil { + break + } + + return e.complexity.Organization.ViewerMembership(childComplexity), true case "Organization.websiteUrl": if e.complexity.Organization.WebsiteURL == nil { break @@ -2189,6 +2197,8 @@ type Organization implements Node { last: Int before: CursorKey ): SAMLConfigurationConnection @goField(forceResolver: true) + + viewerMembership: Membership @goField(forceResolver: true) } enum MembershipRole @@ -3621,6 +3631,8 @@ func (ec *executionContext) fieldContext_CreateOrganizationPayload_organization( return ec.fieldContext_Organization_invitations(ctx, field) case "samlConfigurations": return ec.fieldContext_Organization_samlConfigurations(ctx, field) + case "viewerMembership": + return ec.fieldContext_Organization_viewerMembership(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Organization", field.Name) }, @@ -3870,6 +3882,8 @@ func (ec *executionContext) fieldContext_DeleteOrganizationHorizontalLogoPayload return ec.fieldContext_Organization_invitations(ctx, field) case "samlConfigurations": return ec.fieldContext_Organization_samlConfigurations(ctx, field) + case "viewerMembership": + return ec.fieldContext_Organization_viewerMembership(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Organization", field.Name) }, @@ -4639,6 +4653,8 @@ func (ec *executionContext) fieldContext_Invitation_organization(_ context.Conte return ec.fieldContext_Organization_invitations(ctx, field) case "samlConfigurations": return ec.fieldContext_Organization_samlConfigurations(ctx, field) + case "viewerMembership": + return ec.fieldContext_Organization_viewerMembership(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Organization", field.Name) }, @@ -5071,6 +5087,8 @@ func (ec *executionContext) fieldContext_Membership_organization(_ context.Conte return ec.fieldContext_Organization_invitations(ctx, field) case "samlConfigurations": return ec.fieldContext_Organization_samlConfigurations(ctx, field) + case "viewerMembership": + return ec.fieldContext_Organization_viewerMembership(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Organization", field.Name) }, @@ -7562,6 +7580,53 @@ func (ec *executionContext) fieldContext_Organization_samlConfigurations(ctx con return fc, nil } +func (ec *executionContext) _Organization_viewerMembership(ctx context.Context, field graphql.CollectedField, obj *types.Organization) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Organization_viewerMembership, + func(ctx context.Context) (any, error) { + return ec.resolvers.Organization().ViewerMembership(ctx, obj) + }, + nil, + ec.marshalOMembership2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐMembership, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Organization_viewerMembership(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Organization", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Membership_id(ctx, field) + case "createdAt": + return ec.fieldContext_Membership_createdAt(ctx, field) + case "identity": + return ec.fieldContext_Membership_identity(ctx, field) + case "profile": + return ec.fieldContext_Membership_profile(ctx, field) + case "organization": + return ec.fieldContext_Membership_organization(ctx, field) + case "role": + return ec.fieldContext_Membership_role(ctx, field) + case "permissions": + return ec.fieldContext_Membership_permissions(ctx, field) + case "lastSession": + return ec.fieldContext_Membership_lastSession(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Membership", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _OrganizationSessionCreated_session(ctx context.Context, field graphql.CollectedField, obj *types.OrganizationSessionCreated) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -7975,6 +8040,8 @@ func (ec *executionContext) fieldContext_Permission_organization(_ context.Conte return ec.fieldContext_Organization_invitations(ctx, field) case "samlConfigurations": return ec.fieldContext_Organization_samlConfigurations(ctx, field) + case "viewerMembership": + return ec.fieldContext_Organization_viewerMembership(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Organization", field.Name) }, @@ -8332,6 +8399,8 @@ func (ec *executionContext) fieldContext_PersonalAPIKey_organizations(_ context. return ec.fieldContext_Organization_invitations(ctx, field) case "samlConfigurations": return ec.fieldContext_Organization_samlConfigurations(ctx, field) + case "viewerMembership": + return ec.fieldContext_Organization_viewerMembership(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Organization", field.Name) }, @@ -10561,6 +10630,8 @@ func (ec *executionContext) fieldContext_UpdateOrganizationPayload_organization( return ec.fieldContext_Organization_invitations(ctx, field) case "samlConfigurations": return ec.fieldContext_Organization_samlConfigurations(ctx, field) + case "viewerMembership": + return ec.fieldContext_Organization_viewerMembership(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Organization", field.Name) }, @@ -15059,6 +15130,39 @@ func (ec *executionContext) _Organization(ctx context.Context, sel ast.Selection continue } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "viewerMembership": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Organization_viewerMembership(ctx, field, obj) + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) default: panic("unknown field " + strconv.Quote(field.Name)) @@ -18731,6 +18835,13 @@ func (ec *executionContext) marshalOInviteMemberPayload2ᚖgoᚗproboᚗincᚋpr return ec._InviteMemberPayload(ctx, sel, v) } +func (ec *executionContext) marshalOMembership2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐMembership(ctx context.Context, sel ast.SelectionSet, v *types.Membership) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._Membership(ctx, sel, v) +} + func (ec *executionContext) marshalOMembershipConnection2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐMembershipConnection(ctx context.Context, sel ast.SelectionSet, v *types.MembershipConnection) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/pkg/server/api/connect/v1/types/types.go b/pkg/server/api/connect/v1/types/types.go index 59565e1e9..730a9ed31 100644 --- a/pkg/server/api/connect/v1/types/types.go +++ b/pkg/server/api/connect/v1/types/types.go @@ -239,6 +239,7 @@ type Organization struct { Members *MembershipConnection `json:"members,omitempty"` Invitations *InvitationConnection `json:"invitations,omitempty"` SamlConfigurations *SAMLConfigurationConnection `json:"samlConfigurations,omitempty"` + ViewerMembership *Membership `json:"viewerMembership,omitempty"` } func (Organization) IsNode() {} diff --git a/pkg/server/api/connect/v1/v1_resolver.go b/pkg/server/api/connect/v1/v1_resolver.go index 0b24d056d..f43cb28b0 100644 --- a/pkg/server/api/connect/v1/v1_resolver.go +++ b/pkg/server/api/connect/v1/v1_resolver.go @@ -1013,6 +1013,19 @@ func (r *organizationResolver) SamlConfigurations(ctx context.Context, obj *type return types.NewSAMLConfigurationConnection(page, r, obj.ID), nil } +// ViewerMembership is the resolver for the viewerMembership field. +func (r *organizationResolver) ViewerMembership(ctx context.Context, obj *types.Organization) (*types.Membership, error) { + identity := IdentityFromContext(ctx) + + membership, err := r.iam.AccountService.GetMembershipForOrganization(ctx, identity.ID, obj.ID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot get membership for organization", log.Error(err)) + return nil, gqlutils.InternalServerError(ctx) + } + + return types.NewMembership(membership), nil +} + // TotalCount is the resolver for the totalCount field. func (r *personalAPIKeyConnectionResolver) TotalCount(ctx context.Context, obj *types.PersonalAPIKeyConnection) (*int, error) { switch obj.Resolver.(type) {