Replug organizations page without assume or user dropdown

Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
Émile Ré
2025-12-19 19:46:20 +01:00
committed by Bryan Frimin
parent e4df37b5c8
commit 4218200409
25 changed files with 1286 additions and 938 deletions

View File

@@ -1,161 +0,0 @@
/**
* @generated SignedSource<<f441167b6ed9402b92fcdf6dbaa77170>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type MainLayoutQuery$variables = {
organizationId: string;
};
export type MainLayoutQuery$data = {
readonly organization: {
readonly id?: string;
readonly logoUrl?: string | null | undefined;
readonly name?: string;
};
readonly viewer: {
readonly id: string;
};
};
export type MainLayoutQuery = {
response: MainLayoutQuery$data;
variables: MainLayoutQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
}
],
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v2 = {
"alias": null,
"args": null,
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
(v1/*: any*/)
],
"storageKey": null
},
v3 = [
{
"kind": "Variable",
"name": "id",
"variableName": "organizationId"
}
],
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
v5 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "logoUrl",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "MainLayoutQuery",
"selections": [
(v2/*: any*/),
{
"alias": "organization",
"args": (v3/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"kind": "InlineFragment",
"selections": [
(v1/*: any*/),
(v4/*: any*/),
(v5/*: any*/)
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "MainLayoutQuery",
"selections": [
(v2/*: any*/),
{
"alias": "organization",
"args": (v3/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v1/*: any*/),
{
"kind": "InlineFragment",
"selections": [
(v4/*: any*/),
(v5/*: any*/)
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "2ec49ff40720bacec29e7b6b1bf1408b",
"id": null,
"metadata": {},
"name": "MainLayoutQuery",
"operationKind": "query",
"text": "query MainLayoutQuery(\n $organizationId: ID!\n) {\n viewer {\n id\n }\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n name\n logoUrl\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "d958a2acbd9d13698b9c2b350b05d5a0";
export default node;

View File

@@ -1,364 +0,0 @@
import { useTranslate } from "@probo/i18n";
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router";
import {
Avatar,
Button,
Card,
IconPlusLarge,
IconCheckmark1,
IconLock,
IconClock,
IconMagnifyingGlass,
Badge,
Input,
} from "@probo/ui";
import { usePageTitle } from "@probo/hooks";
import { formatDate } from "@probo/helpers";
interface Organization {
id: string;
name: string;
logoUrl?: string | null;
authenticationMethod: string;
authStatus: "authenticated" | "unauthenticated" | "expired";
loginUrl: string;
}
interface Invitation {
id: string;
email: string;
fullName: string;
role: string;
expiresAt: string;
acceptedAt?: string | null;
createdAt: string;
organization: {
id: string;
name: string;
};
}
export default function OrganizationsPage() {
const { __ } = useTranslate();
const navigate = useNavigate();
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(true);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [isLoadingInvitations, setIsLoadingInvitations] = useState(true);
const [isAccepting, setIsAccepting] = useState(false);
const [search, setSearch] = useState("");
const filteredOrganizations = useMemo(() => {
if (!search.trim()) {
return organizations;
}
return organizations.filter((org) =>
org.name.toLowerCase().includes(search.toLowerCase())
);
}, [organizations, search]);
// Fetch organizations from REST endpoint
useEffect(() => {
const fetchOrganizations = async () => {
try {
const response = await fetch('/connect/organizations', {
credentials: 'include',
});
if (!response.ok) {
throw new Error('Failed to fetch organizations');
}
const data: { organizations: Organization[] } = await response.json();
setOrganizations(data.organizations);
} catch (err) {
console.error('Failed to fetch organizations:', err);
} finally {
setIsLoadingOrganizations(false);
}
};
fetchOrganizations();
}, []);
// Fetch pending invitations from REST endpoint
useEffect(() => {
const fetchInvitations = async () => {
try {
const response = await fetch('/connect/invitations', {
credentials: 'include',
});
if (!response.ok) {
throw new Error('Failed to fetch invitations');
}
const data: { invitations: Invitation[] } = await response.json();
setInvitations(data.invitations);
} catch (err) {
console.error('Failed to fetch invitations:', err);
} finally {
setIsLoadingInvitations(false);
}
};
fetchInvitations();
}, []);
const handleAcceptInvitation = async (invitationId: string, organizationId: string) => {
setIsAccepting(true);
try {
const response = await fetch('/connect/invitations/accept', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ invitationId }),
});
if (!response.ok) {
throw new Error('Failed to accept invitation');
}
// Navigate to the organization after successful acceptance
navigate(`/organizations/${organizationId}`);
} catch (err) {
console.error('Failed to accept invitation:', err);
alert(__('Failed to accept invitation'));
} finally {
setIsAccepting(false);
}
};
usePageTitle(__("Select an organization"));
useEffect(() => {
// Only auto-navigate once both organizations and invitations are loaded
if (!isLoadingOrganizations && !isLoadingInvitations) {
if (organizations.length === 1 && invitations.length === 0) {
navigate(`/organizations/${organizations[0].id}`);
} else if (organizations.length === 0 && invitations.length === 0) {
navigate("/organizations/new");
}
}
}, [organizations, invitations, isLoadingOrganizations, isLoadingInvitations, navigate]);
return (
<>
<div className="space-y-6 w-full py-6">
<h1 className="text-3xl font-bold text-center">
{__("Select an organization")}
</h1>
<div className="space-y-4 w-full">
{invitations.length > 0 && (
<div className="space-y-3">
<h2 className="text-xl font-semibold">
{__("Pending invitations")}
</h2>
{invitations.map((invitation) => (
<InvitationCard
key={invitation.id}
invitation={invitation}
onAccept={handleAcceptInvitation}
isAccepting={isAccepting}
/>
))}
</div>
)}
{organizations.length > 0 && (
<div className="space-y-3">
{invitations.length > 0 && (
<h2 className="text-xl font-semibold">
{__("Your organizations")}
</h2>
)}
{organizations.length > 3 && (
<div className="w-full">
<Input
icon={IconMagnifyingGlass}
placeholder={__("Search organizations...")}
value={search}
onValueChange={setSearch}
/>
</div>
)}
{filteredOrganizations.length === 0 ? (
<div className="text-center text-txt-secondary py-4">
{__("No organizations found")}
</div>
) : (
filteredOrganizations.map((organization) => (
<OrganizationCard
key={organization.id}
organization={organization}
/>
))
)}
</div>
)}
<Card padded>
<h2 className="text-xl font-semibold mb-1">
{__("Create an organization")}
</h2>
<p className="text-txt-tertiary mb-4">
{__("Add a new organization to your account")}
</p>
<Button
to="/organizations/new"
variant="quaternary"
icon={IconPlusLarge}
className="w-full"
>
{__("Create organization")}
</Button>
</Card>
</div>
</div>
</>
);
}
type InvitationCardProps = {
invitation: Invitation;
onAccept: (invitationId: string, organizationId: string) => void;
isAccepting: boolean;
};
function InvitationCard({ invitation, onAccept, isAccepting }: InvitationCardProps) {
const { __ } = useTranslate();
return (
<Card padded className="w-full">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-1">
<h3 className="text-lg font-semibold">
{invitation.organization.name}
</h3>
<p className="text-sm text-txt-secondary">
{__("Role")}: <span className="font-medium">{invitation.role}</span>
</p>
<p className="text-xs text-txt-tertiary">
{__("Invited on")} {formatDate(invitation.createdAt)}
</p>
</div>
<Button
onClick={() => onAccept(invitation.id, invitation.organization.id)}
disabled={isAccepting}
>
{isAccepting ? __("Accepting...") : __("Accept invitation")}
</Button>
</div>
</Card>
);
}
type OrganizationCardProps = {
organization: Organization;
};
function OrganizationCard({ organization }: OrganizationCardProps) {
const { __ } = useTranslate();
const isAuthenticated = organization.authStatus === "authenticated";
const isExpired = organization.authStatus === "expired";
const needsAuth = organization.authStatus === "unauthenticated";
// Determine target URL and button text based on auth status
const targetUrl = isAuthenticated
? `/organizations/${organization.id}`
: organization.loginUrl;
const getAuthBadge = () => {
if (isAuthenticated) {
return (
<Badge variant="success" className="flex items-center gap-1">
<IconCheckmark1 size={14} />
{__("Authenticated")}
</Badge>
);
}
if (isExpired) {
return (
<Badge variant="warning" className="flex items-center gap-1">
<IconClock size={14} />
{__("Session expired")}
</Badge>
);
}
if (needsAuth) {
return (
<Badge variant="neutral" className="flex items-center gap-1">
<IconLock size={14} />
{__("Authentication required")}
</Badge>
);
}
return null;
};
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>
)}
</Button>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,48 @@
import {
RelayEnvironmentProvider,
usePreloadedQuery,
type PreloadedQuery,
} from "react-relay";
import { organizationLayoutQuery } from "./OrganizationLayoutQuery";
import { Outlet } from "react-router";
import { Layout } from "@probo/ui";
import { Sidebar } from "./_components/Sidebar";
import { OrganizationDropdown } from "./_components/OrganizationDropdown";
import { consoleEnvironment } from "/environments";
import type { OrganizationLayoutQuery } from "./__generated__/OrganizationLayoutQuery.graphql";
import { PermissionsProvider } from "/providers/PermissionsProvider";
interface OrganizationLayoutProps {
queryRef: PreloadedQuery<OrganizationLayoutQuery>;
}
export default function OrganizationLayout(props: OrganizationLayoutProps) {
const { queryRef } = props;
const data = usePreloadedQuery<OrganizationLayoutQuery>(
organizationLayoutQuery,
queryRef,
);
return (
<PermissionsProvider>
<Layout
header={
<>
<div className="mr-auto">
<OrganizationDropdown fKey={data.organization} />
</div>
{/* <Suspense fallback={<Skeleton className="w-32 h-8" />}>
<UserDropdown organizationId={organizationId} />
</Suspense> */}
</>
}
sidebar={<Sidebar />}
>
<RelayEnvironmentProvider environment={consoleEnvironment}>
<Outlet />
</RelayEnvironmentProvider>
</Layout>
</PermissionsProvider>
);
}

View File

@@ -0,0 +1,47 @@
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 { OrganizationLayoutQuery } from "./__generated__/OrganizationLayoutQuery.graphql";
const Layout = lazy(() => import("./OrganizationLayout"));
export const organizationLayoutQuery = graphql`
query OrganizationLayoutQuery($organizationId: ID!) {
organization: node(id: $organizationId) @required(action: THROW) {
... on Organization {
...OrganizationDropdownFragment
}
}
viewer {
pendingInvitations {
totalCount
}
}
}
`;
export function OrganizationLayoutQuery() {
const organizationId = useOrganizationId();
const [queryRef, loadQuery] = useQueryLoader<OrganizationLayoutQuery>(
organizationLayoutQuery,
);
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

@@ -9,15 +9,12 @@ import {
} from "@probo/ui";
import { useMemo, useState } from "react";
import { InvitationCard } from "./_components/InvitationCard";
import type {
OrganizationsQuery,
OrganizationsQuery$data,
} from "./__generated__/OrganizationsQuery.graphql";
import { organizationsQuery } from "./OrganizationsQuery";
import { organizationsPageQuery } from "./OrganizationsPageQuery";
import { MembershipCard } from "./_components/MembershipCard";
import type { OrganizationsPageQuery } from "./__generated__/OrganizationsPageQuery.graphql";
interface PageProps {
queryRef: PreloadedQuery<OrganizationsQuery>;
queryRef: PreloadedQuery<OrganizationsPageQuery>;
}
export default function Page(props: PageProps) {
@@ -30,11 +27,12 @@ export default function Page(props: PageProps) {
memberships: { edges: initialMemberships },
pendingInvitations: { edges: invitations },
},
} = usePreloadedQuery<OrganizationsQuery>(organizationsQuery, queryRef);
} = usePreloadedQuery<OrganizationsPageQuery>(
organizationsPageQuery,
queryRef,
);
const memberships = useMemo<
OrganizationsQuery$data["viewer"]["memberships"]["edges"]
>(() => {
const memberships = useMemo(() => {
if (!search.trim()) {
return initialMemberships;
}

View File

@@ -1,24 +1,22 @@
import { lazy } from "@probo/react-lazy";
import { useQueryLoader } from "react-relay";
import { graphql } from "relay-runtime";
import type { OrganizationsQuery } from "./__generated__/OrganizationsQuery.graphql";
import { Suspense, useEffect } from "react";
import { CenteredLayoutSkeleton } from "@probo/ui";
import type { OrganizationsPageQuery } from "./__generated__/OrganizationsPageQuery.graphql";
const Page = lazy(() => import("./OrganizationsPage"));
export const organizationsQuery = graphql`
query OrganizationsQuery {
export const organizationsPageQuery = graphql`
query OrganizationsPageQuery {
viewer @required(action: THROW) {
memberships(
first: 1000
orderBy: { direction: DESC, field: CREATED_AT }
) {
edges {
node {
memberships(first: 1000, orderBy: { direction: DESC, field: CREATED_AT })
@required(action: THROW) {
edges @required(action: THROW) {
node @required(action: THROW) {
id
...MembershipCardFragment
organization {
organization @required(action: THROW) {
name
}
}
@@ -27,9 +25,9 @@ export const organizationsQuery = graphql`
pendingInvitations(
first: 1000
orderBy: { direction: DESC, field: CREATED_AT }
) {
edges {
node {
) @required(action: THROW) {
edges @required(action: THROW) {
node @required(action: THROW) {
id
...InvitationCardFragment
}
@@ -39,9 +37,10 @@ export const organizationsQuery = graphql`
}
`;
export function OrganizationsQuery() {
const [queryRef, loadQuery] =
useQueryLoader<OrganizationsQuery>(organizationsQuery);
export function OrganizationsPageQuery() {
const [queryRef, loadQuery] = useQueryLoader<OrganizationsPageQuery>(
organizationsPageQuery,
);
useEffect(() => {
loadQuery({});

View File

@@ -0,0 +1,188 @@
/**
* @generated SignedSource<<e99ea3ab9f033b6a5384d389dd964f06>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type OrganizationLayoutQuery$variables = {
organizationId: string;
};
export type OrganizationLayoutQuery$data = {
readonly organization: {
readonly " $fragmentSpreads": FragmentRefs<"OrganizationDropdownFragment">;
};
readonly viewer: {
readonly pendingInvitations: {
readonly totalCount: number | null | undefined;
} | null | undefined;
} | null | undefined;
};
export type OrganizationLayoutQuery = {
response: OrganizationLayoutQuery$data;
variables: OrganizationLayoutQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "organizationId"
}
],
v2 = {
"alias": null,
"args": null,
"concreteType": "InvitationConnection",
"kind": "LinkedField",
"name": "pendingInvitations",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "totalCount",
"storageKey": null
}
],
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "OrganizationLayoutQuery",
"selections": [
{
"kind": "RequiredField",
"field": {
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"kind": "InlineFragment",
"selections": [
{
"args": null,
"kind": "FragmentSpread",
"name": "OrganizationDropdownFragment"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
},
"action": "THROW"
},
{
"alias": null,
"args": null,
"concreteType": "Identity",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
(v2/*: any*/)
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "OrganizationLayoutQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
}
],
"type": "Organization",
"abstractKey": null
},
(v3/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "Identity",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/)
],
"storageKey": null
}
]
},
"params": {
"cacheID": "b991c1a406cc9c0df5c67099285c31a8",
"id": null,
"metadata": {},
"name": "OrganizationLayoutQuery",
"operationKind": "query",
"text": "query OrganizationLayoutQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n ...OrganizationDropdownFragment\n }\n id\n }\n viewer {\n pendingInvitations {\n totalCount\n }\n id\n }\n}\n\nfragment OrganizationDropdownFragment on Organization {\n name\n}\n"
}
};
})();
(node as any).hash = "2e8a1c50dc999d218830ab3df10cdeb5";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<97119b2ea90f368155a13a32941b99d3>>
* @generated SignedSource<<6ac0b24d615666de1a5ea1c751095e6d>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -10,8 +10,8 @@
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type OrganizationsQuery$variables = Record<PropertyKey, never>;
export type OrganizationsQuery$data = {
export type OrganizationsPageQuery$variables = Record<PropertyKey, never>;
export type OrganizationsPageQuery$data = {
readonly viewer: {
readonly memberships: {
readonly edges: ReadonlyArray<{
@@ -34,9 +34,9 @@ export type OrganizationsQuery$data = {
};
};
};
export type OrganizationsQuery = {
response: OrganizationsQuery$data;
variables: OrganizationsQuery$variables;
export type OrganizationsPageQuery = {
response: OrganizationsPageQuery$data;
variables: OrganizationsPageQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -74,7 +74,7 @@ return {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "OrganizationsQuery",
"name": "OrganizationsPageQuery",
"selections": [
{
"kind": "RequiredField",
@@ -87,94 +87,122 @@ return {
"plural": false,
"selections": [
{
"alias": null,
"args": (v0/*: any*/),
"concreteType": "MembershipConnection",
"kind": "LinkedField",
"name": "memberships",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MembershipEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"kind": "RequiredField",
"field": {
"alias": null,
"args": (v0/*: any*/),
"concreteType": "MembershipConnection",
"kind": "LinkedField",
"name": "memberships",
"plural": false,
"selections": [
{
"kind": "RequiredField",
"field": {
"alias": null,
"args": null,
"concreteType": "Membership",
"concreteType": "MembershipEdge",
"kind": "LinkedField",
"name": "node",
"plural": false,
"name": "edges",
"plural": true,
"selections": [
(v1/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "MembershipCardFragment"
},
{
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "organization",
"plural": false,
"selections": [
(v2/*: any*/)
],
"storageKey": null
"kind": "RequiredField",
"field": {
"alias": null,
"args": null,
"concreteType": "Membership",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "MembershipCardFragment"
},
{
"kind": "RequiredField",
"field": {
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "organization",
"plural": false,
"selections": [
(v2/*: any*/)
],
"storageKey": null
},
"action": "THROW"
}
],
"storageKey": null
},
"action": "THROW"
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "memberships(first:1000,orderBy:{\"direction\":\"DESC\",\"field\":\"CREATED_AT\"})"
},
"action": "THROW"
}
],
"storageKey": "memberships(first:1000,orderBy:{\"direction\":\"DESC\",\"field\":\"CREATED_AT\"})"
},
"action": "THROW"
},
{
"alias": null,
"args": (v0/*: any*/),
"concreteType": "InvitationConnection",
"kind": "LinkedField",
"name": "pendingInvitations",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "InvitationEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"kind": "RequiredField",
"field": {
"alias": null,
"args": (v0/*: any*/),
"concreteType": "InvitationConnection",
"kind": "LinkedField",
"name": "pendingInvitations",
"plural": false,
"selections": [
{
"kind": "RequiredField",
"field": {
"alias": null,
"args": null,
"concreteType": "Invitation",
"concreteType": "InvitationEdge",
"kind": "LinkedField",
"name": "node",
"plural": false,
"name": "edges",
"plural": true,
"selections": [
(v1/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "InvitationCardFragment"
"kind": "RequiredField",
"field": {
"alias": null,
"args": null,
"concreteType": "Invitation",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "InvitationCardFragment"
}
],
"storageKey": null
},
"action": "THROW"
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "pendingInvitations(first:1000,orderBy:{\"direction\":\"DESC\",\"field\":\"CREATED_AT\"})"
},
"action": "THROW"
}
],
"storageKey": "pendingInvitations(first:1000,orderBy:{\"direction\":\"DESC\",\"field\":\"CREATED_AT\"})"
},
"action": "THROW"
}
],
"storageKey": null
@@ -189,7 +217,7 @@ return {
"operation": {
"argumentDefinitions": [],
"kind": "Operation",
"name": "OrganizationsQuery",
"name": "OrganizationsPageQuery",
"selections": [
{
"alias": null,
@@ -229,7 +257,7 @@ return {
"args": null,
"concreteType": "Session",
"kind": "LinkedField",
"name": "activeSession",
"name": "lastSession",
"plural": false,
"selections": [
(v1/*: any*/),
@@ -340,16 +368,16 @@ return {
]
},
"params": {
"cacheID": "5dad37a0c652055f81de5bd2c09995c2",
"cacheID": "146deb87c5d32960212f323b4f5c792c",
"id": null,
"metadata": {},
"name": "OrganizationsQuery",
"name": "OrganizationsPageQuery",
"operationKind": "query",
"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"
"text": "query OrganizationsPageQuery {\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 lastSession {\n id\n expiresAt\n }\n organization {\n id\n name\n logoUrl\n }\n}\n"
}
};
})();
(node as any).hash = "dc4b2e28600292c73ea31b5cb450dba7";
(node as any).hash = "7c03287e592920408306cac9bfa7fc81";
export default node;

View File

@@ -1,42 +0,0 @@
/**
* @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

@@ -10,7 +10,7 @@ const fragment = graphql`
id
role
createdAt
organization {
organization @required(action: THROW) {
id
name
}

View File

@@ -16,11 +16,11 @@ import { parseDate } from "@probo/helpers";
const fragment = graphql`
fragment MembershipCardFragment on Membership {
activeSession {
lastSession {
id
expiresAt
}
organization {
organization @required(action: THROW) {
id
name
logoUrl
@@ -36,11 +36,13 @@ export function MembershipCard(props: MembershipCardProps) {
const { fKey } = props;
const { __ } = useTranslate();
const { activeSession, organization } =
useFragment<MembershipCardFragment$key>(fragment, fKey);
const isAuthenticated = !!activeSession;
const { lastSession, organization } = useFragment<MembershipCardFragment$key>(
fragment,
fKey,
);
const isAuthenticated = !!lastSession;
const isExpired =
activeSession && parseDate(activeSession.expiresAt) >= new Date();
lastSession && parseDate(lastSession.expiresAt) >= new Date();
// Determine target URL and button text based on auth status
// const targetUrl = isAuthenticated

View File

@@ -1,126 +0,0 @@
import { useTranslate } from "@probo/i18n";
import { Avatar, Badge, Button, Card, IconLock } from "@probo/ui";
import { Link } from "react-router";
import { graphql } from "relay-runtime";
import { useFragment } from "react-relay";
import type { OrganizationCardFragment$key } from "./__generated__/OrganizationCardFragment.graphql";
const fragment = graphql`
fragment OrganizationCardFragment on Organization {
id
name
logoUrl
}
`;
interface OrganizationCardProps {
fKey: OrganizationCardFragment$key;
}
export function OrganizationCard(props: OrganizationCardProps) {
const { fKey } = props;
const { __ } = useTranslate();
const organization = useFragment<OrganizationCardFragment$key>(
fragment,
fKey,
);
// const isAuthenticated = organization.authStatus === "authenticated";
// const isExpired = organization.authStatus === "expired";
// const needsAuth = organization.authStatus === "unauthenticated";
// 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>
// );
// }
// if (isExpired) {
// return (
// <Badge variant="warning" className="flex items-center gap-1">
// <IconClock size={14} />
// {__("Session expired")}
// </Badge>
// );
// }
// if (needsAuth) {
return (
<Badge variant="neutral" className="flex items-center gap-1">
<IconLock size={14} />
{__("Authentication required")}
</Badge>
);
// }
return null;
};
// 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,89 @@
import { useFragment, useQueryLoader } from "react-relay";
import { graphql } from "relay-runtime";
import type { OrganizationDropdownFragment$key } from "./__generated__/OrganizationDropdownFragment.graphql";
import {
Button,
Dropdown,
DropdownSeparator,
IconChevronGrabberVertical,
IconMagnifyingGlass,
Input,
} from "@probo/ui";
import { Suspense, useCallback, useState } from "react";
import { useTranslate } from "@probo/i18n";
import {
OrganizationDropdownMenu,
organizationDropdownMenuQuery,
} from "./OrganizationDropdownMenu";
import type { OrganizationDropdownMenuQuery } from "./__generated__/OrganizationDropdownMenuQuery.graphql";
const fragment = graphql`
fragment OrganizationDropdownFragment on Organization {
name
}
`;
export function OrganizationDropdown(props: {
fKey: OrganizationDropdownFragment$key;
}) {
const { fKey } = props;
const { __ } = useTranslate();
const [search, setSearch] = useState("");
const currentOrganization = useFragment(fragment, fKey);
const [queryRef, loadQuery] = useQueryLoader<OrganizationDropdownMenuQuery>(
organizationDropdownMenuQuery,
);
const handleOpenMenu = useCallback(
(open: boolean) => {
if (open) loadQuery({});
},
[loadQuery],
);
return (
<div className="flex items-center gap-1">
<Dropdown
onOpenChange={handleOpenMenu}
toggle={
<Button
className="-ml-3"
variant="tertiary"
iconAfter={IconChevronGrabberVertical}
>
{currentOrganization.name}
</Button>
}
>
<div className="px-3 py-2">
<Input
icon={IconMagnifyingGlass}
placeholder={__("Search organizations...")}
value={search}
onValueChange={setSearch}
onKeyDown={(e) => {
e.stopPropagation();
}}
autoFocus
/>
</div>
<div className="max-h-150 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent hover:scrollbar-thumb-gray-400">
{queryRef && (
<Suspense
fallback={
<div className="px-3 py-2 text-gray-500">
{__("Loading organizations...")}
</div>
}
>
<OrganizationDropdownMenu search={search} queryRef={queryRef} />
</Suspense>
)}
</div>
<DropdownSeparator />
</Dropdown>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { graphql, usePreloadedQuery, type PreloadedQuery } from "react-relay";
import type { OrganizationDropdownMenuQuery } from "./__generated__/OrganizationDropdownMenuQuery.graphql";
import { useMemo } from "react";
import { OrganizationDropdownMenuItem } from "./OrganizationDropdownMenuItem";
export const organizationDropdownMenuQuery = graphql`
query OrganizationDropdownMenuQuery {
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
organization @required(action: THROW) {
name
}
...OrganizationDropdownMenuItemFragment
}
}
}
}
}
`;
interface OrganizationDropdownMenuProps {
queryRef: PreloadedQuery<OrganizationDropdownMenuQuery>;
search: string;
}
export function OrganizationDropdownMenu(props: OrganizationDropdownMenuProps) {
const { queryRef, search } = props;
const {
viewer: {
memberships: { edges: initialMemberships },
},
} = usePreloadedQuery<OrganizationDropdownMenuQuery>(
organizationDropdownMenuQuery,
queryRef,
);
const memberships = useMemo(() => {
if (!search) {
return initialMemberships;
}
return initialMemberships.filter(({ node: { organization } }) =>
organization.name.toLowerCase().includes(search.toLowerCase()),
);
}, [initialMemberships, search]);
return (
<>
{memberships.map(({ node }) => (
<OrganizationDropdownMenuItem fKey={node} key={node.id} />
))}
</>
);
}

View File

@@ -0,0 +1,50 @@
import {
Avatar,
DropdownItem,
IconCheckmark1,
IconClock,
IconLock,
} from "@probo/ui";
import { graphql } from "relay-runtime";
import type { OrganizationDropdownMenuItemFragment$key } from "./__generated__/OrganizationDropdownMenuItemFragment.graphql";
import { useFragment } from "react-relay";
import { parseDate } from "@probo/helpers";
const fragment = graphql`
fragment OrganizationDropdownMenuItemFragment on Membership {
id
lastSession {
id
expiresAt
}
organization @required(action: THROW) {
logoUrl
name
}
}
`;
export function OrganizationDropdownMenuItem(props: {
fKey: OrganizationDropdownMenuItemFragment$key;
}) {
const { fKey } = props;
const { id, lastSession, organization } =
useFragment<OrganizationDropdownMenuItemFragment$key>(fragment, fKey);
const isAuthenticated = !!lastSession;
const isExpired =
lastSession && parseDate(lastSession.expiresAt) >= new Date();
return (
<DropdownItem key={id}>
{/* TODO add link or anchor */}
<Avatar name={organization.name} src={organization.logoUrl} />
<span className="flex-1">{organization.name}</span>
{isAuthenticated && (
<IconCheckmark1 size={16} className="text-green-600" />
)}
{isExpired && <IconClock size={16} className="text-orange-600" />}
{!lastSession && <IconLock size={16} className="text-gray-400" />}
</DropdownItem>
);
}

View File

@@ -0,0 +1,166 @@
import { use } from "react";
import { PermissionsContext } from "/providers/PermissionsContext";
import {
IconBank,
IconBook,
IconBox,
IconCalendar1,
IconCircleProgress,
IconClock,
IconCrossLargeX,
IconFire3,
IconGroup1,
IconInboxEmpty,
IconListStack,
IconMedal,
IconPageTextLine,
IconRotateCw,
IconSettingsGear2,
IconShield,
IconStore,
IconTodo,
SidebarItem,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { useOrganizationId } from "/hooks/useOrganizationId";
export function Sidebar() {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { isAuthorized } = use(PermissionsContext);
const prefix = `/organizations/${organizationId}`;
console.log(isAuthorized("Organization", "listMeetings"));
return (
<ul className="space-y-[2px]">
{isAuthorized("Organization", "listMeetings") && (
<SidebarItem
label={__("Meetings")}
icon={IconCalendar1}
to={`${prefix}/meetings`}
/>
)}
{isAuthorized("Organization", "listTasks") && (
<SidebarItem
label={__("Tasks")}
icon={IconInboxEmpty}
to={`${prefix}/tasks`}
/>
)}
{isAuthorized("Organization", "listMeasures") && (
<SidebarItem
label={__("Measures")}
icon={IconTodo}
to={`${prefix}/measures`}
/>
)}
{isAuthorized("Organization", "listRisks") && (
<SidebarItem
label={__("Risks")}
icon={IconFire3}
to={`${prefix}/risks`}
/>
)}
{isAuthorized("Organization", "listFrameworks") && (
<SidebarItem
label={__("Frameworks")}
icon={IconBank}
to={`${prefix}/frameworks`}
/>
)}
{isAuthorized("Organization", "listPeople") && (
<SidebarItem
label={__("People")}
icon={IconGroup1}
to={`${prefix}/people`}
/>
)}
{isAuthorized("Organization", "listVendors") && (
<SidebarItem
label={__("Vendors")}
icon={IconStore}
to={`${prefix}/vendors`}
/>
)}
{isAuthorized("Organization", "listDocuments") && (
<SidebarItem
label={__("Documents")}
icon={IconPageTextLine}
to={`${prefix}/documents`}
/>
)}
{isAuthorized("Organization", "listAssets") && (
<SidebarItem
label={__("Assets")}
icon={IconBox}
to={`${prefix}/assets`}
/>
)}
{isAuthorized("Organization", "listData") && (
<SidebarItem
label={__("Data")}
icon={IconListStack}
to={`${prefix}/data`}
/>
)}
{isAuthorized("Organization", "listAudits") && (
<SidebarItem
label={__("Audits")}
icon={IconMedal}
to={`${prefix}/audits`}
/>
)}
{isAuthorized("Organization", "listNonconformities") && (
<SidebarItem
label={__("Nonconformities")}
icon={IconCrossLargeX}
to={`${prefix}/nonconformities`}
/>
)}
{isAuthorized("Organization", "listObligations") && (
<SidebarItem
label={__("Obligations")}
icon={IconBook}
to={`${prefix}/obligations`}
/>
)}
{isAuthorized("Organization", "listContinualImprovements") && (
<SidebarItem
label={__("Continual Improvements")}
icon={IconRotateCw}
to={`${prefix}/continual-improvements`}
/>
)}
{isAuthorized("Organization", "listProcessingActivities") && (
<SidebarItem
label={__("Processing Activities")}
icon={IconCircleProgress}
to={`${prefix}/processing-activities`}
/>
)}
{isAuthorized("Organization", "listSnapshots") && (
<SidebarItem
label={__("Snapshots")}
icon={IconClock}
to={`${prefix}/snapshots`}
/>
)}
{isAuthorized("Organization", "getTrustCenter") && (
<SidebarItem
label={__("Trust Center")}
icon={IconShield}
to={`${prefix}/trust-center`}
/>
)}
{isAuthorized("Organization", "listMembers") && (
<SidebarItem
label={__("Settings")}
icon={IconSettingsGear2}
to={`${prefix}/settings`}
/>
)}
</ul>
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<5193da6ee4b5da0bed510c255c3407b1>>
* @generated SignedSource<<acb8bfd444063a884a8a3a35eed8971f>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -56,23 +56,27 @@ return {
"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
}
],
"storageKey": null
"kind": "RequiredField",
"field": {
"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
}
],
"storageKey": null
},
"action": "THROW"
}
],
"type": "Invitation",
@@ -80,6 +84,6 @@ return {
};
})();
(node as any).hash = "1f61b5f07abc69ad33c880bc39cd0e96";
(node as any).hash = "57aedeabc62e474c6e5b995af1d31888";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<30ba0cd2d4738694d58bf346410d4d68>>
* @generated SignedSource<<4660f07fbf06d0125ce01cd54019f389>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -11,7 +11,7 @@
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type MembershipCardFragment$data = {
readonly activeSession: {
readonly lastSession: {
readonly expiresAt: any;
readonly id: string;
} | null | undefined;
@@ -46,7 +46,7 @@ return {
"args": null,
"concreteType": "Session",
"kind": "LinkedField",
"name": "activeSession",
"name": "lastSession",
"plural": false,
"selections": [
(v0/*: any*/),
@@ -61,30 +61,34 @@ return {
"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
"kind": "RequiredField",
"field": {
"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
},
"action": "THROW"
}
],
"type": "Membership",
@@ -92,6 +96,6 @@ return {
};
})();
(node as any).hash = "2145f6030b486447df7b7d9ff798ec06";
(node as any).hash = "5a2532aed755a94df49c2f9ce0929a1f";
export default node;

View File

@@ -1,58 +0,0 @@
/**
* @generated SignedSource<<06aa55abfa21c09c85bb308f813565d8>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type OrganizationCardFragment$data = {
readonly id: string;
readonly logoUrl: string | null | undefined;
readonly name: string;
readonly " $fragmentType": "OrganizationCardFragment";
};
export type OrganizationCardFragment$key = {
readonly " $data"?: OrganizationCardFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"OrganizationCardFragment">;
};
const node: ReaderFragment = {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "OrganizationCardFragment",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "logoUrl",
"storageKey": null
}
],
"type": "Organization",
"abstractKey": null
};
(node as any).hash = "a8b9f4a515650db79f41507fb52f7b96";
export default node;

View File

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

View File

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

View File

@@ -0,0 +1,263 @@
/**
* @generated SignedSource<<2bde2fba4e4897436af08d9dd700b265>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type OrganizationDropdownMenuQuery$variables = Record<PropertyKey, never>;
export type OrganizationDropdownMenuQuery$data = {
readonly viewer: {
readonly memberships: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly organization: {
readonly name: string;
};
readonly " $fragmentSpreads": FragmentRefs<"OrganizationDropdownMenuItemFragment">;
};
}>;
};
};
};
export type OrganizationDropdownMenuQuery = {
response: OrganizationDropdownMenuQuery$data;
variables: OrganizationDropdownMenuQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"kind": "Literal",
"name": "first",
"value": 1000
},
{
"kind": "Literal",
"name": "orderBy",
"value": {
"direction": "DESC",
"field": "CREATED_AT"
}
}
],
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "OrganizationDropdownMenuQuery",
"selections": [
{
"kind": "RequiredField",
"field": {
"alias": null,
"args": null,
"concreteType": "Identity",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
{
"kind": "RequiredField",
"field": {
"alias": null,
"args": (v0/*: any*/),
"concreteType": "MembershipConnection",
"kind": "LinkedField",
"name": "memberships",
"plural": false,
"selections": [
{
"kind": "RequiredField",
"field": {
"alias": null,
"args": null,
"concreteType": "MembershipEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"kind": "RequiredField",
"field": {
"alias": null,
"args": null,
"concreteType": "Membership",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
{
"kind": "RequiredField",
"field": {
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "organization",
"plural": false,
"selections": [
(v2/*: any*/)
],
"storageKey": null
},
"action": "THROW"
},
{
"args": null,
"kind": "FragmentSpread",
"name": "OrganizationDropdownMenuItemFragment"
}
],
"storageKey": null
},
"action": "THROW"
}
],
"storageKey": null
},
"action": "THROW"
}
],
"storageKey": "memberships(first:1000,orderBy:{\"direction\":\"DESC\",\"field\":\"CREATED_AT\"})"
},
"action": "THROW"
}
],
"storageKey": null
},
"action": "THROW"
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [],
"kind": "Operation",
"name": "OrganizationDropdownMenuQuery",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Identity",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
{
"alias": null,
"args": (v0/*: any*/),
"concreteType": "MembershipConnection",
"kind": "LinkedField",
"name": "memberships",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MembershipEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Membership",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "organization",
"plural": false,
"selections": [
(v2/*: any*/),
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "logoUrl",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "Session",
"kind": "LinkedField",
"name": "lastSession",
"plural": false,
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "expiresAt",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "memberships(first:1000,orderBy:{\"direction\":\"DESC\",\"field\":\"CREATED_AT\"})"
},
(v1/*: any*/)
],
"storageKey": null
}
]
},
"params": {
"cacheID": "8338998501d59b0036d1eaf98dff1ffb",
"id": null,
"metadata": {},
"name": "OrganizationDropdownMenuQuery",
"operationKind": "query",
"text": "query OrganizationDropdownMenuQuery {\n viewer {\n memberships(first: 1000, orderBy: {direction: DESC, field: CREATED_AT}) {\n edges {\n node {\n id\n organization {\n name\n id\n }\n ...OrganizationDropdownMenuItemFragment\n }\n }\n }\n id\n }\n}\n\nfragment OrganizationDropdownMenuItemFragment on Membership {\n id\n lastSession {\n id\n expiresAt\n }\n organization {\n logoUrl\n name\n id\n }\n}\n"
}
};
})();
(node as any).hash = "27c82735b5dcef01422d87312c127519";
export default node;

View File

@@ -1,31 +1,41 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { type PropsWithChildren } from "react";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { PermissionsContext, type PermissionsResponse } from "./PermissionsContext";
import { PermissionsContext } from "./PermissionsContext";
import { Role } from "@probo/helpers";
export function PermissionsProvider(props: PropsWithChildren) {
const { children } = props;
const organizationId = useOrganizationId();
// const organizationId = useOrganizationId();
const { data } = useSuspenseQuery<PermissionsResponse>({
queryKey: ["permissions", organizationId],
queryFn: async () => {
const response = await fetch(`/authz/${organizationId}/permissions`, { credentials: "include" });
if (!response.ok) {
throw new Error("Failed to fetch permissions");
}
return response.json() as Promise<PermissionsResponse>;
},
});
// const { data } = useSuspenseQuery<PermissionsResponse>({
// queryKey: ["permissions", organizationId],
// queryFn: async () => {
// const response = await fetch(`/authz/${organizationId}/permissions`, { credentials: "include" });
// if (!response.ok) {
// throw new Error("Failed to fetch permissions");
// }
// return response.json() as Promise<PermissionsResponse>;
// },
// });
// @ts-expect-error wip refactor
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const isAuthorized = (entity: string, action: string) => {
return data.permissions[entity]?.[action] ?? false;
}
// return data.permissions[entity]?.[action] ?? false;
return true;
};
return (
<PermissionsContext value={{ ...data, isAuthorized }}>
<PermissionsContext
value={{ permissions: {}, role: Role.OWNER, isAuthorized }}
>
{children}
</PermissionsContext>
);
// return (
// <PermissionsContext value={{ ...data, isAuthorized }}>
// {children}
// </PermissionsContext>
// );
}

View File

@@ -4,7 +4,6 @@ import {
redirect,
useRouteError,
} from "react-router";
import { MainLayout } from "./layouts/MainLayout";
import { EmployeeLayout } from "./layouts/EmployeeLayout";
import { AuthLayout, CenteredLayout, CenteredLayoutSkeleton } from "@probo/ui";
import { PageSkeleton } from "./components/skeletons/PageSkeleton.tsx";
@@ -49,7 +48,8 @@ import {
UnAuthenticatedError,
UnauthorizedError,
} from "@probo/relay";
import { OrganizationsQuery } from "./pages/iam/organizations/OrganizationsQuery.tsx";
import { OrganizationsPageQuery } from "./pages/iam/organizations/OrganizationsPageQuery.tsx";
import { OrganizationLayoutQuery } from "./pages/iam/organizations/OrganizationLayoutQuery.tsx";
/**
* Top level error boundary
@@ -121,7 +121,7 @@ const routes = [
index: true,
Component: () => (
<RelayEnvironmentProvider environment={connectEnvironment}>
<OrganizationsQuery />
<OrganizationsPageQuery />
</RelayEnvironmentProvider>
),
},
@@ -184,8 +184,8 @@ const routes = [
{
path: "/organizations/:organizationId",
Component: () => (
<RelayEnvironmentProvider environment={consoleEnvironment}>
<MainLayout />
<RelayEnvironmentProvider environment={connectEnvironment}>
<OrganizationLayoutQuery />
</RelayEnvironmentProvider>
),
ErrorBoundary: ErrorBoundary,

View File

@@ -10,15 +10,16 @@ type Props = PropsWithChildren<{
toggle?: ReactNode;
className?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}>;
export const dropdown = tv({
base: "z-50 p-2 shadow-mid min-w-[8rem] bg-level-1 overflow-y-auto overflow-x-hidden rounded-2xl border-border-low",
});
export function Dropdown({ children, toggle, className, open }: Props) {
export function Dropdown({ children, toggle, className, open, onOpenChange }: Props) {
return (
<DropdownMenu.Root open={open}>
<DropdownMenu.Root open={open} onOpenChange={onOpenChange}>
{toggle && (
<DropdownMenu.Trigger asChild>{toggle}</DropdownMenu.Trigger>
)}