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
@@ -0,0 +1,55 @@
import { useMatches } from 'react-router';
/**
* The layout treatment a route wants from its parent layout(s).
*
* - `'settings'` — the full-height unified settings layout: no centered page
* container, a full-width app header, and a viewport-height flex column so the
* settings shell can fill the available space and scroll internally.
* - `null` — the default layout (centered `<PageContainer />`, normal flow).
*/
export type LayoutMode = 'settings' | null;
/**
* Typed route `handle` export. Controls layout rendering.
*
* - `hideAppHeader` — tells the parent layout to skip rendering `<AppHeader />`.
* - `layoutMode` — selects the layout treatment the parent layout(s) apply. See
* {@link LayoutMode}.
*/
export type RouteHandle = {
hideAppHeader?: boolean;
layoutMode?: LayoutMode;
};
/**
* Returns layout flags from the deepest matching route that sets any.
* Layouts call this to decide whether to render certain elements.
*/
export function useChildRouteFlags(): { hideAppHeader: boolean; layoutMode: LayoutMode } {
const matches = useMatches();
let hideAppHeader = false;
let layoutMode: LayoutMode = null;
// Walk from deepest match backward so the leaf route wins per flag.
for (let i = matches.length - 1; i >= 0; i--) {
const handle = matches[i].handle;
if (handle == null || typeof handle !== 'object') {
continue;
}
const h = handle as RouteHandle;
if (layoutMode === null && h.layoutMode) {
layoutMode = h.layoutMode;
}
if (!hideAppHeader && h.hideAppHeader) {
hideAppHeader = true;
}
}
return { hideAppHeader, layoutMode };
}
+1
View File
@@ -0,0 +1 @@
export const PREFERRED_TEAM_URL_COOKIE = 'preferred-team-url';
@@ -1,37 +0,0 @@
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type GetTeamEmailByEmailOptions = {
email: string;
};
export const getTeamEmailByEmail = async ({ email }: GetTeamEmailByEmailOptions) => {
return await prisma.teamEmail.findFirst({
where: {
email,
},
include: {
team: {
select: {
id: true,
name: true,
url: true,
},
},
},
});
};
export const getTeamWithEmail = async ({ userId, teamUrl }: { userId: number; teamUrl: string }) => {
return await prisma.team.findFirstOrThrow({
where: {
...buildTeamWhereQuery({ teamId: undefined, userId }),
url: teamUrl,
},
include: {
teamEmail: true,
emailVerification: true,
},
});
};
+2 -2
View File
@@ -15,12 +15,12 @@ export type GetTeamsOptions = {
};
export const ZGetTeamsResponseSchema = TeamSchema.extend({
teamRole: z.nativeEnum(TeamMemberRole),
currentTeamRole: z.nativeEnum(TeamMemberRole),
}).array();
export type TGetTeamsResponse = z.infer<typeof ZGetTeamsResponseSchema>;
export const getTeams = async ({ userId, teamId }: GetTeamsOptions) => {
export const getTeams = async ({ userId, teamId }: GetTeamsOptions): Promise<TGetTeamsResponse> => {
const teams = await prisma.team.findMany({
where: buildTeamWhereQuery({ teamId, userId }),
include: {
@@ -3,6 +3,7 @@ import { DateTime } from 'luxon';
import { EMAIL_VERIFICATION_STATE, USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
import { jobsClient } from '../../jobs/client';
import { getMostRecentEmailVerificationToken } from './get-most-recent-email-verification-token';
export type VerifyEmailProps = {
token: string;
@@ -36,13 +37,8 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
const valid = verificationToken.expires > new Date();
if (!valid) {
const mostRecentToken = await prisma.verificationToken.findFirst({
where: {
userId: verificationToken.userId,
},
orderBy: {
createdAt: 'desc',
},
const mostRecentToken = await getMostRecentEmailVerificationToken({
userId: verificationToken.userId,
});
// If there isn't a recent token or it's older than 1 hour, send a new token
@@ -80,6 +76,7 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
prisma.verificationToken.updateMany({
where: {
userId: verificationToken.userId,
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
},
data: {
completed: true,
@@ -89,6 +86,7 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
prisma.verificationToken.deleteMany({
where: {
userId: verificationToken.userId,
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
expires: {
lt: new Date(),
},
@@ -1,24 +1,26 @@
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
export const getWebhooksByTeamId = async (teamId: number, userId: number) => {
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
return await prisma.webhook.findMany({
where: {
team: {
id: teamId,
teamGroups: {
some: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
teamId,
},
orderBy: {
createdAt: 'desc',
+298
View File
@@ -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 };
};
+57
View File
@@ -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;
};
+3
View File
@@ -1,6 +1,9 @@
import macrosPlugin from 'vite-plugin-babel-macros';
import { defineConfig } from 'vitest/config';
export default defineConfig({
// Transform lingui macros (e.g. `msg`) used by the code under test.
plugins: [macrosPlugin()],
test: {
include: ['**/*.test.ts'],
},