mirror of
https://github.com/documenso/documenso.git
synced 2026-08-19 21:11:54 +10:00
feat: unify settings (#3128)
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user