mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 02:53:32 +10:00
212 lines
7.9 KiB
TypeScript
212 lines
7.9 KiB
TypeScript
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
|
import { getSettingsNavGroups, type SettingsNavGroup, type SettingsNavItem } from '@documenso/lib/utils/settings-nav';
|
|
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
|
import { Button } from '@documenso/ui/primitives/button';
|
|
import type { MessageDescriptor } from '@lingui/core';
|
|
import { msg } from '@lingui/core/macro';
|
|
import { useLingui } from '@lingui/react';
|
|
import { Trans } from '@lingui/react/macro';
|
|
import { useEffect, useRef } from 'react';
|
|
import { Link, Outlet, useLocation } from 'react-router';
|
|
import { match } from 'ts-pattern';
|
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
|
import { SettingsScopeBreadcrumb } from './settings-scope-breadcrumb';
|
|
import { UnifiedSettingsSidebar } from './unified-settings-sidebar';
|
|
import { UnifiedSettingsSidebarMobile } from './unified-settings-sidebar-mobile';
|
|
|
|
export type UnifiedSettingsScope = 'organisation' | 'team' | 'account';
|
|
|
|
export type UnifiedSettingsLayoutProps = {
|
|
activeScope: UnifiedSettingsScope;
|
|
|
|
/**
|
|
* The team the user last worked in, read from the `preferred-team-url` cookie by the
|
|
* layout's loader. Used to keep the sidebar's team switcher stable at organisation and
|
|
* account scope, where the URL carries no team.
|
|
*/
|
|
preferredTeamUrl?: string | null;
|
|
};
|
|
|
|
/**
|
|
* Walk a group's items and find the item (and its parent if it's a sub-nav child)
|
|
* that matches the current pathname most specifically. Returns the labels to use
|
|
* as breadcrumb crumbs (e.g. ['Preferences', 'Document'] or just ['Members']).
|
|
*/
|
|
const findActiveCrumbs = (group: SettingsNavGroup | null, pathname: string): MessageDescriptor[] => {
|
|
if (!group) {
|
|
return [];
|
|
}
|
|
|
|
let bestMatch: SettingsNavItem | null = null;
|
|
|
|
for (const item of group.items) {
|
|
if (item.isSubNavParent) {
|
|
continue;
|
|
}
|
|
|
|
if (pathname === item.path || pathname.startsWith(`${item.path}/`)) {
|
|
if (!bestMatch || item.path.length > bestMatch.path.length) {
|
|
bestMatch = item;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!bestMatch) {
|
|
return [];
|
|
}
|
|
|
|
if (bestMatch.isSubNav) {
|
|
const parent = group.items.find((it) => it.isSubNavParent);
|
|
return parent ? [parent.label, bestMatch.label] : [bestMatch.label];
|
|
}
|
|
|
|
return [bestMatch.label];
|
|
};
|
|
|
|
export const UnifiedSettingsLayout = ({ activeScope, preferredTeamUrl = null }: UnifiedSettingsLayoutProps) => {
|
|
const { _ } = useLingui();
|
|
const { organisations } = useSession();
|
|
const { pathname } = useLocation();
|
|
|
|
const currentOrganisation = useOptionalCurrentOrganisation();
|
|
const team = useOptionalCurrentTeam();
|
|
|
|
const contentPaneRef = useRef<HTMLElement>(null);
|
|
|
|
// Scroll back to the top when navigating between settings pages.
|
|
useEffect(() => {
|
|
contentPaneRef.current?.scrollTo(0, 0);
|
|
}, [pathname]);
|
|
|
|
// An organisation is worth showing in the sidebar if it has settings the user can reach —
|
|
// either the organisation's own, or those of a team inside it.
|
|
const hasReachableSettings = (org: (typeof organisations)[number]) =>
|
|
canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) ||
|
|
org.teams.some((t) => canExecuteTeamAction('MANAGE_TEAM', t.currentTeamRole));
|
|
|
|
const organisation =
|
|
currentOrganisation ??
|
|
organisations.find((org) => org.teams.some((t) => t.url === preferredTeamUrl) && hasReachableSettings(org)) ??
|
|
organisations.find(hasReachableSettings) ??
|
|
null;
|
|
|
|
const manageableTeams = organisation?.teams.filter((t) => canExecuteTeamAction('MANAGE_TEAM', t.currentTeamRole));
|
|
|
|
const teamForSidebar =
|
|
team ?? manageableTeams?.find((t) => t.url === preferredTeamUrl) ?? manageableTeams?.[0] ?? null;
|
|
|
|
const sidebarTeamUrl = teamForSidebar?.url ?? null;
|
|
|
|
// Sync the selected team URL in the sidebar into the preferred team URL cookie.
|
|
useEffect(() => {
|
|
if (!sidebarTeamUrl) {
|
|
return;
|
|
}
|
|
|
|
const body = new FormData();
|
|
|
|
body.append('teamUrl', sidebarTeamUrl);
|
|
|
|
void fetch('/api/preferred-team', { method: 'POST', body });
|
|
}, [sidebarTeamUrl]);
|
|
|
|
const groups = getSettingsNavGroups({
|
|
organisation: organisation
|
|
? {
|
|
url: organisation.url,
|
|
currentOrganisationRole: organisation.currentOrganisationRole,
|
|
organisationClaim: organisation.organisationClaim,
|
|
}
|
|
: null,
|
|
team: teamForSidebar ? { url: teamForSidebar.url, currentTeamRole: teamForSidebar.currentTeamRole } : null,
|
|
hasManageableBillingOrgs: organisations.some((org) =>
|
|
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
|
|
),
|
|
});
|
|
|
|
const canManageOrg =
|
|
organisation !== null && canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole);
|
|
|
|
// Must be derived from the team in the URL context, NOT from `teamForSidebar` — the
|
|
// latter falls back to any manageable team in the org, which would let a team manager
|
|
// through the organisation-scope guard (and `useOptionalCurrentTeam()` resolves for any
|
|
// member regardless of role, which would let a plain member through the team guard).
|
|
const canManageCurrentTeam = team !== null && canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole);
|
|
|
|
// Account pages are available to every user. The organisation and team scopes each
|
|
// require the manage permission for THAT scope — they are not interchangeable.
|
|
const isAuthorised = match(activeScope)
|
|
.with('account', () => true)
|
|
.with('organisation', () => canManageOrg)
|
|
.with('team', () => canManageCurrentTeam)
|
|
.exhaustive();
|
|
|
|
if (!isAuthorised) {
|
|
return (
|
|
<GenericErrorLayout
|
|
errorCode={401}
|
|
errorCodeMap={{
|
|
401: {
|
|
heading: msg`Unauthorized`,
|
|
subHeading: msg`401 Unauthorized`,
|
|
message: msg`You are not authorized to access this page.`,
|
|
},
|
|
}}
|
|
primaryButton={
|
|
<Button asChild>
|
|
<Link to="/settings">
|
|
<Trans>Go to your settings</Trans>
|
|
</Link>
|
|
</Button>
|
|
}
|
|
secondaryButton={null}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const scopeName = match(activeScope)
|
|
.with('account', () => _(msg`Account`))
|
|
.with('organisation', () => organisation?.name ?? '')
|
|
.with('team', () => team?.name ?? organisation?.name ?? '')
|
|
.exhaustive();
|
|
|
|
const activeGroup =
|
|
activeScope === 'account' ? groups.account : activeScope === 'organisation' ? groups.organisation : groups.team;
|
|
|
|
const crumbs = findActiveCrumbs(activeGroup, pathname).map((label) => _(label));
|
|
|
|
return (
|
|
<div className="flex flex-col md:min-h-0 md:flex-1 md:flex-row">
|
|
<aside className="hover-scrollbar w-full shrink-0 border-b bg-background md:w-80 md:overflow-y-auto md:border-r md:border-b-0">
|
|
<div className="flex h-full flex-col">
|
|
<div className="hidden md:block">
|
|
<UnifiedSettingsSidebar
|
|
groups={groups}
|
|
currentOrgUrl={organisation?.url ?? null}
|
|
currentTeamUrl={teamForSidebar?.url ?? null}
|
|
/>
|
|
</div>
|
|
<div className="md:hidden">
|
|
<UnifiedSettingsSidebarMobile
|
|
groups={groups}
|
|
activeScope={activeScope}
|
|
currentOrgUrl={organisation?.url ?? null}
|
|
currentTeamUrl={teamForSidebar?.url ?? null}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<main ref={contentPaneRef} className="relative flex-1 px-4 md:overflow-y-auto md:px-12 lg:px-16">
|
|
<div className="mx-auto w-full max-w-3xl py-6 md:py-8" data-testid="unified-settings-content">
|
|
<SettingsScopeBreadcrumb scope={activeScope} scopeName={scopeName} crumbs={crumbs} />
|
|
<Outlet />
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|