Move page loaders in lazy components + plug viewer membership

Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
Émile Ré
2025-12-22 13:14:03 +01:00
committed by Bryan Frimin
parent 7ea232cceb
commit d4016186c2
23 changed files with 496 additions and 301 deletions

View File

@@ -0,0 +1,22 @@
import { Logo } from "@probo/ui";
import { Outlet } from "react-router";
import { IAMRelayProvider } from "/providers/IAMRelayProvider";
export default function () {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-screen text-txt-primary">
<div className="bg-level-0 flex flex-col items-center justify-center">
<div className="w-full max-w-md px-6">
<IAMRelayProvider>
<Outlet />
</IAMRelayProvider>
</div>
</div>
<div className="hidden lg:flex bg-dialog font-bold flex-col items-center justify-center p-8 text-txt-primary lg:p-10">
<div className="flex flex-col items-center justify-center gap-4">
<Logo withPicto className="w-[440px]" />
</div>
</div>
</div>
);
}

View File

@@ -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() { export default function SignInPage() {
const { __ } = useTranslate(); const { __ } = useTranslate();

View File

@@ -1,5 +1,4 @@
import { usePreloadedQuery, type PreloadedQuery } from "react-relay"; import { graphql, usePreloadedQuery, type PreloadedQuery } from "react-relay";
import { membershipLayoutQuery } from "./MembershipLayoutQuery";
import { Link, Outlet } from "react-router"; import { Link, Outlet } from "react-router";
import { Badge, Button, IconPeopleAdd, Layout, Skeleton } from "@probo/ui"; import { Badge, Button, IconPeopleAdd, Layout, Skeleton } from "@probo/ui";
import { Sidebar } from "./_components/Sidebar"; import { Sidebar } from "./_components/Sidebar";
@@ -11,7 +10,24 @@ import { Suspense } from "react";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { CoreRelayProvider } from "/providers/CoreRelayProvider"; 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<MembershipLayoutQuery>; queryRef: PreloadedQuery<MembershipLayoutQuery>;
}) { }) {
const { queryRef } = props; const { queryRef } = props;
@@ -47,7 +63,7 @@ export default function MembershipLayout(props: {
)} )}
</div> </div>
<Suspense fallback={<Skeleton className="w-32 h-8" />}> <Suspense fallback={<Skeleton className="w-32 h-8" />}>
<SessionDropdown fKey={viewer} /> <SessionDropdown fKey={organization} />
</Suspense> </Suspense>
</> </>
} }

View File

@@ -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>(
membershipLayoutQuery,
);
useEffect(() => {
loadQuery({
organizationId,
});
}, [loadQuery, organizationId]);
if (!queryRef) {
return <Skeleton className="w-full h-screen" />;
}
return (
<Suspense fallback={<Skeleton className="w-full h-screen" />}>
<MembershipLayout queryRef={queryRef} />
</Suspense>
);
}
export default function () {
return (
<IAMRelayProvider>
<MembershipLayoutLoader />
</IAMRelayProvider>
);
}

View File

@@ -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>(
membershipLayoutQuery,
);
useEffect(() => {
loadQuery({
organizationId,
});
}, [loadQuery, organizationId]);
if (!queryRef) {
return <Skeleton className="w-full h-screen" />;
}
return (
<Suspense fallback={<Skeleton className="w-full h-screen" />}>
<Layout queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -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 { useTranslate } from "@probo/i18n";
import { import {
Button, Button,
@@ -9,15 +9,42 @@ import {
} from "@probo/ui"; } from "@probo/ui";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { InvitationCard } from "./_components/InvitationCard"; import { InvitationCard } from "./_components/InvitationCard";
import { membershipsPageQuery } from "./MembershipsPageQuery";
import { MembershipCard } from "./_components/MembershipCard"; import { MembershipCard } from "./_components/MembershipCard";
import type { MembershipsPageQuery } from "./__generated__/MembershipsPageQuery.graphql"; import type { MembershipsPageQuery } from "./__generated__/MembershipsPageQuery.graphql";
interface PageProps { export const membershipsPageQuery = graphql`
queryRef: PreloadedQuery<MembershipsPageQuery>; 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<MembershipsPageQuery>;
}) {
const { __ } = useTranslate(); const { __ } = useTranslate();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");

View File

@@ -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>(membershipsPageQuery);
useEffect(() => {
loadQuery({});
}, [loadQuery]);
if (!queryRef) {
return <CenteredLayoutSkeleton />;
}
return (
<Suspense fallback={<CenteredLayoutSkeleton />}>
<MembershipsPage queryRef={queryRef} />
</Suspense>
);
}
export default function () {
return (
<IAMRelayProvider>
<MembershipsPageLoader />
</IAMRelayProvider>
);
}

View File

@@ -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>(membershipsPageQuery);
useEffect(() => {
loadQuery({});
}, [loadQuery]);
if (!queryRef) {
return <CenteredLayoutSkeleton />;
}
return (
<Suspense fallback={<CenteredLayoutSkeleton />}>
<Page queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<4b0d5d271f19e23939fc2b392aa79fbc>> * @generated SignedSource<<5ad02f9e0c07f5e9900005150f93d6fe>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -15,13 +15,13 @@ export type MembershipLayoutQuery$variables = {
}; };
export type MembershipLayoutQuery$data = { export type MembershipLayoutQuery$data = {
readonly organization: { readonly organization: {
readonly " $fragmentSpreads": FragmentRefs<"MembershipsDropdown_organizationFragment">; readonly " $fragmentSpreads": FragmentRefs<"MembershipsDropdown_organizationFragment" | "SessionDropdownFragment">;
}; };
readonly viewer: { readonly viewer: {
readonly pendingInvitations: { readonly pendingInvitations: {
readonly totalCount: number; readonly totalCount: number;
}; };
readonly " $fragmentSpreads": FragmentRefs<"MembershipsDropdown_viewerFragment" | "SessionDropdownFragment">; readonly " $fragmentSpreads": FragmentRefs<"MembershipsDropdown_viewerFragment">;
}; };
}; };
export type MembershipLayoutQuery = { export type MembershipLayoutQuery = {
@@ -51,14 +51,7 @@ v2 = {
"name": "totalCount", "name": "totalCount",
"storageKey": null "storageKey": null
}, },
v3 = [ v3 = {
{
"kind": "Variable",
"name": "organizationId",
"variableName": "organizationId"
}
],
v4 = {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
@@ -89,6 +82,11 @@ return {
"args": null, "args": null,
"kind": "FragmentSpread", "kind": "FragmentSpread",
"name": "MembershipsDropdown_organizationFragment" "name": "MembershipsDropdown_organizationFragment"
},
{
"args": null,
"kind": "FragmentSpread",
"name": "SessionDropdownFragment"
} }
], ],
"type": "Organization", "type": "Organization",
@@ -133,11 +131,6 @@ return {
"storageKey": null "storageKey": null
}, },
"action": "THROW" "action": "THROW"
},
{
"args": (v3/*: any*/),
"kind": "FragmentSpread",
"name": "SessionDropdownFragment"
} }
], ],
"storageKey": null "storageKey": null
@@ -178,12 +171,62 @@ return {
"kind": "ScalarField", "kind": "ScalarField",
"name": "name", "name": "name",
"storageKey": null "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", "type": "Organization",
"abstractKey": null "abstractKey": null
}, },
(v4/*: any*/) (v3/*: any*/)
], ],
"storageKey": null "storageKey": null
}, },
@@ -207,56 +250,23 @@ return {
], ],
"storageKey": null "storageKey": null
}, },
{ (v3/*: any*/)
"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*/)
], ],
"storageKey": null "storageKey": null
} }
] ]
}, },
"params": { "params": {
"cacheID": "a849dd96b5088ddc9a3bbab50273c411", "cacheID": "1fcebe9376b82798d829d98e2bdabaf3",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "MembershipLayoutQuery", "name": "MembershipLayoutQuery",
"operationKind": "query", "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; export default node;

View File

@@ -17,12 +17,14 @@ import { useTranslate } from "@probo/i18n";
import type { SessionDropdownFragment$key } from "./__generated__/SessionDropdownFragment.graphql"; import type { SessionDropdownFragment$key } from "./__generated__/SessionDropdownFragment.graphql";
export const fragment = graphql` export const fragment = graphql`
fragment SessionDropdownFragment on Identity fragment SessionDropdownFragment on Organization {
@argumentDefinitions(organizationId: { type: "ID!" }) { viewerMembership @required(action: THROW) {
email identity @required(action: THROW) {
profileFor(organizationId: $organizationId) { email
firstName }
lastName profile @required(action: THROW) {
fullName
}
} }
} }
`; `;
@@ -43,8 +45,12 @@ export function SessionDropdown(props: { fKey: SessionDropdownFragment$key }) {
const { isAuthorized } = use(PermissionsContext); const { isAuthorized } = use(PermissionsContext);
const { toast } = useToast(); const { toast } = useToast();
const { email, profileFor: profile } = const {
useFragment<SessionDropdownFragment$key>(fragment, fKey); viewerMembership: {
identity: { email },
profile: { fullName },
},
} = useFragment<SessionDropdownFragment$key>(fragment, fKey);
const [signOut] = useMutation(signOutMutation); const [signOut] = useMutation(signOutMutation);
const handleLogout: React.MouseEventHandler<HTMLAnchorElement> = (e) => { const handleLogout: React.MouseEventHandler<HTMLAnchorElement> = (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 ( return (
<UserDropdown fullName={fullName} email={email}> <UserDropdown fullName={fullName} email={email}>
{isAuthorized("Organization", "deleteOrganization") && ( {isAuthorized("Organization", "deleteOrganization") && (

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<ccb8e318151fff4495fb2eaacc671612>> * @generated SignedSource<<0d0906bc941900bb200a6a9824aaacc4>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -11,11 +11,14 @@
import { ReaderFragment } from 'relay-runtime'; import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime"; import { FragmentRefs } from "relay-runtime";
export type SessionDropdownFragment$data = { export type SessionDropdownFragment$data = {
readonly email: any; readonly viewerMembership: {
readonly profileFor: { readonly identity: {
readonly firstName: string | null | undefined; readonly email: any;
readonly lastName: string | null | undefined; };
} | null | undefined; readonly profile: {
readonly fullName: string;
};
};
readonly " $fragmentType": "SessionDropdownFragment"; readonly " $fragmentType": "SessionDropdownFragment";
}; };
export type SessionDropdownFragment$key = { export type SessionDropdownFragment$key = {
@@ -24,60 +27,75 @@ export type SessionDropdownFragment$key = {
}; };
const node: ReaderFragment = { const node: ReaderFragment = {
"argumentDefinitions": [ "argumentDefinitions": [],
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
}
],
"kind": "Fragment", "kind": "Fragment",
"metadata": null, "metadata": null,
"name": "SessionDropdownFragment", "name": "SessionDropdownFragment",
"selections": [ "selections": [
{ {
"alias": null, "kind": "RequiredField",
"args": null, "field": {
"kind": "ScalarField", "alias": null,
"name": "email", "args": null,
"storageKey": null "concreteType": "Membership",
}, "kind": "LinkedField",
{ "name": "viewerMembership",
"alias": null, "plural": false,
"args": [ "selections": [
{ {
"kind": "Variable", "kind": "RequiredField",
"name": "organizationId", "field": {
"variableName": "organizationId" "alias": null,
} "args": null,
], "concreteType": "Identity",
"concreteType": "IdentityProfile", "kind": "LinkedField",
"kind": "LinkedField", "name": "identity",
"name": "profileFor", "plural": false,
"plural": false, "selections": [
"selections": [ {
{ "alias": null,
"alias": null, "args": null,
"args": null, "kind": "ScalarField",
"kind": "ScalarField", "name": "email",
"name": "firstName", "storageKey": null
"storageKey": null }
}, ],
{ "storageKey": null
"alias": null, },
"args": null, "action": "THROW"
"kind": "ScalarField", },
"name": "lastName", {
"storageKey": null "kind": "RequiredField",
} "field": {
], "alias": null,
"storageKey": 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 "abstractKey": null
}; };
(node as any).hash = "de1d0f069e5648898cab6a56a394d86f"; (node as any).hash = "c283fcefcab15b977b44ece82f7cf450";
export default node; export default node;

View File

@@ -5,6 +5,7 @@ import type { FormEventHandler } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { formatError, type GraphQLError } from "@probo/helpers"; import { formatError, type GraphQLError } from "@probo/helpers";
import type { NewOrganizationPageMutation } from "./__generated__/NewOrganizationPageMutation.graphql"; import type { NewOrganizationPageMutation } from "./__generated__/NewOrganizationPageMutation.graphql";
import { IAMRelayProvider } from "/providers/IAMRelayProvider";
const createOrganizationMutation = graphql` const createOrganizationMutation = graphql`
mutation NewOrganizationPageMutation($input: CreateOrganizationInput!) { mutation NewOrganizationPageMutation($input: CreateOrganizationInput!) {
@@ -17,7 +18,7 @@ const createOrganizationMutation = graphql`
} }
`; `;
export default function Page() { function NewOrganizationPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const { __ } = useTranslate(); const { __ } = useTranslate();
@@ -97,3 +98,11 @@ export default function Page() {
</div> </div>
); );
} }
export default function () {
return (
<IAMRelayProvider>
<NewOrganizationPage />
</IAMRelayProvider>
);
}

View File

@@ -7,11 +7,23 @@ import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
import { usePreloadedQuery, type PreloadedQuery } from "react-relay"; import { usePreloadedQuery, type PreloadedQuery } from "react-relay";
import type { GeneralSettingsPageQuery } from "./__generated__/GeneralSettingsPageQuery.graphql"; import type { GeneralSettingsPageQuery } from "./__generated__/GeneralSettingsPageQuery.graphql";
import { generalSettingsPageQuery } from "./GeneralSettingsPageQuery";
import type { GeneralSettingsPage_deleteMutation } from "./__generated__/GeneralSettingsPage_deleteMutation.graphql"; import type { GeneralSettingsPage_deleteMutation } from "./__generated__/GeneralSettingsPage_deleteMutation.graphql";
import { DeleteOrganizationDialog } from "/components/organizations/DeleteOrganizationDialog"; import { DeleteOrganizationDialog } from "/components/organizations/DeleteOrganizationDialog";
import { OrganizationForm } from "./_components/OrganizationForm"; 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` const deleteOrganizationMutation = graphql`
mutation GeneralSettingsPage_deleteMutation( mutation GeneralSettingsPage_deleteMutation(
$input: DeleteOrganizationInput! $input: DeleteOrganizationInput!

View File

@@ -1,25 +1,14 @@
import { graphql } from "relay-runtime";
import { useOrganizationId } from "/hooks/useOrganizationId"; import { useOrganizationId } from "/hooks/useOrganizationId";
import { useQueryLoader } from "react-relay"; import { useQueryLoader } from "react-relay";
import { useEffect } from "react"; import { useEffect } from "react";
import { GeneralSettingsPage } from "./GeneralSettingsPage"; import {
GeneralSettingsPage,
generalSettingsPageQuery,
} from "./GeneralSettingsPage";
import type { GeneralSettingsPageQuery } from "./__generated__/GeneralSettingsPageQuery.graphql"; import type { GeneralSettingsPageQuery } from "./__generated__/GeneralSettingsPageQuery.graphql";
import { IAMRelayProvider } from "/providers/IAMRelayProvider"; import { IAMRelayProvider } from "/providers/IAMRelayProvider";
export const generalSettingsPageQuery = graphql` function GeneralSettingsPageLoader() {
query GeneralSettingsPageQuery($organizationId: ID!) {
organization: node(id: $organizationId) @required(action: THROW) {
__typename
... on Organization {
id
name @required(action: THROW)
...OrganizationFormFragment
}
}
}
`;
function GeneralSettingsPageQuery() {
const organizationId = useOrganizationId(); const organizationId = useOrganizationId();
const [queryRef, loadQuery] = useQueryLoader<GeneralSettingsPageQuery>( const [queryRef, loadQuery] = useQueryLoader<GeneralSettingsPageQuery>(
generalSettingsPageQuery, generalSettingsPageQuery,
@@ -41,7 +30,7 @@ function GeneralSettingsPageQuery() {
export default function () { export default function () {
return ( return (
<IAMRelayProvider> <IAMRelayProvider>
<GeneralSettingsPageQuery /> <GeneralSettingsPageLoader />
</IAMRelayProvider> </IAMRelayProvider>
); );
} }

View File

@@ -11,7 +11,7 @@ import { Outlet } from "react-router";
import { useOrganizationId } from "/hooks/useOrganizationId"; import { useOrganizationId } from "/hooks/useOrganizationId";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
export default function SettingsLayout() { export default function () {
const organizationId = useOrganizationId(); const organizationId = useOrganizationId();
const { __ } = useTranslate(); const { __ } = useTranslate();

View File

@@ -5,7 +5,7 @@ import {
useRouteError, useRouteError,
} from "react-router"; } from "react-router";
import { EmployeeLayout } from "./layouts/EmployeeLayout"; 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 { PageSkeleton } from "./components/skeletons/PageSkeleton.tsx";
import { loadQuery } from "react-relay"; import { loadQuery } from "react-relay";
import { riskRoutes } from "./routes/riskRoutes.ts"; import { riskRoutes } from "./routes/riskRoutes.ts";
@@ -46,13 +46,6 @@ import {
UnAuthenticatedError, UnAuthenticatedError,
UnauthorizedError, UnauthorizedError,
} from "@probo/relay"; } 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 * Top level error boundary
@@ -78,11 +71,7 @@ function ErrorBoundary({ error: propsError }: { error?: string }) {
const routes = [ const routes = [
{ {
path: "/auth", path: "/auth",
Component: () => ( Component: lazy(() => import("./pages/iam/auth/AuthLayout")),
<IAMRelayProvider>
<AuthLayout />
</IAMRelayProvider>
),
children: [ children: [
{ {
path: "login", path: "login",
@@ -118,18 +107,14 @@ const routes = [
children: [ children: [
{ {
index: true, index: true,
Component: () => ( Component: lazy(
<IAMRelayProvider> () => import("./pages/iam/memberships/MembershipsPageLoader"),
<MembershipsPageQuery />
</IAMRelayProvider>
), ),
}, },
{ {
path: "organizations/new", path: "organizations/new",
Component: () => ( Component: lazy(
<IAMRelayProvider> () => import("./pages/iam/organizations/NewOrganizationPage"),
<NewOrganizationPage />
</IAMRelayProvider>
), ),
}, },
{ {
@@ -184,10 +169,8 @@ const routes = [
}, },
{ {
path: "/organizations/:organizationId", path: "/organizations/:organizationId",
Component: () => ( Component: lazy(
<IAMRelayProvider> () => import("./pages/iam/memberships/MembershipLayoutLoader.tsx"),
<MembershipLayoutQuery />
</IAMRelayProvider>
), ),
ErrorBoundary: ErrorBoundary, ErrorBoundary: ErrorBoundary,
children: [ children: [
@@ -222,7 +205,7 @@ const routes = [
path: "general", path: "general",
Component: lazy( Component: lazy(
() => () =>
import("./pages/iam/organizations/settings/GeneralSettingsPageQuery.tsx"), import("./pages/iam/organizations/settings/GeneralSettingsPageLoader.tsx"),
), ),
}, },
// { // {

View File

@@ -1,19 +0,0 @@
import { Outlet } from "react-router";
import { Logo } from "../Atoms/Logo/Logo";
export function AuthLayout() {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-screen text-txt-primary">
<div className="bg-level-0 flex flex-col items-center justify-center">
<div className="w-full max-w-md px-6">
<Outlet />
</div>
</div>
<div className="hidden lg:flex bg-dialog font-bold flex flex-col items-center justify-center p-8 text-txt-primary lg:p-10">
<div className="flex flex-col items-center justify-center gap-4">
<Logo withPicto className="w-[440px]" />
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
// Layouts // Layouts
export { Layout, Drawer } from "./Layouts/Layout"; export { Layout, Drawer } from "./Layouts/Layout";
export { AuthLayout } from "./Layouts/AuthLayout";
export { ErrorLayout } from "./Layouts/ErrorLayout"; export { ErrorLayout } from "./Layouts/ErrorLayout";
export { export {
CenteredLayout, CenteredLayout,

View File

@@ -16,6 +16,7 @@ package iam
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"time" "time"
@@ -715,6 +716,41 @@ func (s AccountService) ListOrganizations(ctx context.Context, identityID gid.GI
return organizations, nil 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) { func (s AccountService) GetProfileForMembership(ctx context.Context, membershipID gid.GID) (*coredata.MembershipProfile, error) {
var ( var (
scope = coredata.NewScopeFromObjectID(membershipID) scope = coredata.NewScopeFromObjectID(membershipID)

View File

@@ -208,6 +208,8 @@ type Organization implements Node {
last: Int last: Int
before: CursorKey before: CursorKey
): SAMLConfigurationConnection @goField(forceResolver: true) ): SAMLConfigurationConnection @goField(forceResolver: true)
viewerMembership: Membership @goField(forceResolver: true)
} }
enum MembershipRole enum MembershipRole

View File

@@ -237,6 +237,7 @@ type ComplexityRoot struct {
Name func(childComplexity int) int Name func(childComplexity int) int
SamlConfigurations func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey) int SamlConfigurations func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey) int
UpdatedAt func(childComplexity int) int UpdatedAt func(childComplexity int) int
ViewerMembership func(childComplexity int) int
WebsiteURL 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) 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) 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) 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 { type PersonalAPIKeyConnectionResolver interface {
TotalCount(ctx context.Context, obj *types.PersonalAPIKeyConnection) (*int, error) 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 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": case "Organization.websiteUrl":
if e.complexity.Organization.WebsiteURL == nil { if e.complexity.Organization.WebsiteURL == nil {
break break
@@ -2189,6 +2197,8 @@ type Organization implements Node {
last: Int last: Int
before: CursorKey before: CursorKey
): SAMLConfigurationConnection @goField(forceResolver: true) ): SAMLConfigurationConnection @goField(forceResolver: true)
viewerMembership: Membership @goField(forceResolver: true)
} }
enum MembershipRole enum MembershipRole
@@ -3621,6 +3631,8 @@ func (ec *executionContext) fieldContext_CreateOrganizationPayload_organization(
return ec.fieldContext_Organization_invitations(ctx, field) return ec.fieldContext_Organization_invitations(ctx, field)
case "samlConfigurations": case "samlConfigurations":
return ec.fieldContext_Organization_samlConfigurations(ctx, field) 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) 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) return ec.fieldContext_Organization_invitations(ctx, field)
case "samlConfigurations": case "samlConfigurations":
return ec.fieldContext_Organization_samlConfigurations(ctx, field) 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) 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) return ec.fieldContext_Organization_invitations(ctx, field)
case "samlConfigurations": case "samlConfigurations":
return ec.fieldContext_Organization_samlConfigurations(ctx, field) 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) 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) return ec.fieldContext_Organization_invitations(ctx, field)
case "samlConfigurations": case "samlConfigurations":
return ec.fieldContext_Organization_samlConfigurations(ctx, field) 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) 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 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) { func (ec *executionContext) _OrganizationSessionCreated_session(ctx context.Context, field graphql.CollectedField, obj *types.OrganizationSessionCreated) (ret graphql.Marshaler) {
return graphql.ResolveField( return graphql.ResolveField(
ctx, ctx,
@@ -7975,6 +8040,8 @@ func (ec *executionContext) fieldContext_Permission_organization(_ context.Conte
return ec.fieldContext_Organization_invitations(ctx, field) return ec.fieldContext_Organization_invitations(ctx, field)
case "samlConfigurations": case "samlConfigurations":
return ec.fieldContext_Organization_samlConfigurations(ctx, field) 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) 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) return ec.fieldContext_Organization_invitations(ctx, field)
case "samlConfigurations": case "samlConfigurations":
return ec.fieldContext_Organization_samlConfigurations(ctx, field) 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) 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) return ec.fieldContext_Organization_invitations(ctx, field)
case "samlConfigurations": case "samlConfigurations":
return ec.fieldContext_Organization_samlConfigurations(ctx, field) 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) 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 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) }) out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
default: default:
panic("unknown field " + strconv.Quote(field.Name)) 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) 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 { 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 { if v == nil {
return graphql.Null return graphql.Null

View File

@@ -239,6 +239,7 @@ type Organization struct {
Members *MembershipConnection `json:"members,omitempty"` Members *MembershipConnection `json:"members,omitempty"`
Invitations *InvitationConnection `json:"invitations,omitempty"` Invitations *InvitationConnection `json:"invitations,omitempty"`
SamlConfigurations *SAMLConfigurationConnection `json:"samlConfigurations,omitempty"` SamlConfigurations *SAMLConfigurationConnection `json:"samlConfigurations,omitempty"`
ViewerMembership *Membership `json:"viewerMembership,omitempty"`
} }
func (Organization) IsNode() {} func (Organization) IsNode() {}

View File

@@ -1013,6 +1013,19 @@ func (r *organizationResolver) SamlConfigurations(ctx context.Context, obj *type
return types.NewSAMLConfigurationConnection(page, r, obj.ID), nil 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. // TotalCount is the resolver for the totalCount field.
func (r *personalAPIKeyConnectionResolver) TotalCount(ctx context.Context, obj *types.PersonalAPIKeyConnection) (*int, error) { func (r *personalAPIKeyConnectionResolver) TotalCount(ctx context.Context, obj *types.PersonalAPIKeyConnection) (*int, error) {
switch obj.Resolver.(type) { switch obj.Resolver.(type) {