OrganizationsPage switch to MembershipCard and add membership session state

Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
Émile Ré
2025-12-19 12:35:22 +01:00
committed by Bryan Frimin
parent ddf585fc18
commit 433c081d22
13 changed files with 422 additions and 88 deletions

View File

@@ -4,5 +4,5 @@
"language": "typescript",
"eagerEsModules": true,
"noFutureProofEnums": true,
"excludes": ["**/pages/iam/**/*", "**/node_modules/**"]
"excludes": ["**/iam/**/*", "**/node_modules/**", "**/__generated__/**"]
}

View File

@@ -4,5 +4,5 @@
"language": "typescript",
"eagerEsModules": true,
"noFutureProofEnums": true,
"excludes": ["**/node_modules/**"]
"excludes": ["**/node_modules/**", "**/__generated__/**"]
}

View File

@@ -0,0 +1,42 @@
/**
* @generated SignedSource<<b6a869ebbc0ded2da70f59b7856b906f>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type sessionIsExpiredFragment$data = {
readonly expiresAt: any;
readonly " $fragmentType": "sessionIsExpiredFragment";
};
export type sessionIsExpiredFragment$key = {
readonly " $data"?: sessionIsExpiredFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"sessionIsExpiredFragment">;
};
const node: ReaderFragment = {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "sessionIsExpiredFragment",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "expiresAt",
"storageKey": null
}
],
"type": "Session",
"abstractKey": null
};
(node as any).hash = "ef7bc8bbfa6d2573b0df09b5b13b00b2";
export default node;

View File

@@ -8,13 +8,13 @@ import {
Input,
} from "@probo/ui";
import { useMemo, useState } from "react";
import { OrganizationCard } from "./_components/OrganizationCard";
import { InvitationCard } from "./_components/InvitationCard";
import type {
OrganizationsQuery,
OrganizationsQuery$data,
} from "./__generated__/OrganizationsQuery.graphql";
import { organizationsQuery } from "./OrganizationsQuery";
import { MembershipCard } from "./_components/MembershipCard";
interface PageProps {
queryRef: PreloadedQuery<OrganizationsQuery>;
@@ -87,8 +87,8 @@ export default function Page(props: PageProps) {
{__("No organizations found")}
</div>
) : (
memberships.map(({ node: { id, organization } }) => (
<OrganizationCard key={id} fKey={organization} />
memberships.map(({ node }) => (
<MembershipCard key={node.id} fKey={node} />
))
)}
</div>

View File

@@ -17,10 +17,9 @@ export const organizationsQuery = graphql`
edges {
node {
id
createdAt
...MembershipCardFragment
organization {
name
...OrganizationCardFragment
}
}
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<c7e497311fbff76ab3b574012b9b9660>>
* @generated SignedSource<<97119b2ea90f368155a13a32941b99d3>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -16,12 +16,11 @@ export type OrganizationsQuery$data = {
readonly memberships: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly createdAt: any;
readonly id: string;
readonly organization: {
readonly name: string;
readonly " $fragmentSpreads": FragmentRefs<"OrganizationCardFragment">;
};
readonly " $fragmentSpreads": FragmentRefs<"MembershipCardFragment">;
};
}>;
};
@@ -64,13 +63,6 @@ v1 = {
"storageKey": null
},
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
@@ -119,7 +111,11 @@ return {
"plural": false,
"selections": [
(v1/*: any*/),
(v2/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "MembershipCardFragment"
},
{
"alias": null,
"args": null,
@@ -128,12 +124,7 @@ return {
"name": "organization",
"plural": false,
"selections": [
(v3/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "OrganizationCardFragment"
}
(v2/*: any*/)
],
"storageKey": null
}
@@ -233,7 +224,25 @@ return {
"plural": false,
"selections": [
(v1/*: any*/),
(v2/*: any*/),
{
"alias": null,
"args": null,
"concreteType": "Session",
"kind": "LinkedField",
"name": "activeSession",
"plural": false,
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "expiresAt",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -242,8 +251,8 @@ return {
"name": "organization",
"plural": false,
"selections": [
(v3/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
{
"alias": null,
"args": null,
@@ -295,7 +304,13 @@ return {
"name": "role",
"storageKey": null
},
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -305,7 +320,7 @@ return {
"plural": false,
"selections": [
(v1/*: any*/),
(v3/*: any*/)
(v2/*: any*/)
],
"storageKey": null
}
@@ -325,16 +340,16 @@ return {
]
},
"params": {
"cacheID": "145dddcd59c5c55a1c61913d9f43b38b",
"cacheID": "5dad37a0c652055f81de5bd2c09995c2",
"id": null,
"metadata": {},
"name": "OrganizationsQuery",
"operationKind": "query",
"text": "query OrganizationsQuery {\n viewer {\n memberships(first: 1000, orderBy: {direction: DESC, field: CREATED_AT}) {\n edges {\n node {\n id\n createdAt\n organization {\n name\n ...OrganizationCardFragment\n id\n }\n }\n }\n }\n pendingInvitations(first: 1000, orderBy: {direction: DESC, field: CREATED_AT}) {\n edges {\n node {\n id\n ...InvitationCardFragment\n }\n }\n }\n id\n }\n}\n\nfragment InvitationCardFragment on Invitation {\n id\n role\n createdAt\n organization {\n id\n name\n }\n}\n\nfragment OrganizationCardFragment on Organization {\n id\n name\n logoUrl\n}\n"
"text": "query OrganizationsQuery {\n viewer {\n memberships(first: 1000, orderBy: {direction: DESC, field: CREATED_AT}) {\n edges {\n node {\n id\n ...MembershipCardFragment\n organization {\n name\n id\n }\n }\n }\n }\n pendingInvitations(first: 1000, orderBy: {direction: DESC, field: CREATED_AT}) {\n edges {\n node {\n id\n ...InvitationCardFragment\n }\n }\n }\n id\n }\n}\n\nfragment InvitationCardFragment on Invitation {\n id\n role\n createdAt\n organization {\n id\n name\n }\n}\n\nfragment MembershipCardFragment on Membership {\n activeSession {\n id\n expiresAt\n }\n organization {\n id\n name\n logoUrl\n }\n}\n"
}
};
})();
(node as any).hash = "f85219c0fcd69be1b5cfb0a5b9c9b8db";
(node as any).hash = "dc4b2e28600292c73ea31b5cb450dba7";
export default node;

View File

@@ -0,0 +1,42 @@
/**
* @generated SignedSource<<b6a869ebbc0ded2da70f59b7856b906f>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type sessionIsExpiredFragment$data = {
readonly expiresAt: any;
readonly " $fragmentType": "sessionIsExpiredFragment";
};
export type sessionIsExpiredFragment$key = {
readonly " $data"?: sessionIsExpiredFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"sessionIsExpiredFragment">;
};
const node: ReaderFragment = {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "sessionIsExpiredFragment",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "expiresAt",
"storageKey": null
}
],
"type": "Session",
"abstractKey": null
};
(node as any).hash = "ef7bc8bbfa6d2573b0df09b5b13b00b2";
export default node;

View File

@@ -0,0 +1,133 @@
import { useTranslate } from "@probo/i18n";
import {
Avatar,
Badge,
Button,
Card,
IconCheckmark1,
IconClock,
IconLock,
} from "@probo/ui";
import { Link } from "react-router";
import { graphql } from "relay-runtime";
import { useFragment } from "react-relay";
import type { MembershipCardFragment$key } from "./__generated__/MembershipCardFragment.graphql";
import { parseDate } from "@probo/helpers";
const fragment = graphql`
fragment MembershipCardFragment on Membership {
activeSession {
id
expiresAt
}
organization {
id
name
logoUrl
}
}
`;
interface MembershipCardProps {
fKey: MembershipCardFragment$key;
}
export function MembershipCard(props: MembershipCardProps) {
const { fKey } = props;
const { __ } = useTranslate();
const { activeSession, organization } =
useFragment<MembershipCardFragment$key>(fragment, fKey);
const isAuthenticated = !!activeSession;
const isExpired =
activeSession && parseDate(activeSession.expiresAt) >= new Date();
// Determine target URL and button text based on auth status
// const targetUrl = isAuthenticated
// ? `/organizations/${organization.id}`
// : organization.loginUrl;
const targetUrl = `/organizations/${organization.id}`;
const getAuthBadge = () => {
if (isAuthenticated) {
return (
<Badge variant="success" className="flex items-center gap-1">
<IconCheckmark1 size={14} />
{__("Authenticated")}
</Badge>
);
} else if (isExpired) {
return (
<Badge variant="warning" className="flex items-center gap-1">
<IconClock size={14} />
{__("Session expired")}
</Badge>
);
} else {
return (
<Badge variant="neutral" className="flex items-center gap-1">
<IconLock size={14} />
{__("Authentication required")}
</Badge>
);
}
};
// const getButtonText = () => {
// if (isAuthenticated) return __("Select");
// if (organization.authenticationMethod === "saml")
// return __("Login with SAML");
// return __("Login");
// };
// Check if the URL is a backend SAML endpoint
const isSAMLUrl = targetUrl.includes("/connect/saml/");
return (
<Card padded className="w-full">
<div className="flex items-center justify-between">
{isSAMLUrl ? (
<a
href={targetUrl}
className="flex items-center gap-4 hover:text-primary flex-1"
>
<Avatar
src={organization.logoUrl}
name={organization.name}
size="l"
/>
<div className="flex flex-col gap-1">
<h2 className="font-semibold text-xl">{organization.name}</h2>
{getAuthBadge()}
</div>
</a>
) : (
<Link
to={targetUrl}
className="flex items-center gap-4 hover:text-primary flex-1"
>
<Avatar
src={organization.logoUrl}
name={organization.name}
size="l"
/>
<div className="flex flex-col gap-1">
<h2 className="font-semibold text-xl">{organization.name}</h2>
{getAuthBadge()}
</div>
</Link>
)}
<div className="flex items-center gap-3">
<Button asChild>
{/* {isSAMLUrl ? (
<a href={targetUrl}>{getButtonText()}</a>
) : (
<Link to={targetUrl}>{getButtonText()}</Link>
)} */}
<Link to={targetUrl}>LOGIN</Link>
</Button>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,97 @@
/**
* @generated SignedSource<<30ba0cd2d4738694d58bf346410d4d68>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type MembershipCardFragment$data = {
readonly activeSession: {
readonly expiresAt: any;
readonly id: string;
} | null | undefined;
readonly organization: {
readonly id: string;
readonly logoUrl: string | null | undefined;
readonly name: string;
};
readonly " $fragmentType": "MembershipCardFragment";
};
export type MembershipCardFragment$key = {
readonly " $data"?: MembershipCardFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"MembershipCardFragment">;
};
const node: ReaderFragment = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "MembershipCardFragment",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Session",
"kind": "LinkedField",
"name": "activeSession",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "expiresAt",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "organization",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "logoUrl",
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Membership",
"abstractKey": null
};
})();
(node as any).hash = "2145f6030b486447df7b7d9ff798ec06";
export default node;

View File

@@ -1,5 +1,5 @@
import { loadQuery } from "react-relay";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
import { lazy } from "@probo/react-lazy";
import { rightsRequestsQuery, rightsRequestNodeQuery } from "/hooks/graph/RightsRequestGraph";
@@ -12,7 +12,7 @@ export const rightsRequestRoutes = [
path: "rights-requests",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<RightsRequestGraphListQuery>(relayEnvironment, rightsRequestsQuery, {
loadQuery<RightsRequestGraphListQuery>(consoleEnvironment, rightsRequestsQuery, {
organizationId,
}),
),
@@ -24,7 +24,7 @@ export const rightsRequestRoutes = [
path: "rights-requests/:requestId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ requestId }) =>
loadQuery<RightsRequestGraphNodeQuery>(relayEnvironment, rightsRequestNodeQuery, {
loadQuery<RightsRequestGraphNodeQuery>(consoleEnvironment, rightsRequestNodeQuery, {
rightsRequestId: requestId!,
}),
),

View File

@@ -1,6 +1,6 @@
import { lazy } from "@probo/react-lazy";
import { loadQuery } from "react-relay";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import { PageSkeleton } from "/components/skeletons/PageSkeleton.tsx";
import {
paginatedStateOfApplicabilityQuery,
@@ -16,7 +16,7 @@ export const statesOfApplicabilityRoutes = [
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<StateOfApplicabilityGraphPaginatedQuery>(
relayEnvironment,
consoleEnvironment,
paginatedStateOfApplicabilityQuery,
{ organizationId: organizationId! },
),
@@ -30,7 +30,7 @@ export const statesOfApplicabilityRoutes = [
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<StateOfApplicabilityGraphPaginatedQuery>(
relayEnvironment,
consoleEnvironment,
paginatedStateOfApplicabilityQuery,
{ organizationId: organizationId! },
),
@@ -44,7 +44,7 @@ export const statesOfApplicabilityRoutes = [
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ stateOfApplicabilityId }) =>
loadQuery<StateOfApplicabilityGraphNodeQuery>(
relayEnvironment,
consoleEnvironment,
stateOfApplicabilityNodeQuery,
{ stateOfApplicabilityId: stateOfApplicabilityId! },
),
@@ -58,7 +58,7 @@ export const statesOfApplicabilityRoutes = [
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ stateOfApplicabilityId }) =>
loadQuery<StateOfApplicabilityGraphNodeQuery>(
relayEnvironment,
consoleEnvironment,
stateOfApplicabilityNodeQuery,
{ stateOfApplicabilityId: stateOfApplicabilityId! },
),

View File

@@ -9,21 +9,21 @@ export function toDateInput(dateString?: string | null): string {
}
export function formatDate(dateInput?: string | null): string {
if (!dateInput) return '';
if (!dateInput) return "";
const date = parseDate(dateInput);
return date.toLocaleDateString();
}
function parseDate(dateString: string): Date {
if (dateString.includes('T')) {
export function parseDate(dateString: string): Date {
if (dateString.includes("T")) {
return new Date(dateString);
}
const parts = dateString.split('-');
const parts = dateString.split("-");
return new Date(
parseInt(parts[0], 10),
parts[1] ? parseInt(parts[1], 10) - 1 : 0,
parts[2] ? parseInt(parts[2], 10) : 1
parts[2] ? parseInt(parts[2], 10) : 1,
);
}

View File

@@ -1,16 +1,16 @@
export { objectKeys, objectEntries, cleanFormData } from "./object";
export { sprintf, faviconUrl, slugify, domain } from "./string";
export {
getTreatment,
getRiskImpacts,
getRiskLikelihoods,
getSeverity,
getTreatment,
getRiskImpacts,
getRiskLikelihoods,
getSeverity,
} from "./risk";
export {
withViewTransition,
downloadFile,
safeOpenUrl,
focusSiblingElement,
withViewTransition,
downloadFile,
safeOpenUrl,
focusSiblingElement,
} from "./dom";
export { times, groupBy, isEmpty } from "./array";
export { randomInt } from "./number";
@@ -18,39 +18,39 @@ export { getMeasureStateLabel, measureStates } from "./measure";
export { getRole, getRoles, peopleRoles } from "./people";
export { certificationCategoryLabel, certifications } from "./certifications";
export {
getCountryName,
getCountryOptions,
getCountryLabel,
countries,
type CountryCode,
getCountryName,
getCountryOptions,
getCountryLabel,
countries,
type CountryCode,
} from "./countries";
export {
getDocumentTypeLabel,
documentTypes,
getDocumentClassificationLabel,
documentClassifications,
getDocumentTypeLabel,
documentTypes,
getDocumentClassificationLabel,
documentClassifications,
} from "./documents";
export { getAssetTypeVariant } from "./assets";
export {
getSnapshotTypeLabel,
getSnapshotTypeUrlPath,
snapshotTypes,
validateSnapshotConsistency,
getSnapshotTypeLabel,
getSnapshotTypeUrlPath,
snapshotTypes,
validateSnapshotConsistency,
} from "./snapshots";
export {
getAuditStateLabel,
getAuditStateVariant,
auditStates,
getAuditStateLabel,
getAuditStateVariant,
auditStates,
} from "./audits";
export {
getStatusVariant,
getStatusLabel,
getStatusOptions,
getStatusVariant,
getStatusLabel,
getStatusOptions,
} from "./registryStatus";
export {
getObligationStatusVariant,
getObligationStatusLabel,
getObligationStatusOptions,
getObligationStatusVariant,
getObligationStatusLabel,
getObligationStatusOptions,
} from "./obligationStatus";
export {
getObligationTypeLabel,
@@ -65,24 +65,30 @@ export {
} from "./trustCenterVisibility";
export { promisifyMutation } from "./relay";
export { fileType, fileSize } from "./file";
export { formatDatetime, formatDate, toDateInput, formatDuration } from "./date";
export {
formatDatetime,
formatDate,
toDateInput,
formatDuration,
parseDate,
} from "./date";
export { getTrustCenterUrl } from "./trustCenter";
export { formatError, type GraphQLError } from "./error";
export { Role, getAssignableRoles } from "./roles";
export {
getTrustCenterDocumentAccessInfo,
getTrustCenterDocumentAccessStatusBadgeVariant,
getTrustCenterDocumentAccessStatusLabel,
type TrustCenterDocumentAccessInfo,
getTrustCenterDocumentAccessInfo,
getTrustCenterDocumentAccessStatusBadgeVariant,
getTrustCenterDocumentAccessStatusLabel,
type TrustCenterDocumentAccessInfo,
} from "./trustCenterDocumentAccess";
export {
getRightsRequestTypeLabel,
getRightsRequestTypeOptions,
getRightsRequestStateVariant,
getRightsRequestStateLabel,
getRightsRequestStateOptions,
rightsRequestTypes,
rightsRequestStates,
type RightsRequestType,
type RightsRequestState,
getRightsRequestTypeLabel,
getRightsRequestTypeOptions,
getRightsRequestStateVariant,
getRightsRequestStateLabel,
getRightsRequestStateOptions,
rightsRequestTypes,
rightsRequestStates,
type RightsRequestType,
type RightsRequestState,
} from "./rightsRequest";