Rename MembershipLayout and create ViewerLayout for identity context pages

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-01-07 11:25:36 +01:00
committed by Bryan Frimin
parent 62aacced71
commit 5e602d744b
17 changed files with 604 additions and 156 deletions

View File

@@ -0,0 +1,64 @@
/**
* @generated SignedSource<<e7945358237630130f305751e60ad92a>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type ViewerDropdownFragment$data = {
readonly canListAPIKeys: boolean;
readonly email: string;
readonly fullName: string;
readonly " $fragmentType": "ViewerDropdownFragment";
};
export type ViewerDropdownFragment$key = {
readonly " $data"?: ViewerDropdownFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"ViewerDropdownFragment">;
};
const node: ReaderFragment = {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "ViewerDropdownFragment",
"selections": [
{
"alias": "canListAPIKeys",
"args": [
{
"kind": "Literal",
"name": "action",
"value": "iam:personal-api-key:list"
}
],
"kind": "ScalarField",
"name": "permission",
"storageKey": "permission(action:\"iam:personal-api-key:list\")"
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"type": "Identity",
"abstractKey": null
};
(node as any).hash = "e96fe96007f4d949403890fda7180cba";
export default node;

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<8cb399eb2f38eaf3059531ea4bc3fa30>> * @generated SignedSource<<93ee9324d2ee86042f9f46eda06d0770>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -9,15 +9,15 @@
// @ts-nocheck // @ts-nocheck
import { ConcreteRequest } from 'relay-runtime'; import { ConcreteRequest } from 'relay-runtime';
export type SessionDropdownSignOutMutation$variables = Record<PropertyKey, never>; export type ViewerDropdownSignOutMutation$variables = Record<PropertyKey, never>;
export type SessionDropdownSignOutMutation$data = { export type ViewerDropdownSignOutMutation$data = {
readonly signOut: { readonly signOut: {
readonly success: boolean; readonly success: boolean;
} | null | undefined; } | null | undefined;
}; };
export type SessionDropdownSignOutMutation = { export type ViewerDropdownSignOutMutation = {
response: SessionDropdownSignOutMutation$data; response: ViewerDropdownSignOutMutation$data;
variables: SessionDropdownSignOutMutation$variables; variables: ViewerDropdownSignOutMutation$variables;
}; };
const node: ConcreteRequest = (function(){ const node: ConcreteRequest = (function(){
@@ -46,7 +46,7 @@ return {
"argumentDefinitions": [], "argumentDefinitions": [],
"kind": "Fragment", "kind": "Fragment",
"metadata": null, "metadata": null,
"name": "SessionDropdownSignOutMutation", "name": "ViewerDropdownSignOutMutation",
"selections": (v0/*: any*/), "selections": (v0/*: any*/),
"type": "Mutation", "type": "Mutation",
"abstractKey": null "abstractKey": null
@@ -55,20 +55,20 @@ return {
"operation": { "operation": {
"argumentDefinitions": [], "argumentDefinitions": [],
"kind": "Operation", "kind": "Operation",
"name": "SessionDropdownSignOutMutation", "name": "ViewerDropdownSignOutMutation",
"selections": (v0/*: any*/) "selections": (v0/*: any*/)
}, },
"params": { "params": {
"cacheID": "a677190a127d7f75d5c629fab236a7a3", "cacheID": "9252bcfdab3361ae1690a713e2765de3",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "SessionDropdownSignOutMutation", "name": "ViewerDropdownSignOutMutation",
"operationKind": "mutation", "operationKind": "mutation",
"text": "mutation SessionDropdownSignOutMutation {\n signOut {\n success\n }\n}\n" "text": "mutation ViewerDropdownSignOutMutation {\n signOut {\n success\n }\n}\n"
} }
}; };
})(); })();
(node as any).hash = "10c9bdf35ab3961c7488973e052cf978"; (node as any).hash = "a08b0de590063055e1ccbacd1d443a0f";
export default node; export default node;

View File

@@ -0,0 +1,120 @@
/**
* @generated SignedSource<<ecd129a1f62f100fe57ee9b08f9321d0>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type ViewerLayoutQuery$variables = Record<PropertyKey, never>;
export type ViewerLayoutQuery$data = {
readonly viewer: {
readonly " $fragmentSpreads": FragmentRefs<"ViewerDropdownFragment">;
};
};
export type ViewerLayoutQuery = {
response: ViewerLayoutQuery$data;
variables: ViewerLayoutQuery$variables;
};
const node: ConcreteRequest = {
"fragment": {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "ViewerLayoutQuery",
"selections": [
{
"kind": "RequiredField",
"field": {
"alias": null,
"args": null,
"concreteType": "Identity",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
{
"args": null,
"kind": "FragmentSpread",
"name": "ViewerDropdownFragment"
}
],
"storageKey": null
},
"action": "THROW"
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [],
"kind": "Operation",
"name": "ViewerLayoutQuery",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Identity",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
{
"alias": "canListAPIKeys",
"args": [
{
"kind": "Literal",
"name": "action",
"value": "iam:personal-api-key:list"
}
],
"kind": "ScalarField",
"name": "permission",
"storageKey": "permission(action:\"iam:personal-api-key:list\")"
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "faf40277113685d45576f1b466123787",
"id": null,
"metadata": {},
"name": "ViewerLayoutQuery",
"operationKind": "query",
"text": "query ViewerLayoutQuery {\n viewer {\n ...ViewerDropdownFragment\n id\n }\n}\n\nfragment ViewerDropdownFragment on Identity {\n canListAPIKeys: permission(action: \"iam:personal-api-key:list\")\n email\n fullName\n}\n"
}
};
(node as any).hash = "48e174d981dbc9198a56aac976a20b21";
export default node;

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<73d5a0059348fd0c5950ae3a873cc383>> * @generated SignedSource<<a87fd05a4f80d2d7094647468d695967>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -10,42 +10,29 @@
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 ViewerMembershipDropdownFragment$data = {
readonly canDelete: boolean;
readonly viewerMembership: { readonly viewerMembership: {
readonly identity: { readonly identity: {
readonly canListAPIKeys: boolean;
readonly email: string; readonly email: string;
}; };
readonly profile: { readonly profile: {
readonly fullName: string; readonly fullName: string;
}; };
}; };
readonly " $fragmentType": "SessionDropdownFragment"; readonly " $fragmentType": "ViewerMembershipDropdownFragment";
}; };
export type SessionDropdownFragment$key = { export type ViewerMembershipDropdownFragment$key = {
readonly " $data"?: SessionDropdownFragment$data; readonly " $data"?: ViewerMembershipDropdownFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"SessionDropdownFragment">; readonly " $fragmentSpreads": FragmentRefs<"ViewerMembershipDropdownFragment">;
}; };
const node: ReaderFragment = { const node: ReaderFragment = {
"argumentDefinitions": [], "argumentDefinitions": [],
"kind": "Fragment", "kind": "Fragment",
"metadata": null, "metadata": null,
"name": "SessionDropdownFragment", "name": "ViewerMembershipDropdownFragment",
"selections": [ "selections": [
{
"alias": "canDelete",
"args": [
{
"kind": "Literal",
"name": "action",
"value": "iam:organization:delete"
}
],
"kind": "ScalarField",
"name": "permission",
"storageKey": "permission(action:\"iam:organization:delete\")"
},
{ {
"kind": "RequiredField", "kind": "RequiredField",
"field": { "field": {
@@ -72,6 +59,19 @@ const node: ReaderFragment = {
"kind": "ScalarField", "kind": "ScalarField",
"name": "email", "name": "email",
"storageKey": null "storageKey": null
},
{
"alias": "canListAPIKeys",
"args": [
{
"kind": "Literal",
"name": "action",
"value": "iam:personal-api-key:list"
}
],
"kind": "ScalarField",
"name": "permission",
"storageKey": "permission(action:\"iam:personal-api-key:list\")"
} }
], ],
"storageKey": null "storageKey": null
@@ -110,6 +110,6 @@ const node: ReaderFragment = {
"abstractKey": null "abstractKey": null
}; };
(node as any).hash = "51810eafbf165e7b5eeec42cb268ae54"; (node as any).hash = "82c179a1ec2b90ce5f12c0959f97150f";
export default node; export default node;

View File

@@ -0,0 +1,74 @@
/**
* @generated SignedSource<<5d6fc3e9d931dccebc57b68196c98bed>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type ViewerMembershipDropdownSignOutMutation$variables = Record<PropertyKey, never>;
export type ViewerMembershipDropdownSignOutMutation$data = {
readonly signOut: {
readonly success: boolean;
} | null | undefined;
};
export type ViewerMembershipDropdownSignOutMutation = {
response: ViewerMembershipDropdownSignOutMutation$data;
variables: ViewerMembershipDropdownSignOutMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"alias": null,
"args": null,
"concreteType": "SignOutPayload",
"kind": "LinkedField",
"name": "signOut",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "success",
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "ViewerMembershipDropdownSignOutMutation",
"selections": (v0/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [],
"kind": "Operation",
"name": "ViewerMembershipDropdownSignOutMutation",
"selections": (v0/*: any*/)
},
"params": {
"cacheID": "da5fc8d24f8fac6617c4e4de8d9a629d",
"id": null,
"metadata": {},
"name": "ViewerMembershipDropdownSignOutMutation",
"operationKind": "mutation",
"text": "mutation ViewerMembershipDropdownSignOutMutation {\n signOut {\n success\n }\n}\n"
}
};
})();
(node as any).hash = "856ff5d4affd9ad27871ab5f4fef3b1e";
export default node;

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<4268d9e187cf29b47f9438cf48cc3e73>> * @generated SignedSource<<d089cfdf5c585ba8fcb83456438efe62>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -11,11 +11,11 @@
import { ConcreteRequest } from 'relay-runtime'; import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime"; import { FragmentRefs } from "relay-runtime";
export type MembershipRole = "ADMIN" | "AUDITOR" | "EMPLOYEE" | "OWNER" | "VIEWER"; export type MembershipRole = "ADMIN" | "AUDITOR" | "EMPLOYEE" | "OWNER" | "VIEWER";
export type MembershipLayoutQuery$variables = { export type ViewerMembershipLayoutQuery$variables = {
hideSidebar: boolean; hideSidebar: boolean;
organizationId: string; organizationId: string;
}; };
export type MembershipLayoutQuery$data = { export type ViewerMembershipLayoutQuery$data = {
readonly organization: { readonly organization: {
readonly __typename: "Organization"; readonly __typename: "Organization";
readonly viewerMembership: { readonly viewerMembership: {
@@ -24,7 +24,7 @@ export type MembershipLayoutQuery$data = {
}; };
readonly role: MembershipRole; readonly role: MembershipRole;
}; };
readonly " $fragmentSpreads": FragmentRefs<"MembershipsDropdown_organizationFragment" | "SessionDropdownFragment" | "SidebarFragment">; readonly " $fragmentSpreads": FragmentRefs<"MembershipsDropdown_organizationFragment" | "SidebarFragment" | "ViewerMembershipDropdownFragment">;
} | { } | {
// This will never be '%other', but we need some // This will never be '%other', but we need some
// value in case none of the concrete values match. // value in case none of the concrete values match.
@@ -38,9 +38,9 @@ export type MembershipLayoutQuery$data = {
readonly " $fragmentSpreads": FragmentRefs<"MembershipsDropdown_viewerFragment">; readonly " $fragmentSpreads": FragmentRefs<"MembershipsDropdown_viewerFragment">;
}; };
}; };
export type MembershipLayoutQuery = { export type ViewerMembershipLayoutQuery = {
response: MembershipLayoutQuery$data; response: ViewerMembershipLayoutQuery$data;
variables: MembershipLayoutQuery$variables; variables: ViewerMembershipLayoutQuery$variables;
}; };
const node: ConcreteRequest = (function(){ const node: ConcreteRequest = (function(){
@@ -111,7 +111,7 @@ return {
], ],
"kind": "Fragment", "kind": "Fragment",
"metadata": null, "metadata": null,
"name": "MembershipLayoutQuery", "name": "ViewerMembershipLayoutQuery",
"selections": [ "selections": [
{ {
"kind": "RequiredField", "kind": "RequiredField",
@@ -135,7 +135,7 @@ return {
{ {
"args": null, "args": null,
"kind": "FragmentSpread", "kind": "FragmentSpread",
"name": "SessionDropdownFragment" "name": "ViewerMembershipDropdownFragment"
}, },
{ {
"condition": "hideSidebar", "condition": "hideSidebar",
@@ -242,7 +242,7 @@ return {
(v0/*: any*/) (v0/*: any*/)
], ],
"kind": "Operation", "kind": "Operation",
"name": "MembershipLayoutQuery", "name": "ViewerMembershipLayoutQuery",
"selections": [ "selections": [
{ {
"alias": "organization", "alias": "organization",
@@ -263,19 +263,6 @@ return {
"name": "name", "name": "name",
"storageKey": null "storageKey": null
}, },
{
"alias": "canDelete",
"args": [
{
"kind": "Literal",
"name": "action",
"value": "iam:organization:delete"
}
],
"kind": "ScalarField",
"name": "permission",
"storageKey": "permission(action:\"iam:organization:delete\")"
},
{ {
"alias": null, "alias": null,
"args": null, "args": null,
@@ -293,6 +280,19 @@ return {
"plural": false, "plural": false,
"selections": [ "selections": [
(v6/*: any*/), (v6/*: any*/),
{
"alias": "canListAPIKeys",
"args": [
{
"kind": "Literal",
"name": "action",
"value": "iam:personal-api-key:list"
}
],
"kind": "ScalarField",
"name": "permission",
"storageKey": "permission(action:\"iam:personal-api-key:list\")"
},
(v8/*: any*/) (v8/*: any*/)
], ],
"storageKey": null "storageKey": null
@@ -618,16 +618,16 @@ return {
] ]
}, },
"params": { "params": {
"cacheID": "5f50873fa9c14115e307b43d7c1ae822", "cacheID": "d7e0b080876472d059c97bf57818c697",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "MembershipLayoutQuery", "name": "ViewerMembershipLayoutQuery",
"operationKind": "query", "operationKind": "query",
"text": "query MembershipLayoutQuery(\n $organizationId: ID!\n $hideSidebar: Boolean!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n ...MembershipsDropdown_organizationFragment\n ...SessionDropdownFragment\n ...SidebarFragment @skip(if: $hideSidebar)\n viewerMembership {\n role\n profile {\n fullName\n id\n }\n id\n }\n }\n id\n }\n viewer {\n email\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 canDelete: permission(action: \"iam:organization:delete\")\n viewerMembership {\n identity {\n email\n id\n }\n profile {\n fullName\n id\n }\n id\n }\n}\n\nfragment SidebarFragment on Organization {\n canListMeetings: permission(action: \"core:meeting:list\")\n canListTasks: permission(action: \"core:task:list\")\n canListMeasures: permission(action: \"core:measures:list\")\n canListRisks: permission(action: \"core:risk:list\")\n canListFrameworks: permission(action: \"core:frameworks:list\")\n canListPeople: permission(action: \"core:people:list\")\n canListVendors: permission(action: \"core:vendor:list\")\n canListDocuments: permission(action: \"core:document:list\")\n canListAssets: permission(action: \"core:asset:list\")\n canListData: permission(action: \"core:datum:list\")\n canListAudits: permission(action: \"core:audit:list\")\n canListNonconformities: permission(action: \"core:nonconformity:list\")\n canListObligations: permission(action: \"core:obligation:list\")\n canListContinualImprovements: permission(action: \"core:continual-improvement:list\")\n canListProcessingActivities: permission(action: \"core:processing-activity:list\")\n canListRightsRequests: permission(action: \"core:rights-request:list\")\n canListSnapshots: permission(action: \"core:snapshot:list\")\n canGetTrustCenter: permission(action: \"core:trust-center:get\")\n canUpdateOrganization: permission(action: \"iam:organization:update\")\n canListStatesOfApplicability: permission(action: \"core:state-of-applicability:list\")\n}\n" "text": "query ViewerMembershipLayoutQuery(\n $organizationId: ID!\n $hideSidebar: Boolean!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n ...MembershipsDropdown_organizationFragment\n ...ViewerMembershipDropdownFragment\n ...SidebarFragment @skip(if: $hideSidebar)\n viewerMembership {\n role\n profile {\n fullName\n id\n }\n id\n }\n }\n id\n }\n viewer {\n email\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 SidebarFragment on Organization {\n canListMeetings: permission(action: \"core:meeting:list\")\n canListTasks: permission(action: \"core:task:list\")\n canListMeasures: permission(action: \"core:measures:list\")\n canListRisks: permission(action: \"core:risk:list\")\n canListFrameworks: permission(action: \"core:frameworks:list\")\n canListPeople: permission(action: \"core:people:list\")\n canListVendors: permission(action: \"core:vendor:list\")\n canListDocuments: permission(action: \"core:document:list\")\n canListAssets: permission(action: \"core:asset:list\")\n canListData: permission(action: \"core:datum:list\")\n canListAudits: permission(action: \"core:audit:list\")\n canListNonconformities: permission(action: \"core:nonconformity:list\")\n canListObligations: permission(action: \"core:obligation:list\")\n canListContinualImprovements: permission(action: \"core:continual-improvement:list\")\n canListProcessingActivities: permission(action: \"core:processing-activity:list\")\n canListRightsRequests: permission(action: \"core:rights-request:list\")\n canListSnapshots: permission(action: \"core:snapshot:list\")\n canGetTrustCenter: permission(action: \"core:trust-center:get\")\n canUpdateOrganization: permission(action: \"iam:organization:update\")\n canListStatesOfApplicability: permission(action: \"core:state-of-applicability:list\")\n}\n\nfragment ViewerMembershipDropdownFragment on Organization {\n viewerMembership {\n identity {\n email\n canListAPIKeys: permission(action: \"iam:personal-api-key:list\")\n id\n }\n profile {\n fullName\n id\n }\n id\n }\n}\n"
} }
}; };
})(); })();
(node as any).hash = "e52fc7540b2908e5b4f2c4cef33b5b81"; (node as any).hash = "298aba62d7920932f86433919c623d4c";
export default node; export default node;

View File

@@ -0,0 +1,42 @@
import { graphql, usePreloadedQuery, type PreloadedQuery } from "react-relay";
import { Outlet } from "react-router";
import { CenteredLayout, Layout, Skeleton } from "@probo/ui";
import type { ViewerLayoutQuery } from "/__generated__/iam/ViewerLayoutQuery.graphql";
import { Suspense } from "react";
import { ViewerDropdown } from "./_components/ViewerDropdown";
export const viewerLayoutQuery = graphql`
query ViewerLayoutQuery {
viewer @required(action: THROW) {
...ViewerDropdownFragment
}
}
`;
export function ViewerLayout(props: {
hideSidebar?: boolean;
queryRef: PreloadedQuery<ViewerLayoutQuery>;
}) {
const { queryRef } = props;
const { viewer } = usePreloadedQuery<ViewerLayoutQuery>(
viewerLayoutQuery,
queryRef,
);
return (
<Layout
headerTrailing={
<div className="ml-auto">
<Suspense fallback={<Skeleton className="w-32 h-8" />}>
<ViewerDropdown fKey={viewer} />
</Suspense>
</div>
}
>
<CenteredLayout>
<Outlet />
</CenteredLayout>
</Layout>
);
}

View File

@@ -0,0 +1,33 @@
import { useQueryLoader } from "react-relay";
import { Suspense, useEffect } from "react";
import { CenteredLayoutSkeleton } from "@probo/ui";
import type { ViewerLayoutQuery } from "/__generated__/iam/ViewerLayoutQuery.graphql";
import { ViewerLayout, viewerLayoutQuery } from "./ViewerLayout";
import { IAMRelayProvider } from "/providers/IAMRelayProvider";
function ViewerLayoutLoader() {
const [queryRef, loadQuery] =
useQueryLoader<ViewerLayoutQuery>(viewerLayoutQuery);
useEffect(() => {
loadQuery({});
}, [loadQuery]);
if (!queryRef) {
return <CenteredLayoutSkeleton />;
}
return (
<Suspense fallback={<CenteredLayoutSkeleton />}>
<ViewerLayout queryRef={queryRef} />
</Suspense>
);
}
export default function () {
return (
<IAMRelayProvider>
<ViewerLayoutLoader />
</IAMRelayProvider>
);
}

View File

@@ -0,0 +1,92 @@
import {
DropdownSeparator,
IconArrowBoxLeft,
IconCircleQuestionmark,
IconKey,
UserDropdown,
UserDropdownItem,
useToast,
} from "@probo/ui";
import { graphql } from "relay-runtime";
import { useFragment, useMutation } from "react-relay";
import { useTranslate } from "@probo/i18n";
import type { ViewerDropdownFragment$key } from "/__generated__/iam/ViewerDropdownFragment.graphql";
import { formatError } from "@probo/helpers";
export const fragment = graphql`
fragment ViewerDropdownFragment on Identity {
canListAPIKeys: permission(action: "iam:personal-api-key:list")
email
fullName
}
`;
const signOutMutation = graphql`
mutation ViewerDropdownSignOutMutation {
signOut {
success
}
}
`;
export function ViewerDropdown(props: { fKey: ViewerDropdownFragment$key }) {
const { fKey } = props;
const { __ } = useTranslate();
const { toast } = useToast();
const { canListAPIKeys, email, fullName } =
useFragment<ViewerDropdownFragment$key>(fragment, fKey);
const [signOut] = useMutation(signOutMutation);
const handleLogout: React.MouseEventHandler<HTMLAnchorElement> = (e) => {
e.preventDefault();
signOut({
variables: {},
onCompleted: (_, e) => {
if (e) {
toast({
title: __("Request failed"),
description: formatError(__("Cannot sign out"), e),
variant: "error",
});
return;
}
window.location.reload();
},
onError: (e) => {
toast({
title: __("Error"),
description: e.message,
variant: "error",
});
},
});
};
return (
<UserDropdown fullName={fullName} email={email}>
{canListAPIKeys && (
<UserDropdownItem
to="/me/api-keys"
icon={IconKey}
label={__("API Keys")}
/>
)}
<UserDropdownItem
to="mailto:support@getprobo.com"
icon={IconCircleQuestionmark}
label={__("Help")}
/>
<DropdownSeparator />
<UserDropdownItem
variant="danger"
to="/logout"
icon={IconArrowBoxLeft}
label="Logout"
onClick={handleLogout}
/>
</UserDropdown>
);
}

View File

@@ -1,22 +1,25 @@
import { graphql, usePreloadedQuery, type PreloadedQuery } from "react-relay"; import { graphql, usePreloadedQuery, type PreloadedQuery } from "react-relay";
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 "../memberships/_components/Sidebar";
import { MembershipsDropdown } from "./MembershipsDropdown"; import { MembershipsDropdown } from "../memberships/MembershipsDropdown";
import type { MembershipLayoutQuery } from "/__generated__/iam/MembershipLayoutQuery.graphql"; import type { ViewerMembershipLayoutQuery } from "/__generated__/iam/ViewerMembershipLayoutQuery.graphql";
import { SessionDropdown } from "./_components/SessionDropdown"; import { ViewerMembershipDropdown } from "./_components/ViewerMembershipDropdown";
import { Suspense } from "react"; import { Suspense } from "react";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { CoreRelayProvider } from "/providers/CoreRelayProvider"; import { CoreRelayProvider } from "/providers/CoreRelayProvider";
import { CurrentUser } from "/providers/CurrentUser"; import { CurrentUser } from "/providers/CurrentUser";
export const membershipLayoutQuery = graphql` export const viewerMembershipLayoutQuery = graphql`
query MembershipLayoutQuery($organizationId: ID!, $hideSidebar: Boolean!) { query ViewerMembershipLayoutQuery(
$organizationId: ID!
$hideSidebar: Boolean!
) {
organization: node(id: $organizationId) @required(action: THROW) { organization: node(id: $organizationId) @required(action: THROW) {
__typename __typename
... on Organization { ... on Organization {
...MembershipsDropdown_organizationFragment ...MembershipsDropdown_organizationFragment
...SessionDropdownFragment ...ViewerMembershipDropdownFragment
...SidebarFragment @skip(if: $hideSidebar) ...SidebarFragment @skip(if: $hideSidebar)
viewerMembership @required(action: THROW) { viewerMembership @required(action: THROW) {
role role
@@ -36,16 +39,17 @@ export const membershipLayoutQuery = graphql`
} }
`; `;
export function MembershipLayout(props: { export function ViewerMembershipLayout(props: {
hideSidebar?: boolean; hideSidebar?: boolean;
queryRef: PreloadedQuery<MembershipLayoutQuery>; queryRef: PreloadedQuery<ViewerMembershipLayoutQuery>;
}) { }) {
const { hideSidebar = false, queryRef } = props; const { hideSidebar = false, queryRef } = props;
const { __ } = useTranslate(); const { __ } = useTranslate();
const { organization, viewer } = usePreloadedQuery<MembershipLayoutQuery>( const { organization, viewer } =
membershipLayoutQuery, usePreloadedQuery<ViewerMembershipLayoutQuery>(
viewerMembershipLayoutQuery,
queryRef, queryRef,
); );
if (organization.__typename !== "Organization") { if (organization.__typename !== "Organization") {
@@ -54,9 +58,8 @@ export function MembershipLayout(props: {
return ( return (
<Layout <Layout
header={ headerLeading={
<> <>
<div className="mr-auto">
<MembershipsDropdown <MembershipsDropdown
organizationFKey={organization} organizationFKey={organization}
viewerFKey={viewer} viewerFKey={viewer}
@@ -73,12 +76,13 @@ export function MembershipLayout(props: {
</Badge> </Badge>
</Link> </Link>
)} )}
</div>
<Suspense fallback={<Skeleton className="w-32 h-8" />}>
<SessionDropdown fKey={organization} />
</Suspense>
</> </>
} }
headerTrailing={
<Suspense fallback={<Skeleton className="w-32 h-8" />}>
<ViewerMembershipDropdown fKey={organization} />
</Suspense>
}
sidebar={!hideSidebar && <Sidebar fKey={organization} />} sidebar={!hideSidebar && <Sidebar fKey={organization} />}
> >
<CoreRelayProvider> <CoreRelayProvider>

View File

@@ -2,14 +2,17 @@ import { useQueryLoader } from "react-relay";
import { Suspense, useEffect } from "react"; import { Suspense, useEffect } from "react";
import { Skeleton } from "@probo/ui"; import { Skeleton } from "@probo/ui";
import { useOrganizationId } from "/hooks/useOrganizationId"; import { useOrganizationId } from "/hooks/useOrganizationId";
import type { MembershipLayoutQuery } from "/__generated__/iam/MembershipLayoutQuery.graphql"; import type { ViewerMembershipLayoutQuery } from "/__generated__/iam/ViewerMembershipLayoutQuery.graphql";
import { MembershipLayout, membershipLayoutQuery } from "./MembershipLayout"; import {
ViewerMembershipLayout,
viewerMembershipLayoutQuery,
} from "./ViewerMembershipLayout";
import { IAMRelayProvider } from "/providers/IAMRelayProvider"; import { IAMRelayProvider } from "/providers/IAMRelayProvider";
function MembershipLayoutLoader() { function MembershipLayoutLoader() {
const organizationId = useOrganizationId(); const organizationId = useOrganizationId();
const [queryRef, loadQuery] = useQueryLoader<MembershipLayoutQuery>( const [queryRef, loadQuery] = useQueryLoader<ViewerMembershipLayoutQuery>(
membershipLayoutQuery, viewerMembershipLayoutQuery,
); );
useEffect(() => { useEffect(() => {
@@ -25,7 +28,7 @@ function MembershipLayoutLoader() {
return ( return (
<Suspense fallback={<Skeleton className="w-full h-screen" />}> <Suspense fallback={<Skeleton className="w-full h-screen" />}>
<MembershipLayout queryRef={queryRef} /> <ViewerMembershipLayout queryRef={queryRef} />
</Suspense> </Suspense>
); );
} }

View File

@@ -12,15 +12,15 @@ import { graphql } from "relay-runtime";
import { useOrganizationId } from "/hooks/useOrganizationId"; import { useOrganizationId } from "/hooks/useOrganizationId";
import { useFragment, useMutation } from "react-relay"; import { useFragment, useMutation } from "react-relay";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import type { SessionDropdownFragment$key } from "/__generated__/iam/SessionDropdownFragment.graphql"; import type { ViewerMembershipDropdownFragment$key } from "/__generated__/iam/ViewerMembershipDropdownFragment.graphql";
import { formatError } from "@probo/helpers"; import { formatError } from "@probo/helpers";
export const fragment = graphql` export const fragment = graphql`
fragment SessionDropdownFragment on Organization { fragment ViewerMembershipDropdownFragment on Organization {
canDelete: permission(action: "iam:organization:delete")
viewerMembership @required(action: THROW) { viewerMembership @required(action: THROW) {
identity @required(action: THROW) { identity @required(action: THROW) {
email email
canListAPIKeys: permission(action: "iam:personal-api-key:list")
} }
profile @required(action: THROW) { profile @required(action: THROW) {
fullName fullName
@@ -30,14 +30,16 @@ export const fragment = graphql`
`; `;
const signOutMutation = graphql` const signOutMutation = graphql`
mutation SessionDropdownSignOutMutation { mutation ViewerMembershipDropdownSignOutMutation {
signOut { signOut {
success success
} }
} }
`; `;
export function SessionDropdown(props: { fKey: SessionDropdownFragment$key }) { export function ViewerMembershipDropdown(props: {
fKey: ViewerMembershipDropdownFragment$key;
}) {
const { fKey } = props; const { fKey } = props;
const { __ } = useTranslate(); const { __ } = useTranslate();
@@ -45,12 +47,11 @@ export function SessionDropdown(props: { fKey: SessionDropdownFragment$key }) {
const { toast } = useToast(); const { toast } = useToast();
const { const {
canDelete,
viewerMembership: { viewerMembership: {
identity: { email }, identity: { canListAPIKeys, email },
profile: { fullName }, profile: { fullName },
}, },
} = useFragment<SessionDropdownFragment$key>(fragment, fKey); } = useFragment<ViewerMembershipDropdownFragment$key>(fragment, fKey);
const [signOut] = useMutation(signOutMutation); const [signOut] = useMutation(signOutMutation);
const handleLogout: React.MouseEventHandler<HTMLAnchorElement> = (e) => { const handleLogout: React.MouseEventHandler<HTMLAnchorElement> = (e) => {
@@ -81,7 +82,7 @@ export function SessionDropdown(props: { fKey: SessionDropdownFragment$key }) {
return ( return (
<UserDropdown fullName={fullName} email={email}> <UserDropdown fullName={fullName} email={email}>
{canDelete && ( {canListAPIKeys && (
<UserDropdownItem <UserDropdownItem
to="/me/api-keys" to="/me/api-keys"
icon={IconKey} icon={IconKey}

View File

@@ -4,15 +4,15 @@ import { Skeleton } from "@probo/ui";
import { useOrganizationId } from "/hooks/useOrganizationId"; import { useOrganizationId } from "/hooks/useOrganizationId";
import { IAMRelayProvider } from "/providers/IAMRelayProvider"; import { IAMRelayProvider } from "/providers/IAMRelayProvider";
import { import {
MembershipLayout, ViewerMembershipLayout,
membershipLayoutQuery, viewerMembershipLayoutQuery,
} from "/pages/iam/memberships/MembershipLayout"; } from "../../iam/organizations/ViewerMembershipLayout";
import type { MembershipLayoutQuery } from "/__generated__/iam/MembershipLayoutQuery.graphql"; import type { ViewerMembershipLayoutQuery } from "/__generated__/iam/ViewerMembershipLayoutQuery.graphql";
function EmployeeLayoutLoader() { function EmployeeLayoutLoader() {
const organizationId = useOrganizationId(); const organizationId = useOrganizationId();
const [queryRef, loadQuery] = useQueryLoader<MembershipLayoutQuery>( const [queryRef, loadQuery] = useQueryLoader<ViewerMembershipLayoutQuery>(
membershipLayoutQuery, viewerMembershipLayoutQuery,
); );
useEffect(() => { useEffect(() => {
@@ -28,7 +28,7 @@ function EmployeeLayoutLoader() {
return ( return (
<Suspense fallback={<Skeleton className="w-full h-screen" />}> <Suspense fallback={<Skeleton className="w-full h-screen" />}>
<MembershipLayout queryRef={queryRef} hideSidebar /> <ViewerMembershipLayout queryRef={queryRef} hideSidebar />
</Suspense> </Suspense>
); );
} }

View File

@@ -4,7 +4,7 @@ import {
redirect, redirect,
useRouteError, useRouteError,
} from "react-router"; } from "react-router";
import { CenteredLayout, CenteredLayoutSkeleton } from "@probo/ui"; import { CenteredLayoutSkeleton } from "@probo/ui";
import { PageSkeleton } from "./components/skeletons/PageSkeleton.tsx"; import { PageSkeleton } from "./components/skeletons/PageSkeleton.tsx";
import { riskRoutes } from "./routes/riskRoutes.ts"; import { riskRoutes } from "./routes/riskRoutes.ts";
import { measureRoutes } from "./routes/measureRoutes.ts"; import { measureRoutes } from "./routes/measureRoutes.ts";
@@ -103,7 +103,7 @@ const routes = [
}, },
{ {
path: "/", path: "/",
Component: CenteredLayout, Component: lazy(() => import("./pages/iam/memberships/ViewerLayoutLoader")),
Fallback: CenteredLayoutSkeleton, Fallback: CenteredLayoutSkeleton,
ErrorBoundary: ErrorBoundary, ErrorBoundary: ErrorBoundary,
children: [ children: [
@@ -158,7 +158,7 @@ const routes = [
{ {
path: "/organizations/:organizationId", path: "/organizations/:organizationId",
Component: lazy( Component: lazy(
() => import("./pages/iam/memberships/MembershipLayoutLoader"), () => import("./pages/iam/organizations/ViewerMembershipLayoutLoader"),
), ),
ErrorBoundary: ErrorBoundary, ErrorBoundary: ErrorBoundary,
children: [ children: [

View File

@@ -4,7 +4,7 @@ import { Skeleton } from "../Atoms/Skeleton/Skeleton";
export function CenteredLayout({ children }: PropsWithChildren) { export function CenteredLayout({ children }: PropsWithChildren) {
return ( return (
<div className="grid place-items-center min-h-screen text-txt-primary bg-level-0"> <div className="grid place-items-center min-h-[calc(100vh-48px-(2*48px))] text-txt-primary bg-level-0">
<div className="w-full max-w-2xl flex flex-col items-center"> <div className="w-full max-w-2xl flex flex-col items-center">
{children ?? <Outlet />} {children ?? <Outlet />}
</div> </div>

View File

@@ -13,17 +13,24 @@ import { Toasts } from "../Atoms/Toasts/Toasts.tsx";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import clsx from "clsx"; import clsx from "clsx";
import { ConfirmDialog } from "../Molecules/Dialog/ConfirmDialog.tsx"; import { ConfirmDialog } from "../Molecules/Dialog/ConfirmDialog.tsx";
import { Link } from "react-router";
type Props = PropsWithChildren<{ type Props = PropsWithChildren<{
header: ReactNode; headerLeading?: ReactNode;
sidebar: ReactNode; headerTrailing: ReactNode;
sidebar?: ReactNode;
}>; }>;
const LayoutContext = createContext({ const LayoutContext = createContext({
setDrawer: (() => {}) as (v: boolean) => void, setDrawer: (() => {}) as (v: boolean) => void,
}); });
export function Layout({ header, sidebar, children }: Props) { export function Layout({
headerLeading,
headerTrailing,
sidebar,
children,
}: Props) {
const [hasDrawer, setDrawer] = useState(false); const [hasDrawer, setDrawer] = useState(false);
const layoutContext = useMemo( const layoutContext = useMemo(
() => ({ () => ({
@@ -35,7 +42,11 @@ export function Layout({ header, sidebar, children }: Props) {
<LayoutContext value={layoutContext}> <LayoutContext value={layoutContext}>
<div className="text-txt-primary bg-level-0"> <div className="text-txt-primary bg-level-0">
<header className="absolute z-2 left-0 right-0 px-4 flex items-center border-b border-border-solid h-12 bg-level-0"> <header className="absolute z-2 left-0 right-0 px-4 flex items-center border-b border-border-solid h-12 bg-level-0">
<Link to="/">
<Logo className="w-12 h-5" /> <Logo className="w-12 h-5" />
</Link>
{headerLeading && (
<>
<svg <svg
className="mx-3 text-txt-tertiary" className="mx-3 text-txt-tertiary"
width="8" width="8"
@@ -46,7 +57,10 @@ export function Layout({ header, sidebar, children }: Props) {
> >
<path d="M1 17L7 1" stroke="currentColor" /> <path d="M1 17L7 1" stroke="currentColor" />
</svg> </svg>
{header} {headerLeading}
</>
)}
<div className="ml-auto">{headerTrailing}</div>
</header> </header>
<div className="flex h-screen" id="main"> <div className="flex h-screen" id="main">
{sidebar && <Sidebar>{sidebar}</Sidebar>} {sidebar && <Sidebar>{sidebar}</Sidebar>}

View File

@@ -1528,14 +1528,15 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
// Viewer is the resolver for the viewer field. // Viewer is the resolver for the viewer field.
func (r *queryResolver) Viewer(ctx context.Context) (*types.Identity, error) { func (r *queryResolver) Viewer(ctx context.Context) (*types.Identity, error) {
user := IdentityFromContext(ctx) identity := IdentityFromContext(ctx)
return &types.Identity{ return &types.Identity{
ID: user.ID, ID: identity.ID,
Email: user.EmailAddress, Email: identity.EmailAddress,
EmailVerified: user.EmailAddressVerified, EmailVerified: identity.EmailAddressVerified,
CreatedAt: user.CreatedAt, FullName: identity.FullName,
UpdatedAt: user.UpdatedAt, CreatedAt: identity.CreatedAt,
UpdatedAt: identity.UpdatedAt,
}, nil }, nil
} }