Use console layout for employee page
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -1,402 +0,0 @@
|
||||
import { Link, Navigate, Outlet, useParams } from "react-router";
|
||||
import {
|
||||
DropdownSeparator,
|
||||
IconArrowBoxLeft,
|
||||
IconCircleQuestionmark,
|
||||
UserDropdown as UserDropdownRoot,
|
||||
UserDropdownItem,
|
||||
Skeleton,
|
||||
Dropdown,
|
||||
Button,
|
||||
DropdownItem,
|
||||
IconChevronGrabberVertical,
|
||||
IconLock,
|
||||
IconKey,
|
||||
IconPeopleAdd,
|
||||
IconPlusLarge,
|
||||
IconCheckmark1,
|
||||
IconClock,
|
||||
IconMagnifyingGlass,
|
||||
useToast,
|
||||
Logo,
|
||||
Toasts,
|
||||
ConfirmDialog,
|
||||
Avatar,
|
||||
Badge,
|
||||
Input,
|
||||
} from "@probo/ui";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { useLazyLoadQuery } from "react-relay";
|
||||
import type { EmployeeLayoutQuery as EmployeeLayoutQueryType } from "./__generated__/EmployeeLayoutQuery.graphql";
|
||||
import { Suspense, useState, useEffect, useMemo, use } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { PageError } from "/components/PageError";
|
||||
import { PermissionsProvider } from "/providers/PermissionsProvider";
|
||||
import { PermissionsContext } from "/providers/PermissionsContext";
|
||||
|
||||
const EmployeeLayoutQuery = graphql`
|
||||
query EmployeeLayoutQuery($organizationId: ID!) {
|
||||
viewer {
|
||||
id
|
||||
# user {
|
||||
# fullName
|
||||
# email
|
||||
# }
|
||||
}
|
||||
organization: node(id: $organizationId) {
|
||||
... on Organization {
|
||||
id
|
||||
name
|
||||
logoUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function EmployeeLayout() {
|
||||
const { organizationId } = useParams();
|
||||
|
||||
if (!organizationId) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="w-full h-screen" />}>
|
||||
<PermissionsProvider>
|
||||
<EmployeeLayoutContent organizationId={organizationId} />
|
||||
</PermissionsProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function EmployeeLayoutContent({ organizationId }: { organizationId: string }) {
|
||||
const data = useLazyLoadQuery<EmployeeLayoutQueryType>(EmployeeLayoutQuery, {
|
||||
organizationId,
|
||||
});
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Logo className="w-12 h-5" />
|
||||
<svg
|
||||
className="mx-3 text-txt-tertiary"
|
||||
width="8"
|
||||
height="18"
|
||||
viewBox="0 0 8 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M1 17L7 1" stroke="currentColor" />
|
||||
</svg>
|
||||
<div className="mr-auto">
|
||||
<OrganizationSelector currentOrganization={data.organization} />
|
||||
</div>
|
||||
<Suspense fallback={<Skeleton className="w-32 h-8" />}>
|
||||
<UserDropdown />
|
||||
</Suspense>
|
||||
</header>
|
||||
<main className="overflow-y-auto w-full pt-12 h-[calc(100vh-3rem)]">
|
||||
<div className="px-8 pb-8 pt-8">
|
||||
<ErrorBoundary FallbackComponent={PageError}>
|
||||
<Outlet />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</main>
|
||||
<Toasts />
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
logoUrl?: string | null;
|
||||
authenticationMethod: string;
|
||||
authStatus: "authenticated" | "unauthenticated" | "expired";
|
||||
loginUrl: string;
|
||||
}
|
||||
|
||||
interface OrganizationsResponse {
|
||||
organizations: Organization[];
|
||||
}
|
||||
|
||||
interface Invitation {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
role: string;
|
||||
expiresAt: string;
|
||||
acceptedAt?: string | null;
|
||||
createdAt: string;
|
||||
organization: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface InvitationsResponse {
|
||||
invitations: Invitation[];
|
||||
}
|
||||
|
||||
function OrganizationSelector({
|
||||
currentOrganization,
|
||||
}: {
|
||||
currentOrganization: EmployeeLayoutQueryType["response"]["organization"];
|
||||
}) {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [pendingInvitationsCount, setPendingInvitationsCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const filteredOrganizations = useMemo(() => {
|
||||
if (!search.trim()) {
|
||||
return organizations;
|
||||
}
|
||||
return organizations.filter((org) =>
|
||||
org.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}, [organizations, search]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const [orgsResponse, invitationsResponse] = await Promise.all([
|
||||
fetch("/connect/organizations", { credentials: "include" }),
|
||||
fetch("/connect/invitations", { credentials: "include" }),
|
||||
]);
|
||||
|
||||
if (!orgsResponse.ok) {
|
||||
throw new Error("Failed to fetch organizations");
|
||||
}
|
||||
|
||||
if (!invitationsResponse.ok) {
|
||||
throw new Error("Failed to fetch invitations");
|
||||
}
|
||||
|
||||
const orgsData: OrganizationsResponse = await orgsResponse.json();
|
||||
const invitationsData: InvitationsResponse =
|
||||
await invitationsResponse.json();
|
||||
|
||||
const pendingCount = invitationsData.invitations.filter(
|
||||
(inv) => !inv.acceptedAt
|
||||
).length;
|
||||
|
||||
setOrganizations(orgsData.organizations);
|
||||
setPendingInvitationsCount(pendingCount);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
console.error("Failed to fetch data:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button className="-ml-3" variant="tertiary" disabled>
|
||||
{__("Error loading organizations")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Dropdown
|
||||
toggle={
|
||||
<Button
|
||||
className="-ml-3"
|
||||
variant="tertiary"
|
||||
iconAfter={IconChevronGrabberVertical}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? __("Loading...") : currentOrganization?.name || ""}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{!isLoading && organizations.length > 0 && (
|
||||
<div className="px-3 py-2 border-b border-border-low">
|
||||
<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">
|
||||
{isLoading ? (
|
||||
<div className="px-3 py-2 text-gray-500">
|
||||
{__("Loading organizations...")}
|
||||
</div>
|
||||
) : filteredOrganizations.length === 0 ? (
|
||||
<div className="px-3 py-2 text-txt-secondary">
|
||||
{__("No organizations found")}
|
||||
</div>
|
||||
) : (
|
||||
filteredOrganizations.map((organization) => {
|
||||
const isAuthenticated =
|
||||
organization.authStatus === "authenticated";
|
||||
const isExpired = organization.authStatus === "expired";
|
||||
const needsAuth = organization.authStatus === "unauthenticated";
|
||||
|
||||
const targetUrl = isAuthenticated
|
||||
? `/organizations/${organization.id}`
|
||||
: organization.loginUrl;
|
||||
|
||||
const isSAMLUrl = targetUrl.includes("/connect/saml/");
|
||||
|
||||
const logoUrl = organization.logoUrl;
|
||||
|
||||
return (
|
||||
<DropdownItem asChild key={organization.id}>
|
||||
{isSAMLUrl ? (
|
||||
<a href={targetUrl} className="flex items-center gap-2">
|
||||
<Avatar name={organization.name} src={logoUrl} />
|
||||
<span className="flex-1">{organization.name}</span>
|
||||
{isAuthenticated && (
|
||||
<IconCheckmark1 size={16} className="text-green-600" />
|
||||
)}
|
||||
{isExpired && (
|
||||
<IconClock size={16} className="text-orange-600" />
|
||||
)}
|
||||
{needsAuth && (
|
||||
<IconLock size={16} className="text-gray-400" />
|
||||
)}
|
||||
</a>
|
||||
) : (
|
||||
<Link to={targetUrl} className="flex items-center gap-2">
|
||||
<Avatar name={organization.name} src={logoUrl} />
|
||||
<span className="flex-1">{organization.name}</span>
|
||||
{isAuthenticated && (
|
||||
<IconCheckmark1 size={16} className="text-green-600" />
|
||||
)}
|
||||
{isExpired && (
|
||||
<IconClock size={16} className="text-orange-600" />
|
||||
)}
|
||||
{needsAuth && (
|
||||
<IconLock size={16} className="text-gray-400" />
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</DropdownItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<DropdownSeparator />
|
||||
{pendingInvitationsCount > 0 && (
|
||||
<DropdownItem asChild>
|
||||
<Link to="/">
|
||||
<IconPeopleAdd size={16} />
|
||||
<span className="flex-1">{__("Invitations")}</span>
|
||||
<Badge variant="info" size="sm">
|
||||
{pendingInvitationsCount}
|
||||
</Badge>
|
||||
</Link>
|
||||
</DropdownItem>
|
||||
)}
|
||||
<DropdownItem asChild>
|
||||
<Link to="/organizations/new">
|
||||
<IconPlusLarge size={16} />
|
||||
{__("Add organization")}
|
||||
</Link>
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
{pendingInvitationsCount > 0 && (
|
||||
<Link to="/" className="relative" title={__("Invitations")}>
|
||||
<Button variant="tertiary" icon={IconPeopleAdd} />
|
||||
<Badge
|
||||
variant="info"
|
||||
size="sm"
|
||||
className="absolute -top-1 -right-1 min-w-[20px] h-5 flex items-center justify-center"
|
||||
>
|
||||
{pendingInvitationsCount}
|
||||
</Badge>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDropdown() {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const { isAuthorized } = use(PermissionsContext);
|
||||
const user = {
|
||||
fullName: "",
|
||||
email: "",
|
||||
};
|
||||
// const user = useLazyLoadQuery<EmployeeLayoutQueryType>(EmployeeLayoutQuery, {
|
||||
// organizationId,
|
||||
// }).viewer.user;
|
||||
|
||||
const handleLogout: React.MouseEventHandler<HTMLAnchorElement> = async (
|
||||
e
|
||||
) => {
|
||||
e.preventDefault();
|
||||
|
||||
fetch("/connect/logout", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || __("Failed to login"));
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: e.message as string,
|
||||
variant: "error",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<UserDropdownRoot fullName={user.fullName} email={user.email}>
|
||||
{isAuthorized("Organization", "deleteOrganization") && (
|
||||
<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}
|
||||
/>
|
||||
</UserDropdownRoot>
|
||||
);
|
||||
}
|
||||
@@ -28,20 +28,18 @@ export const employeeDocumentsPageQuery = graphql`
|
||||
export function EmployeeDocumentsPage(props: {
|
||||
queryRef: PreloadedQuery<EmployeeDocumentsPageQuery>;
|
||||
}) {
|
||||
const { queryRef } = props;
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
|
||||
const { queryRef } = props;
|
||||
const {
|
||||
viewer: {
|
||||
signableDocuments: { edges: initialDocuments },
|
||||
},
|
||||
viewer: { signableDocuments },
|
||||
} = usePreloadedQuery<EmployeeDocumentsPageQuery>(
|
||||
employeeDocumentsPageQuery,
|
||||
queryRef
|
||||
);
|
||||
|
||||
const documents = initialDocuments.map((edge) => edge.node).filter(Boolean);
|
||||
const documents = signableDocuments.edges.map((edge) => edge.node);
|
||||
|
||||
usePageTitle(__("Documents"));
|
||||
|
||||
|
||||
@@ -22,20 +22,18 @@ function EmployeeDocumentsPageLoader() {
|
||||
}, [loadQuery, organizationId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <PageSkeleton />;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<EmployeeDocumentsPage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
return <EmployeeDocumentsPage queryRef={queryRef} />;
|
||||
}
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<CoreRelayProvider>
|
||||
<EmployeeDocumentsPageLoader />
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<EmployeeDocumentsPageLoader />
|
||||
</Suspense>
|
||||
</CoreRelayProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function DomainSettingsPage(props: {
|
||||
|
||||
const { organization } = usePreloadedQuery<DomainSettingsPageQuery>(
|
||||
domainSettingsPageQuery,
|
||||
queryRef,
|
||||
queryRef
|
||||
);
|
||||
if (organization.__typename !== "Organization") {
|
||||
throw new Error("invalid type for node");
|
||||
@@ -49,7 +49,7 @@ export function DomainSettingsPage(props: {
|
||||
</h3>
|
||||
<p className="text-txt-tertiary mb-4">
|
||||
{__(
|
||||
"Add your own domain to make your trust center more professional",
|
||||
"Add your own domain to make your trust center more professional"
|
||||
)}
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
redirect,
|
||||
useRouteError,
|
||||
} from "react-router";
|
||||
import { EmployeeLayout } from "./layouts/EmployeeLayout.tsx";
|
||||
import { CenteredLayout, CenteredLayoutSkeleton } from "@probo/ui";
|
||||
import { PageSkeleton } from "./components/skeletons/PageSkeleton.tsx";
|
||||
import { riskRoutes } from "./routes/riskRoutes.ts";
|
||||
@@ -27,7 +26,6 @@ import { continualImprovementRoutes } from "./routes/continualImprovementRoutes.
|
||||
import { rightsRequestRoutes } from "./routes/rightsRequestRoutes.ts";
|
||||
import { processingActivityRoutes } from "./routes/processingActivityRoutes.ts";
|
||||
import { statesOfApplicabilityRoutes } from "./routes/statesOfApplicabilityRoutes.ts";
|
||||
import { CoreRelayProvider } from "./providers/CoreRelayProvider.tsx";
|
||||
import { lazy } from "@probo/react-lazy";
|
||||
import { routeFromAppRoute, type AppRoute } from "@probo/routes";
|
||||
import { Role } from "@probo/helpers";
|
||||
@@ -121,15 +119,15 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: "/organizations/:organizationId/employee",
|
||||
Component: () => (
|
||||
<CoreRelayProvider>
|
||||
<EmployeeLayout />
|
||||
</CoreRelayProvider>
|
||||
Fallback: () => "fallback employee...",
|
||||
Component: lazy(
|
||||
() => import("./pages/iam/memberships/MembershipLayoutLoader")
|
||||
),
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
index: true,
|
||||
// Component: () => "hello world",
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("./pages/organizations/employee/EmployeeDocumentsPageLoader")
|
||||
|
||||
Reference in New Issue
Block a user