mirror of
https://github.com/documenso/documenso.git
synced 2026-08-20 05:21:51 +10:00
feat: unify settings (#3128)
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
import {
|
||||
BracesIcon,
|
||||
Building2Icon,
|
||||
CreditCardIcon,
|
||||
Globe2Icon,
|
||||
GroupIcon,
|
||||
LockIcon,
|
||||
MailboxIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
UserIcon,
|
||||
Users2Icon,
|
||||
WebhookIcon,
|
||||
} from 'lucide-react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { FaUsers } from 'react-icons/fa6';
|
||||
import { IS_BILLING_ENABLED } from '../constants/app';
|
||||
import { canExecuteOrganisationAction } from './organisations';
|
||||
import { canExecuteTeamAction } from './teams';
|
||||
|
||||
export type SettingsNavScope = 'organisation' | 'team' | 'account';
|
||||
|
||||
export type SettingsNavItem = {
|
||||
key: string;
|
||||
path: string;
|
||||
label: MessageDescriptor;
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
isSubNav?: boolean;
|
||||
isSubNavParent?: boolean;
|
||||
};
|
||||
|
||||
export type SettingsNavGroup = {
|
||||
scope: SettingsNavScope;
|
||||
items: SettingsNavItem[];
|
||||
};
|
||||
|
||||
export type SettingsNavGroups = {
|
||||
organisation: SettingsNavGroup | null;
|
||||
team: SettingsNavGroup | null;
|
||||
account: SettingsNavGroup;
|
||||
};
|
||||
|
||||
export type GetSettingsNavGroupsArgs = {
|
||||
organisation: {
|
||||
url: string;
|
||||
currentOrganisationRole: OrganisationMemberRole;
|
||||
organisationClaim: { flags: { emailDomains?: boolean; authenticationPortal?: boolean } };
|
||||
} | null;
|
||||
team: {
|
||||
url: string;
|
||||
currentTeamRole: TeamMemberRole;
|
||||
} | null;
|
||||
hasManageableBillingOrgs: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the nav-group structure for the unified settings sidebar.
|
||||
*
|
||||
* Pure data helper — given a current organisation, optional current team, and the billing-enabled
|
||||
* flag, returns the items that should appear in each scope group. Groups the user has no manage
|
||||
* permission for are returned as `null` (not empty arrays) so consumers can branch on visibility.
|
||||
*
|
||||
* Item ordering, claim-flag gating, and which sections are scope-specific are encoded here as the
|
||||
* single source of truth for the unified-settings sidebar.
|
||||
*/
|
||||
export const getSettingsNavGroups = ({
|
||||
organisation,
|
||||
team,
|
||||
hasManageableBillingOrgs,
|
||||
}: GetSettingsNavGroupsArgs): SettingsNavGroups => {
|
||||
const isBillingEnabled = IS_BILLING_ENABLED();
|
||||
|
||||
const canManageOrg =
|
||||
organisation !== null && canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole);
|
||||
|
||||
const canManageTeam = team !== null && canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole);
|
||||
|
||||
const orgGroup: SettingsNavGroup | null = canManageOrg
|
||||
? {
|
||||
scope: 'organisation',
|
||||
items: [
|
||||
{
|
||||
key: 'general',
|
||||
path: `/o/${organisation.url}/settings/general`,
|
||||
label: msg`General`,
|
||||
icon: Building2Icon,
|
||||
},
|
||||
{
|
||||
key: 'preferences',
|
||||
path: `/o/${organisation.url}/settings/document`,
|
||||
label: msg`Preferences`,
|
||||
icon: Settings2Icon,
|
||||
isSubNavParent: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-document',
|
||||
path: `/o/${organisation.url}/settings/document`,
|
||||
label: msg`General`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-branding',
|
||||
path: `/o/${organisation.url}/settings/branding`,
|
||||
label: msg`Branding`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-email',
|
||||
path: `/o/${organisation.url}/settings/email`,
|
||||
label: msg`Email`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-reminders',
|
||||
path: `/o/${organisation.url}/settings/reminders`,
|
||||
label: msg`Reminders`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-certificates',
|
||||
path: `/o/${organisation.url}/settings/certificates`,
|
||||
label: msg`Certificates`,
|
||||
isSubNav: true,
|
||||
},
|
||||
...(isBillingEnabled && organisation.organisationClaim.flags.emailDomains
|
||||
? [
|
||||
{
|
||||
key: 'email-domains',
|
||||
path: `/o/${organisation.url}/settings/email-domains`,
|
||||
label: msg`Email Domains`,
|
||||
icon: MailboxIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'teams',
|
||||
path: `/o/${organisation.url}/settings/teams`,
|
||||
label: msg`Teams`,
|
||||
icon: FaUsers,
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
path: `/o/${organisation.url}/settings/members`,
|
||||
label: msg`Members`,
|
||||
icon: Users2Icon,
|
||||
},
|
||||
{
|
||||
key: 'groups',
|
||||
path: `/o/${organisation.url}/settings/groups`,
|
||||
label: msg`Groups`,
|
||||
icon: GroupIcon,
|
||||
},
|
||||
...(isBillingEnabled && organisation.organisationClaim.flags.authenticationPortal
|
||||
? [
|
||||
{
|
||||
key: 'sso',
|
||||
path: `/o/${organisation.url}/settings/sso`,
|
||||
label: msg`SSO`,
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isBillingEnabled
|
||||
? [
|
||||
{
|
||||
key: 'billing',
|
||||
path: `/o/${organisation.url}/settings/billing`,
|
||||
label: msg`Billing`,
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
: null;
|
||||
|
||||
const teamGroup: SettingsNavGroup | null =
|
||||
canManageTeam && team
|
||||
? {
|
||||
scope: 'team',
|
||||
items: [
|
||||
{
|
||||
key: 'general',
|
||||
path: `/t/${team.url}/settings/general`,
|
||||
label: msg`General`,
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
{
|
||||
key: 'preferences',
|
||||
path: `/t/${team.url}/settings/document`,
|
||||
label: msg`Preferences`,
|
||||
icon: Settings2Icon,
|
||||
isSubNavParent: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-document',
|
||||
path: `/t/${team.url}/settings/document`,
|
||||
label: msg`General`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-branding',
|
||||
path: `/t/${team.url}/settings/branding`,
|
||||
label: msg`Branding`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-email',
|
||||
path: `/t/${team.url}/settings/email`,
|
||||
label: msg`Email`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-reminders',
|
||||
path: `/t/${team.url}/settings/reminders`,
|
||||
label: msg`Reminders`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-certificates',
|
||||
path: `/t/${team.url}/settings/certificates`,
|
||||
label: msg`Certificates`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
path: `/t/${team.url}/settings/members`,
|
||||
label: msg`Members`,
|
||||
icon: Users2Icon,
|
||||
},
|
||||
{
|
||||
key: 'groups',
|
||||
path: `/t/${team.url}/settings/groups`,
|
||||
label: msg`Groups`,
|
||||
icon: GroupIcon,
|
||||
},
|
||||
{
|
||||
key: 'public-profile',
|
||||
path: `/t/${team.url}/settings/public-profile`,
|
||||
label: msg`Public Profile`,
|
||||
icon: Globe2Icon,
|
||||
},
|
||||
{
|
||||
key: 'tokens',
|
||||
path: `/t/${team.url}/settings/tokens`,
|
||||
label: msg`API Tokens`,
|
||||
icon: BracesIcon,
|
||||
},
|
||||
{
|
||||
key: 'webhooks',
|
||||
path: `/t/${team.url}/settings/webhooks`,
|
||||
label: msg`Webhooks`,
|
||||
icon: WebhookIcon,
|
||||
},
|
||||
],
|
||||
}
|
||||
: null;
|
||||
|
||||
const accountGroup: SettingsNavGroup = {
|
||||
scope: 'account',
|
||||
items: [
|
||||
{
|
||||
key: 'profile',
|
||||
path: '/settings/profile',
|
||||
label: msg`Profile`,
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
key: 'organisations',
|
||||
path: '/settings/organisations',
|
||||
label: msg`Organisations`,
|
||||
icon: Building2Icon,
|
||||
},
|
||||
{
|
||||
key: 'security',
|
||||
path: '/settings/security',
|
||||
label: msg`Security`,
|
||||
icon: LockIcon,
|
||||
},
|
||||
...(IS_BILLING_ENABLED() && hasManageableBillingOrgs
|
||||
? [
|
||||
{
|
||||
key: 'billing',
|
||||
path: '/settings/billing',
|
||||
label: msg`Billing`,
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
return { organisation: orgGroup, team: teamGroup, account: accountGroup };
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
export type ComputeSwitcherContinuityPathArgs = {
|
||||
currentPath: string;
|
||||
|
||||
/**
|
||||
* Every settings path navigable in the destination scope — pass the destination's
|
||||
* `SettingsNavGroup.items` paths from `getSettingsNavGroups`.
|
||||
*
|
||||
* Sourcing these from the nav builder (rather than a hardcoded list) means the switcher
|
||||
* can only ever land on a page that is actually reachable there: permission and
|
||||
* claim/billing gating are already applied when the group is built.
|
||||
*/
|
||||
destinationPaths: string[];
|
||||
|
||||
/** Where to land when the current section has no equivalent in the destination. */
|
||||
fallbackPath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the "section" portion of a settings URL. Returns null if not a scoped
|
||||
* settings URL (account settings and non-settings pages have no equivalent to carry over).
|
||||
*
|
||||
* Examples:
|
||||
* /o/acme/settings/members → 'members'
|
||||
* /o/acme/settings → 'general' (bare index redirects to General)
|
||||
* /t/eng/settings/webhooks/42 → 'webhooks'
|
||||
* /o/acme/documents → null
|
||||
* /settings/profile → null
|
||||
*/
|
||||
const parseSettingsSection = (path: string): string | null => {
|
||||
const match = /^\/[ot]\/[^/]+\/settings(?:\/([^/?#]+))?(?:[/?#]|$)/.exec(path);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match[1] ?? 'general';
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the destination URL when the user switches organisation or team via the
|
||||
* unified settings sidebar's switcher.
|
||||
*
|
||||
* Rule: stay on the same section if the destination has one; else use the fallback.
|
||||
*/
|
||||
export const computeSwitcherContinuityPath = ({
|
||||
currentPath,
|
||||
destinationPaths,
|
||||
fallbackPath,
|
||||
}: ComputeSwitcherContinuityPathArgs): string => {
|
||||
const currentSection = parseSettingsSection(currentPath);
|
||||
|
||||
if (!currentSection) {
|
||||
return fallbackPath;
|
||||
}
|
||||
|
||||
return destinationPaths.find((path) => parseSettingsSection(path) === currentSection) ?? fallbackPath;
|
||||
};
|
||||
Reference in New Issue
Block a user