feat: unify settings (#3128)

This commit is contained in:
David Nguyen
2026-08-09 16:00:55 +10:00
committed by GitHub
parent f0ab7c112e
commit d6cf3fec4b
120 changed files with 4181 additions and 1859 deletions
@@ -1,5 +1,3 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { getRootHref } from '@documenso/lib/utils/params';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -14,16 +12,16 @@ import { BrandingLogo } from '~/components/general/branding-logo';
import { AppCommandMenu } from './app-command-menu';
import { AppNavDesktop } from './app-nav-desktop';
import { AppNavMobile } from './app-nav-mobile';
import { MenuSwitcher } from './menu-switcher';
import { OrgMenuSwitcher } from './org-menu-switcher';
export type HeaderProps = HTMLAttributes<HTMLDivElement>;
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
/** Span the full viewport width instead of the centered max-w-screen-xl container. */
fullWidth?: boolean;
};
export const Header = ({ className, ...props }: HeaderProps) => {
export const Header = ({ className, fullWidth = false, ...props }: HeaderProps) => {
const params = useParams();
const { organisations } = useSession();
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
const [scrollY, setScrollY] = useState(0);
@@ -51,12 +49,18 @@ export const Header = ({ className, ...props }: HeaderProps) => {
<header
className={cn(
'sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent bg-background/95 backdrop-blur duration-200 supports-backdrop-blur:bg-background/60',
scrollY > 5 && 'border-b-border',
(scrollY > 5 || fullWidth) && 'border-b-border',
className,
)}
{...props}
>
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
<div
className={cn(
'mx-auto flex w-full items-center justify-between gap-x-4 px-4 md:justify-normal',
fullWidth ? 'md:px-6' : 'max-w-screen-xl md:px-8',
)}
data-testid="app-header-container"
>
<Link
to={getRootHref(params)}
className="hidden rounded-md ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 md:inline"
@@ -78,7 +82,9 @@ export const Header = ({ className, ...props }: HeaderProps) => {
</Link>
</Button>
<div className="md:ml-4">{isPersonalLayout(organisations) ? <MenuSwitcher /> : <OrgMenuSwitcher />}</div>
<div className="md:ml-4">
<OrgMenuSwitcher />
</div>
<div className="flex flex-row items-center space-x-4 md:hidden">
<button onClick={() => setIsCommandMenuOpen(true)}>
@@ -1,5 +1,4 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
@@ -37,8 +36,8 @@ export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: App
const menuNavigationLinks = useMemo(() => {
let teamUrl = currentTeam?.url || null;
if (!teamUrl && isPersonalLayout(organisations)) {
teamUrl = organisations[0].teams[0]?.url || null;
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
teamUrl = organisations[0].teams[0].url;
}
if (!teamUrl) {
@@ -1,7 +1,6 @@
import LogoImage from '@documenso/assets/logo.png';
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
@@ -40,8 +39,8 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
const menuNavigationLinks = useMemo(() => {
let teamUrl = currentTeam?.url || null;
if (!teamUrl && isPersonalLayout(organisations)) {
teamUrl = organisations[0].teams[0]?.url || null;
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
teamUrl = organisations[0].teams[0].url;
}
if (!teamUrl) {
@@ -317,7 +317,6 @@ export const IndividualPersonalLayoutCheckoutButton = ({
const createSubscriptionResponse = await createSubscription({
organisationId: organisations[0].id,
priceId,
isPersonalLayoutMode: true,
});
window.location.href = createSubscriptionResponse.redirectUrl;
@@ -1,104 +0,0 @@
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
import { cn } from '@documenso/ui/lib/utils';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ChevronsUpDown, Plus } from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
export const MenuSwitcher = () => {
const { _ } = useLingui();
const { user } = useSession();
const [languageSwitcherOpen, setLanguageSwitcherOpen] = useState(false);
const isUserAdmin = isAdmin(user);
const formatAvatarFallback = (name?: string) => {
if (name !== undefined) {
return name.slice(0, 1).toUpperCase();
}
return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="menu-switcher"
variant="none"
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
>
<AvatarWithText
avatarSrc={formatAvatarUrl(user.avatarImageId)}
avatarFallback={formatAvatarFallback(user.name || user.email)}
primaryText={user.name}
secondaryText={_(msg`Personal Account`)}
rightSideComponent={<ChevronsUpDown className="ml-auto h-4 w-4 text-muted-foreground" />}
textSectionClassName="hidden lg:flex"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className={cn('z-[60] ml-6 w-full min-w-[12rem] md:ml-0')} align="end" forceMount>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/settings/organisations?action=add-organisation" className="flex items-center justify-between">
<Trans>Create Organisation</Trans>
<Plus className="ml-2 h-4 w-4" />
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{isUserAdmin && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/admin">
<Trans>Admin panel</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/inbox">
<Trans>Personal Inbox</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/settings/profile">
<Trans>User settings</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" onClick={() => setLanguageSwitcherOpen(true)}>
<Trans>Language</Trans>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:!text-destructive px-4 py-2 text-destructive/90"
onSelect={async () => authClient.signOut()}
>
<Trans>Sign Out</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
<LanguageSwitcherDialog open={languageSwitcherOpen} setOpen={setLanguageSwitcherOpen} />
</DropdownMenu>
);
};
@@ -1,6 +1,7 @@
import { authClient } from '@documenso/auth/client';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
@@ -55,6 +56,12 @@ export const OrgMenuSwitcher = () => {
const currentOrganisation = useOptionalCurrentOrganisation();
const currentTeam = useOptionalCurrentTeam();
const canAccessOrganisationSettings =
currentOrganisation &&
canExecuteOrganisationAction('MANAGE_ORGANISATION', currentOrganisation.currentOrganisationRole);
const canAccessTeamSettings = currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole);
// Use hovered org for teams display if available,
// otherwise use current team's org if in a team,
// finally fallback to selected org
@@ -258,26 +265,23 @@ export const OrgMenuSwitcher = () => {
</DropdownMenuItem>
)}
{currentOrganisation &&
canExecuteOrganisationAction('MANAGE_ORGANISATION', currentOrganisation.currentOrganisationRole) && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to={`/o/${currentOrganisation.url}/settings`}>
<Trans>Organisation settings</Trans>
</Link>
</DropdownMenuItem>
)}
{currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to={`/t/${currentTeam.url}/settings`}>
<Trans>Team settings</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/inbox">
<Trans>Personal Inbox</Trans>
<Trans>Inbox</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link
to={
canAccessOrganisationSettings
? `/o/${currentOrganisation?.url}/settings`
: canAccessTeamSettings
? `/t/${currentTeam?.url}/settings`
: '/settings'
}
>
<Trans>Settings</Trans>
</Link>
</DropdownMenuItem>
@@ -287,6 +291,14 @@ export const OrgMenuSwitcher = () => {
</Link>
</DropdownMenuItem>
{IS_BILLING_ENABLED() && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/settings/billing">
<Trans>Billing</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="px-4 py-2 text-muted-foreground"
onClick={() => setLanguageSwitcherOpen(true)}
@@ -1,6 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -13,8 +12,6 @@ export type OrganisationBillingPortalButtonProps = {
};
export const OrganisationBillingPortalButton = ({ buttonProps }: OrganisationBillingPortalButtonProps) => {
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const { _ } = useLingui();
@@ -28,11 +25,10 @@ export const OrganisationBillingPortalButton = ({ buttonProps }: OrganisationBil
try {
const { redirectUrl } = await manageSubscription({
organisationId: organisation.id,
isPersonalLayoutMode: isPersonalLayout(organisations),
});
window.open(redirectUrl, '_blank');
} catch (err) {
} catch (_err) {
toast({
title: _(msg`Something went wrong`),
description: _(
@@ -12,9 +12,9 @@ export type SettingsHeaderProps = {
export const SettingsHeader = ({ children, title, subtitle, className, hideDivider }: SettingsHeaderProps) => {
return (
<>
<div className={cn('flex flex-row items-center justify-between', className)}>
<div className={cn('mb-4 flex flex-row items-center justify-between', className)}>
<div>
<h3 className="font-medium text-lg">{title}</h3>
<h2 className="font-bold text-xl">{title}</h2>
<p className="text-muted-foreground text-sm md:mt-2">{subtitle}</p>
</div>
@@ -22,7 +22,7 @@ export const SettingsHeader = ({ children, title, subtitle, className, hideDivid
{children}
</div>
{!hideDivider && <hr className="my-4" />}
{!hideDivider && <hr className="mb-4" />}
</>
);
};
@@ -1,137 +0,0 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { BracesIcon, CreditCardIcon, Globe2Icon, Lock, Settings2Icon, User, Users, WebhookIcon } from 'lucide-react';
import type { HTMLAttributes } from 'react';
import { Link, useLocation } from 'react-router';
export type SettingsDesktopNavProps = HTMLAttributes<HTMLDivElement>;
export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavProps) => {
const { pathname } = useLocation();
const { organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const hasManageableBillingOrgs = organisations.some((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
);
return (
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
<Link to="/settings/profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/profile') && 'bg-secondary')}
>
<User className="mr-2 h-5 w-5" />
<Trans>Profile</Trans>
</Button>
</Link>
{isPersonalLayoutMode && (
<>
<Link to="/settings/document">
<Button variant="ghost" className={cn('w-full justify-start')}>
<Settings2Icon className="mr-2 h-5 w-5" />
<Trans>Preferences</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/document">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/document') && 'bg-secondary')}
>
<Trans>Document</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/branding">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/branding') && 'bg-secondary')}
>
<Trans>Branding</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/email">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/email') && 'bg-secondary')}
>
<Trans>Email</Trans>
</Button>
</Link>
<Link to="/settings/public-profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/public-profile') && 'bg-secondary')}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
<Link to="/settings/tokens">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/tokens') && 'bg-secondary')}
>
<BracesIcon className="mr-2 h-5 w-5" />
<Trans>API Tokens</Trans>
</Button>
</Link>
<Link to="/settings/webhooks">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/webhooks') && 'bg-secondary')}
>
<WebhookIcon className="mr-2 h-5 w-5" />
<Trans>Webhooks</Trans>
</Button>
</Link>
</>
)}
<Link to="/settings/organisations">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/organisations') && 'bg-secondary')}
>
<Users className="mr-2 h-5 w-5" />
<Trans>Organisations</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
<Link to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/billing') && 'bg-secondary')}
>
<CreditCardIcon className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
<Link to="/settings/security">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/security') && 'bg-secondary')}
>
<Lock className="mr-2 h-5 w-5" />
<Trans>Security</Trans>
</Button>
</Link>
</div>
);
};
@@ -1,144 +0,0 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import {
BracesIcon,
CreditCardIcon,
Globe2Icon,
Lock,
MailIcon,
PaletteIcon,
Settings2Icon,
User,
Users,
WebhookIcon,
} from 'lucide-react';
import type { HTMLAttributes } from 'react';
import { Link, useLocation } from 'react-router';
export type SettingsMobileNavProps = HTMLAttributes<HTMLDivElement>;
export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProps) => {
const { pathname } = useLocation();
const { organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const hasManageableBillingOrgs = organisations.some((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
);
return (
<div className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)} {...props}>
<Link to="/settings/profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/profile') && 'bg-secondary')}
>
<User className="mr-2 h-5 w-5" />
<Trans>Profile</Trans>
</Button>
</Link>
{isPersonalLayoutMode && (
<>
<Link to="/settings/document">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/document') && 'bg-secondary')}
>
<Settings2Icon className="mr-2 h-5 w-5" />
<Trans>Document Preferences</Trans>
</Button>
</Link>
<Link to="/settings/branding">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/branding') && 'bg-secondary')}
>
<PaletteIcon className="mr-2 h-5 w-5" />
<Trans>Branding Preferences</Trans>
</Button>
</Link>
<Link to="/settings/email">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/email') && 'bg-secondary')}
>
<MailIcon className="mr-2 h-5 w-5" />
<Trans>Email Preferences</Trans>
</Button>
</Link>
<Link to="/settings/public-profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/public-profile') && 'bg-secondary')}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
<Link to="/settings/tokens">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/tokens') && 'bg-secondary')}
>
<BracesIcon className="mr-2 h-5 w-5" />
<Trans>API Tokens</Trans>
</Button>
</Link>
<Link to="/settings/webhooks">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/webhooks') && 'bg-secondary')}
>
<WebhookIcon className="mr-2 h-5 w-5" />
<Trans>Webhooks</Trans>
</Button>
</Link>
</>
)}
<Link to="/settings/organisations">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/organisations') && 'bg-secondary')}
>
<Users className="mr-2 h-5 w-5" />
<Trans>Organisations</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
<Link to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/billing') && 'bg-secondary')}
>
<CreditCardIcon className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
<Link to="/settings/security">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/security') && 'bg-secondary')}
>
<Lock className="mr-2 h-5 w-5" />
<Trans>Security</Trans>
</Button>
</Link>
</div>
);
};
@@ -0,0 +1,200 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { type INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { getSettingsNavGroups } from '@documenso/lib/utils/settings-nav';
import { computeSwitcherContinuityPath } from '@documenso/lib/utils/settings-switcher';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { cn } from '@documenso/ui/lib/utils';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Trans, useLingui } from '@lingui/react/macro';
import { ChevronsUpDownIcon, PlusIcon, SearchIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router';
const SEARCH_THRESHOLD = 5;
export type SettingsOrgSwitcherProps = {
currentOrgUrl: string;
};
export const SettingsOrgSwitcher = ({ currentOrgUrl }: SettingsOrgSwitcherProps) => {
const { t } = useLingui();
const { organisations } = useSession();
const { pathname } = useLocation();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const manageableOrgs = useMemo(
() =>
organisations.filter(
(org) =>
canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) ||
org.teams.some((team) => canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)),
),
[organisations],
);
const currentOrg = manageableOrgs.find((org) => org.url === currentOrgUrl);
const hasManageableBillingOrgs = useMemo(
() => organisations.some((org) => canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole)),
[organisations],
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) {
return manageableOrgs;
}
return manageableOrgs.filter((org) => org.name.toLowerCase().includes(q));
}, [manageableOrgs, query]);
const isBillingEnabled = IS_BILLING_ENABLED();
const handleSelect = (orgUrl: string) => {
const destinationOrg = manageableOrgs.find((org) => org.url === orgUrl);
if (!destinationOrg) {
return;
}
const manageableTeam = destinationOrg.teams.find((team) =>
canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole),
);
const destinationGroups = getSettingsNavGroups({
organisation: {
url: destinationOrg.url,
currentOrganisationRole: destinationOrg.currentOrganisationRole,
organisationClaim: destinationOrg.organisationClaim,
},
team: manageableTeam ? { url: manageableTeam.url, currentTeamRole: manageableTeam.currentTeamRole } : null,
hasManageableBillingOrgs,
});
// The list also contains organisations the user can only reach through a team they
// manage — for those `getSettingsNavGroups` returns no organisation group, so we land
// them in the team group instead of on an organisation page they aren't authorised for.
const destinationGroup = destinationGroups.organisation ?? destinationGroups.team;
if (!destinationGroup) {
return;
}
setIsOpen(false);
void navigate(
computeSwitcherContinuityPath({
currentPath: pathname,
destinationPaths: destinationGroup.items.map((item) => item.path),
fallbackPath: destinationGroup.items[0].path,
}),
);
};
if (!currentOrg) {
return null;
}
// Resolve an organisation's plan label. Unknown or custom claims (including
// self-hosted custom claim IDs) fall back to "Custom Plan".
const getPlanName = (organisationClaimId: string | null) => {
const planClaim =
organisationClaimId && organisationClaimId in internalClaims
? internalClaims[organisationClaimId as INTERNAL_CLAIM_ID]
: undefined;
return planClaim ? t`${planClaim.name} Plan` : t`Custom Plan`;
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
data-testid="settings-org-switcher-trigger"
className="flex h-auto w-full items-center justify-start gap-2 rounded-lg border bg-background px-1.5 py-1 hover:bg-muted"
>
<AvatarWithText
className="max-w-none"
avatarClass="h-8 w-8"
avatarSrc={formatAvatarUrl(currentOrg.avatarImageId)}
avatarFallback={currentOrg.name.slice(0, 1).toUpperCase()}
primaryText={<span className="font-semibold text-muted-foreground">{currentOrg.name}</span>}
secondaryText={
isBillingEnabled ? getPlanName(currentOrg.organisationClaim.originalSubscriptionClaimId) : undefined
}
rightSideComponent={<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 text-muted-foreground" />}
/>
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
data-testid="settings-org-switcher-content"
>
{manageableOrgs.length >= SEARCH_THRESHOLD && (
<div className="border-b p-2">
<div className="relative">
<SearchIcon className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t`Search organisations…`}
className="h-8 pl-7"
data-testid="settings-org-switcher-search"
/>
</div>
</div>
)}
<ul className="max-h-72 space-y-1 overflow-auto p-1">
{filtered.map((org) => {
const isCurrent = org.url === currentOrgUrl;
return (
<li key={org.id}>
<button
type="button"
onClick={() => handleSelect(org.url)}
className={cn(
'flex w-full items-center rounded-md px-2 py-2 text-left hover:bg-muted',
isCurrent && 'bg-muted',
)}
data-testid={`settings-org-switcher-item-${org.url}`}
>
<AvatarWithText
avatarClass="h-8 w-8"
avatarSrc={formatAvatarUrl(org.avatarImageId)}
avatarFallback={org.name.slice(0, 1).toUpperCase()}
primaryText={<span className={cn(isCurrent && 'font-semibold')}>{org.name}</span>}
secondaryText={
isBillingEnabled ? getPlanName(org.organisationClaim.originalSubscriptionClaimId) : undefined
}
/>
</button>
</li>
);
})}
</ul>
<div className="border-t p-1">
<Button variant="ghost" asChild className="w-full justify-start" data-testid="settings-org-switcher-create">
<a href="/settings/organisations?action=add-organisation">
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Create organisation</Trans>
</a>
</Button>
</div>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,58 @@
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Trans } from '@lingui/react/macro';
import { Building2Icon, ChevronRightIcon, UserIcon, Users2Icon } from 'lucide-react';
export type SettingsScopeBreadcrumbProps = {
scope: 'organisation' | 'team' | 'account';
scopeName: string;
crumbs: string[];
};
export const SettingsScopeBreadcrumb = ({ scope, scopeName, crumbs }: SettingsScopeBreadcrumbProps) => {
return (
<nav
aria-label="settings-scope-breadcrumb"
className="mb-4 flex flex-wrap items-center gap-2 text-muted-foreground text-sm"
data-testid="settings-scope-breadcrumb"
>
<span>{scopeName}</span>
{crumbs.map((crumb, idx) => {
const isLeaf = idx === crumbs.length - 1;
return (
<span key={`${crumb}-${idx}`} className="flex items-center gap-2">
<ChevronRightIcon className="h-3.5 w-3.5 opacity-50" />
<span className={cn(isLeaf && 'font-semibold text-foreground')}>{crumb}</span>
</span>
);
})}
<Badge
variant={scope === 'organisation' ? 'default' : scope === 'team' ? 'secondary' : 'neutral'}
role="presentation"
className="ml-auto gap-1.5"
data-testid="settings-scope-breadcrumb-chip"
>
{scope === 'organisation' && (
<>
<Building2Icon className="h-3.5 w-3.5" />
<Trans>Organisation Settings</Trans>
</>
)}
{scope === 'team' && (
<>
<Users2Icon className="h-3.5 w-3.5" />
<Trans>Team Settings</Trans>
</>
)}
{scope === 'account' && (
<>
<UserIcon className="h-3.5 w-3.5" />
<Trans>Account Settings</Trans>
</>
)}
</Badge>
</nav>
);
};
@@ -0,0 +1,166 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { getSettingsNavGroups } from '@documenso/lib/utils/settings-nav';
import { computeSwitcherContinuityPath } from '@documenso/lib/utils/settings-switcher';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { cn } from '@documenso/ui/lib/utils';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Trans, useLingui } from '@lingui/react/macro';
import { ChevronsUpDownIcon, PlusIcon, SearchIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router';
const SEARCH_THRESHOLD = 5;
export type SettingsTeamSwitcherProps = {
currentOrgUrl: string;
currentTeamUrl: string | null;
};
export const SettingsTeamSwitcher = ({ currentOrgUrl, currentTeamUrl }: SettingsTeamSwitcherProps) => {
const { t } = useLingui();
const { organisations } = useSession();
const { pathname } = useLocation();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const currentOrg = organisations.find((org) => org.url === currentOrgUrl);
const manageableTeams = useMemo(
() =>
currentOrg ? currentOrg.teams.filter((team) => canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) : [],
[currentOrg],
);
const currentTeam = manageableTeams.find((team) => team.url === currentTeamUrl) ?? manageableTeams[0];
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) {
return manageableTeams;
}
return manageableTeams.filter((team) => team.name.toLowerCase().includes(q));
}, [manageableTeams, query]);
const handleSelect = (teamUrl: string) => {
const destinationTeam = manageableTeams.find((team) => team.url === teamUrl);
if (!currentOrg || !destinationTeam) {
return;
}
const destinationGroup = getSettingsNavGroups({
organisation: {
url: currentOrg.url,
currentOrganisationRole: currentOrg.currentOrganisationRole,
organisationClaim: currentOrg.organisationClaim,
},
team: { url: destinationTeam.url, currentTeamRole: destinationTeam.currentTeamRole },
hasManageableBillingOrgs: false,
}).team;
if (!destinationGroup) {
return;
}
setIsOpen(false);
void navigate(
computeSwitcherContinuityPath({
currentPath: pathname,
destinationPaths: destinationGroup.items.map((item) => item.path),
fallbackPath: destinationGroup.items[0].path,
}),
);
};
if (!currentOrg || !currentTeam) {
return null;
}
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
data-testid="settings-team-switcher-trigger"
className="flex h-auto w-full items-center justify-start gap-2 rounded-lg border bg-background px-1.5 py-1 hover:bg-muted"
>
<AvatarWithText
className="max-w-none"
avatarClass="h-8 w-8"
avatarSrc={formatAvatarUrl(currentTeam.avatarImageId)}
avatarFallback={currentTeam.name.slice(0, 1).toUpperCase()}
primaryText={<span className="font-semibold text-muted-foreground">{currentTeam.name}</span>}
rightSideComponent={<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 text-muted-foreground" />}
/>
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
data-testid="settings-team-switcher-content"
>
{manageableTeams.length >= SEARCH_THRESHOLD && (
<div className="border-b p-2">
<div className="relative">
<SearchIcon className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t`Search teams…`}
className="h-8 pl-7"
data-testid="settings-team-switcher-search"
/>
</div>
</div>
)}
<ul className="max-h-72 space-y-1 overflow-auto p-1">
{filtered.map((team) => {
const isCurrent = team.url === currentTeam.url;
return (
<li key={team.id}>
<button
type="button"
onClick={() => handleSelect(team.url)}
className={cn(
'flex w-full items-center rounded-md px-2 py-2 text-left hover:bg-muted',
isCurrent && 'bg-muted',
)}
data-testid={`settings-team-switcher-item-${team.url}`}
>
<AvatarWithText
avatarClass="h-8 w-8"
avatarSrc={formatAvatarUrl(team.avatarImageId)}
avatarFallback={team.name.slice(0, 1).toUpperCase()}
primaryText={<span className={cn(isCurrent && 'font-semibold')}>{team.name}</span>}
secondaryText={t(EXTENDED_TEAM_MEMBER_ROLE_MAP[team.currentTeamRole])}
/>
</button>
</li>
);
})}
</ul>
<div className="border-t p-1">
<Button variant="ghost" asChild className="w-full justify-start" data-testid="settings-team-switcher-create">
<a href={`/o/${currentOrg.url}/settings/teams?action=add-team`}>
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Create team</Trans>
</a>
</Button>
</div>
</PopoverContent>
</Popover>
);
};
@@ -1,94 +0,0 @@
import type { getTeamWithEmail } from '@documenso/lib/server-only/team/get-team-email-by-email';
import { trpc } from '@documenso/trpc/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react';
import { TeamEmailDeleteDialog } from '~/components/dialogs/team-email-delete-dialog';
import { TeamEmailUpdateDialog } from '~/components/dialogs/team-email-update-dialog';
export type TeamEmailDropdownProps = {
team: Awaited<ReturnType<typeof getTeamWithEmail>>;
};
export const TeamEmailDropdown = ({ team }: TeamEmailDropdownProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: resendEmailVerification, isPending: isResendingEmailVerification } =
trpc.team.email.verification.resend.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`Email verification has been resent`),
duration: 5000,
});
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Unable to resend verification at this time. Please try again.`),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
{!team.teamEmail && team.emailVerification && (
<DropdownMenuItem
disabled={isResendingEmailVerification}
onClick={(e) => {
e.preventDefault();
void resendEmailVerification({ teamId: team.id });
}}
>
{isResendingEmailVerification ? (
<Loader className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
<Trans>Resend verification</Trans>
</DropdownMenuItem>
)}
{team.teamEmail && (
<TeamEmailUpdateDialog
teamEmail={team.teamEmail}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Edit className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</DropdownMenuItem>
}
/>
)}
<TeamEmailDeleteDialog
team={team}
teamName={team.name}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<X className="mr-2 h-4 w-4" />
<Trans>Remove</Trans>
</DropdownMenuItem>
}
/>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -0,0 +1,211 @@
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>
);
};
@@ -0,0 +1,165 @@
import type { SettingsNavGroups, SettingsNavScope } from '@documenso/lib/utils/settings-nav';
import { cn } from '@documenso/ui/lib/utils';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { SettingsOrgSwitcher } from './settings-org-switcher';
import { SettingsTeamSwitcher } from './settings-team-switcher';
type MobileScope = SettingsNavScope | 'account';
export type UnifiedSettingsSidebarMobileProps = {
groups: SettingsNavGroups;
activeScope: MobileScope;
currentOrgUrl: string | null;
currentTeamUrl: string | null;
};
export const UnifiedSettingsSidebarMobile = ({
groups,
activeScope,
currentOrgUrl,
currentTeamUrl,
}: UnifiedSettingsSidebarMobileProps) => {
const { _ } = useLingui();
const { pathname } = useLocation();
const navigate = useNavigate();
const visibleScopes = useMemo<MobileScope[]>(() => {
const scopes: MobileScope[] = [];
if (groups.organisation) {
scopes.push('organisation');
}
if (groups.team) {
scopes.push('team');
}
scopes.push('account');
return scopes;
}, [groups]);
// Falls back to the Account group rather than rendering nothing — an empty scope group
// would leave the mobile viewport with no settings navigation at all.
const activeGroup =
(activeScope === 'organisation' ? groups.organisation : activeScope === 'team' ? groups.team : null) ??
groups.account;
const selectableItems = useMemo(() => activeGroup.items.filter((item) => !item.isSubNavParent), [activeGroup.items]);
// The select matches on exact value, but plenty of settings pages are sub-routes of a
// nav item (`/settings/security/passkeys`, `/t/x/settings/webhooks/:id`, …). Resolving
// to the longest matching item path keeps the trigger populated on those pages instead
// of rendering an empty box — mirrors the desktop sidebar's prefix highlighting.
const selectedPath = useMemo(() => {
let bestMatch: string | undefined;
for (const item of selectableItems) {
if (pathname !== item.path && !pathname.startsWith(`${item.path}/`)) {
continue;
}
if (!bestMatch || item.path.length > bestMatch.length) {
bestMatch = item.path;
}
}
return bestMatch;
}, [selectableItems, pathname]);
const handleScopeChange = (scope: MobileScope) => {
if (scope === activeScope) {
return;
}
if (scope === 'organisation' && groups.organisation) {
void navigate(groups.organisation.items[0].path);
} else if (scope === 'team' && groups.team) {
void navigate(groups.team.items[0].path);
} else if (scope === 'account') {
void navigate(groups.account.items[0].path);
}
};
// The tab row stays full-bleed (its border-b acts as a full-width divider); the
// sections under it get `px-4` to line up with the content pane's own `px-4`
// inset, and `pb-4` keeps the last control off the aside's bottom border.
return (
<div className="flex flex-col gap-3 pb-4" data-testid="unified-settings-sidebar-mobile">
{visibleScopes.length > 1 ? (
<div className="flex border-border border-b" role="tablist">
{visibleScopes.map((scope) => {
const isActive = scope === activeScope;
const accent =
scope === 'organisation'
? 'border-emerald-500 text-emerald-700 dark:text-emerald-300'
: scope === 'team'
? 'border-blue-500 text-blue-700 dark:text-blue-300'
: 'border-foreground text-foreground';
return (
<button
key={scope}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => handleScopeChange(scope)}
className={cn(
'flex-1 border-b-2 py-2 text-center font-bold text-xs uppercase tracking-wide',
isActive ? accent : 'border-transparent text-muted-foreground',
)}
data-testid={`unified-settings-mobile-tab-${scope}`}
>
{scope === 'organisation' && <Trans>Organisation</Trans>}
{scope === 'team' && <Trans>Team</Trans>}
{scope === 'account' && <Trans>Account</Trans>}
</button>
);
})}
</div>
) : (
<div className="px-4 py-2 text-center font-bold text-muted-foreground text-xs uppercase tracking-wide">
{activeScope === 'organisation' && <Trans>Organisation</Trans>}
{activeScope === 'team' && <Trans>Team</Trans>}
{activeScope === 'account' && <Trans>Account</Trans>}
</div>
)}
{/* The organisation switcher is always present in a scoped view — at team scope it's
the only way for a user who can't manage the organisation to move between them,
since the Organisation tab is absent when they have no organisation pages. */}
{activeScope !== 'account' && currentOrgUrl && (
<div className="flex flex-col gap-2 px-4">
<SettingsOrgSwitcher currentOrgUrl={currentOrgUrl} />
{activeScope === 'team' && (
<SettingsTeamSwitcher currentOrgUrl={currentOrgUrl} currentTeamUrl={currentTeamUrl} />
)}
</div>
)}
<div className="px-4">
<div className="mb-1 font-bold text-[10px] text-muted-foreground uppercase tracking-wide">
<Trans>Jump to</Trans>
</div>
<Select
value={selectedPath}
onValueChange={(value) => {
void navigate(value);
}}
>
<SelectTrigger data-testid="unified-settings-mobile-section-trigger">
<SelectValue placeholder={_(msg`Select a section`)} />
</SelectTrigger>
<SelectContent>
{selectableItems.map((item) => (
<SelectItem key={item.path} value={item.path}>
{item.isSubNav ? `${_(msg`Preferences`)} ${_(item.label)}` : _(item.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
};
@@ -0,0 +1,275 @@
import type { SettingsNavGroups, SettingsNavItem, SettingsNavScope } from '@documenso/lib/utils/settings-nav';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@documenso/ui/primitives/collapsible';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ChevronRightIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { NavLink, useLocation } from 'react-router';
import { SettingsOrgSwitcher } from './settings-org-switcher';
import { SettingsTeamSwitcher } from './settings-team-switcher';
export type UnifiedSettingsSidebarProps = {
groups: SettingsNavGroups;
currentOrgUrl: string | null;
currentTeamUrl: string | null;
};
export const UnifiedSettingsSidebar = ({ groups, currentOrgUrl, currentTeamUrl }: UnifiedSettingsSidebarProps) => {
return (
<aside className="flex w-full flex-col" data-testid="unified-settings-sidebar">
{currentOrgUrl && (
<div className="p-4">
<SidebarGroup
heading={<Trans>Organisation Settings</Trans>}
activeBgClassName="bg-[#F1FBEA] text-gray-900 hover:bg-[#F1FBEA] hover:text-gray-900 dark:bg-[#1C2515] dark:text-[#F1FBEA] dark:hover:bg-[#1C2515] dark:hover:text-[#F1FBEA]"
switcher={<SettingsOrgSwitcher currentOrgUrl={currentOrgUrl} />}
items={groups.organisation?.items ?? []}
scope="organisation"
emptyState={
<p
className="rounded-md border border-dashed px-3 py-2 text-muted-foreground text-xs"
data-testid="unified-settings-organisation-empty-state"
>
<Trans>
You don't have permission to manage this organisation. Switch to another one above, or continue in
your team settings below.
</Trans>
</p>
}
/>
</div>
)}
{groups.team && currentOrgUrl && (
<div className="border-t p-4">
<SidebarGroup
heading={<Trans>Team Settings</Trans>}
activeBgClassName="bg-[#F1FBEA] text-gray-900 hover:bg-[#F1FBEA] hover:text-gray-900 dark:bg-[#1C2515] dark:text-[#F1FBEA] dark:hover:bg-[#1C2515] dark:hover:text-[#F1FBEA]"
switcher={<SettingsTeamSwitcher currentOrgUrl={currentOrgUrl} currentTeamUrl={currentTeamUrl} />}
items={groups.team.items}
scope={groups.team.scope}
/>
</div>
)}
<div className={cn('p-4', currentOrgUrl && 'border-t')}>
<SidebarGroup
heading={<Trans>Account Settings</Trans>}
activeBgClassName="bg-[#F1FBEA] text-gray-900 hover:bg-[#F1FBEA] hover:text-gray-900 dark:bg-[#1C2515] dark:text-[#F1FBEA] dark:hover:bg-[#1C2515] dark:hover:text-[#F1FBEA]"
items={groups.account.items}
scope={groups.account.scope}
/>
</div>
</aside>
);
};
type SidebarGroupProps = {
className?: string;
headingClassName?: string;
heading: ReactNode;
activeBgClassName: string;
switcher?: ReactNode;
items: SettingsNavItem[];
scope: SettingsNavScope;
emptyState?: ReactNode;
};
type GroupedNavEntry =
| { kind: 'flat'; item: SettingsNavItem }
| { kind: 'collapsible'; parent: SettingsNavItem; children: SettingsNavItem[] };
/**
* Walk a flat item list and group consecutive `isSubNav` items under their preceding
* `isSubNavParent`. Anything else renders as a flat entry.
*/
const groupNavEntries = (items: SettingsNavItem[]): GroupedNavEntry[] => {
const entries: GroupedNavEntry[] = [];
let currentCollapsible: { parent: SettingsNavItem; children: SettingsNavItem[] } | null = null;
for (const item of items) {
if (item.isSubNavParent) {
currentCollapsible = { parent: item, children: [] };
entries.push({ kind: 'collapsible', ...currentCollapsible });
continue;
}
if (item.isSubNav && currentCollapsible) {
currentCollapsible.children.push(item);
continue;
}
currentCollapsible = null;
entries.push({ kind: 'flat', item });
}
return entries;
};
const SidebarGroup = ({
className,
headingClassName,
heading,
activeBgClassName,
switcher,
items,
scope,
emptyState,
}: SidebarGroupProps) => {
const grouped = groupNavEntries(items);
return (
<div className={className} data-testid="unified-settings-sidebar-group">
<div
className={cn('mb-2 font-semibold text-muted-foreground text-xs uppercase tracking-widest', headingClassName)}
>
{heading}
</div>
{switcher && <div className="mb-2">{switcher}</div>}
{items.length === 0 && emptyState}
<nav className="flex flex-col gap-1">
{grouped.map((entry) => {
if (entry.kind === 'flat') {
return (
<FlatNavItem
key={`${entry.item.key}-${entry.item.path}`}
item={entry.item}
activeBgClassName={activeBgClassName}
scope={scope}
/>
);
}
return (
<CollapsibleNavSection
key={`${entry.parent.key}-${entry.parent.path}`}
parent={entry.parent}
childItems={entry.children}
activeBgClassName={activeBgClassName}
scope={scope}
/>
);
})}
</nav>
</div>
);
};
type FlatNavItemProps = {
item: SettingsNavItem;
activeBgClassName: string;
scope: SettingsNavScope;
};
const FlatNavItem = ({ item, activeBgClassName, scope }: FlatNavItemProps) => {
const { _ } = useLingui();
const { pathname } = useLocation();
// The General items link to the settings root, which prefixes every other
// item's path those require an exact match. Everything else highlights on
// its sub-routes too (e.g. Security on /settings/security/passkeys).
const isActive =
item.key === 'general' ? pathname === item.path : pathname === item.path || pathname.startsWith(`${item.path}/`);
return (
<NavLink to={item.path} className="group block" data-testid={`unified-settings-nav-${scope}-${item.key}`} end>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-full justify-start font-normal text-muted-foreground',
isActive && cn(activeBgClassName, 'font-medium'),
)}
>
{item.icon && <item.icon className="mr-2 h-4 w-4" />}
{_(item.label)}
</Button>
</NavLink>
);
};
type CollapsibleNavSectionProps = {
parent: SettingsNavItem;
childItems: SettingsNavItem[];
activeBgClassName: string;
scope: SettingsNavScope;
};
const CollapsibleNavSection = ({ parent, childItems, activeBgClassName, scope }: CollapsibleNavSectionProps) => {
const { _ } = useLingui();
const { pathname } = useLocation();
const hasActiveChild = childItems.some((child) => pathname === child.path || pathname.startsWith(`${child.path}/`));
const [isOpen, setIsOpen] = useState(hasActiveChild);
// Auto-open when navigating to a sub-route. Closing while on a sub-route is
// respected (state stays closed) until the user navigates away and back.
useEffect(() => {
if (hasActiveChild) {
setIsOpen(true);
}
}, [hasActiveChild]);
const ParentIcon = parent.icon ? parent.icon : null;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'h-8 w-full justify-start font-normal text-muted-foreground',
// Emphasise the parent when one of its children is the active page.
hasActiveChild && 'font-medium text-foreground',
)}
data-testid={`unified-settings-nav-${scope}-${parent.key}`}
aria-expanded={isOpen}
>
{ParentIcon && <ParentIcon className="mr-2 h-4 w-4" />}
{_(parent.label)}
<ChevronRightIcon
className={cn('ml-auto h-4 w-4 transition-transform duration-200', isOpen && 'rotate-90')}
/>
</Button>
</CollapsibleTrigger>
{/* data-[state=closed]:hidden — the `flex` display class would otherwise
defeat the `hidden` attribute Radix sets when closed, leaving the
empty content box (and its mt-1) inflating the gap below the trigger. */}
<CollapsibleContent className="mt-1 flex flex-col gap-1 data-[state=closed]:hidden">
{childItems.map((child) => {
const isActive = pathname === child.path || pathname.startsWith(`${child.path}/`);
return (
<NavLink
key={`${child.key}-${child.path}`}
to={child.path}
className="group block pl-6"
data-testid={`unified-settings-nav-${scope}-${child.key}`}
>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-full justify-start font-normal text-muted-foreground',
isActive && cn(activeBgClassName, 'font-medium'),
)}
>
{_(child.label)}
</Button>
</NavLink>
);
})}
</CollapsibleContent>
</Collapsible>
);
};