fix: improve unified settings (#3160)

This commit is contained in:
David Nguyen
2026-08-10 14:57:19 +10:00
committed by GitHub
parent fc95ee9ead
commit 797f5c0e79
24 changed files with 129 additions and 114 deletions
@@ -1,5 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
@@ -24,10 +25,8 @@ import { Trans } from '@lingui/react/macro';
import { DocumentVisibility, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
import { FormStickySaveBar } from './form-sticky-save-bar';
import { InheritableField } from './inheritable-field';
@@ -64,7 +63,6 @@ type SettingsSubset = Pick<
export type DocumentPreferencesFormProps = {
settings: SettingsSubset;
canInherit: boolean;
isAiFeaturesConfigured?: boolean;
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
};
@@ -83,17 +81,14 @@ const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPr
};
};
export const DocumentPreferencesForm = ({
settings,
onFormSubmit,
canInherit,
isAiFeaturesConfigured = false,
}: DocumentPreferencesFormProps) => {
export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }: DocumentPreferencesFormProps) => {
const { _ } = useLingui();
const { organisations } = useSession();
const currentOrganisation = useCurrentOrganisation();
const optionalTeam = useOptionalCurrentTeam();
const isAiFeaturesConfigured = IS_AI_FEATURES_CONFIGURED();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const ZDocumentPreferencesFormSchema = z.object({
@@ -56,7 +56,7 @@ export const OrganisationUpdateForm = () => {
await refreshSession();
if (url !== organisation.url) {
await navigate(`/o/${url}/settings`);
await navigate(`/o/${url}/settings/general`);
}
toast({
@@ -65,7 +65,7 @@ export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
});
if (url !== teamUrl) {
await navigate(`/t/${url}/settings`);
await navigate(`/t/${url}/settings/general`);
}
} catch (err) {
const error = AppError.parseError(err);
@@ -287,7 +287,7 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
{
id: 'settings-main',
label: msg`Settings`,
path: '/settings',
path: '/settings/profile',
icon: SettingsIcon,
shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''),
},
@@ -389,7 +389,7 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
const formatChipCount = (count: number, isCapped: boolean) => (isCapped ? `${count}` : `${count}`);
const goToSettings = useCallback(() => push('/settings'), [push]);
const goToSettings = useCallback(() => push('/settings/profile'), [push]);
const goToDocuments = useCallback(() => {
if (teamUrl) {
push(`/t/${teamUrl}/documents?status=ALL`);
@@ -168,7 +168,7 @@ export const OrgMenuSwitcher = () => {
{canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && (
<div className="absolute top-0 right-0 bottom-0 flex items-center justify-center">
<Link
to={`/o/${org.url}/settings`}
to={`/o/${org.url}/settings/general`}
className="mr-2 rounded-sm border p-1 text-muted-foreground transition-opacity duration-200 group-hover:opacity-100 md:opacity-0"
>
<Settings2Icon className="h-3.5 w-3.5" />
@@ -221,7 +221,7 @@ export const OrgMenuSwitcher = () => {
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
<div className="absolute top-0 right-0 bottom-0 flex items-center justify-center">
<Link
to={`/t/${team.url}/settings`}
to={`/t/${team.url}/settings/general`}
className="mr-2 rounded-sm border p-1 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100"
>
<Settings2Icon className="h-3.5 w-3.5" />
@@ -275,10 +275,10 @@ export const OrgMenuSwitcher = () => {
<Link
to={
canAccessOrganisationSettings
? `/o/${currentOrganisation?.url}/settings`
? `/o/${currentOrganisation?.url}/settings/general`
: canAccessTeamSettings
? `/t/${currentTeam?.url}/settings`
: '/settings'
? `/t/${currentTeam?.url}/settings/general`
: '/settings/profile'
}
>
<Trans>Settings</Trans>
@@ -157,7 +157,7 @@ export const UnifiedSettingsLayout = ({ activeScope, preferredTeamUrl = null }:
}}
primaryButton={
<Button asChild>
<Link to="/settings">
<Link to="/settings/profile">
<Trans>Go to your settings</Trans>
</Link>
</Button>
@@ -76,7 +76,7 @@ export const OrganisationTeamsTable = () => {
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
<Button variant="outline" asChild>
<Link to={`/t/${row.original.url}/settings`}>
<Link to={`/t/${row.original.url}/settings/general`}>
<Trans>Manage</Trans>
</Link>
</Button>
@@ -2,7 +2,7 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
@@ -29,8 +29,6 @@ export const UserOrganisationsTable = () => {
})),
});
const isPersonalLayoutMode = isPersonalLayout(data);
const results = {
data: data || [],
perPage: 10,
@@ -74,7 +72,7 @@ export const UserOrganisationsTable = () => {
<div className="flex justify-end space-x-2">
{canExecuteOrganisationAction('MANAGE_ORGANISATION', row.original.currentOrganisationRole) && (
<Button variant="outline" asChild>
<Link to={`/o/${row.original.url}/settings`}>
<Link to={`/o/${row.original.url}/settings/general`}>
<Trans>Manage</Trans>
</Link>
</Button>
@@ -99,7 +97,7 @@ export const UserOrganisationsTable = () => {
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, [isPersonalLayoutMode]);
}, []);
return (
<div>
@@ -112,9 +110,6 @@ export const UserOrganisationsTable = () => {
error={{
enable: isLoadingError,
}}
columnVisibility={{
actions: !isPersonalLayoutMode,
}}
skeleton={{
enable: isLoading,
rows: 3,
@@ -136,14 +131,12 @@ export const UserOrganisationsTable = () => {
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
{!isPersonalLayoutMode && (
<TableCell>
<div className="flex flex-row justify-end space-x-2">
<Skeleton className="h-10 w-20 rounded" />
<Skeleton className="h-10 w-16 rounded" />
</div>
</TableCell>
)}
<TableCell>
<div className="flex flex-row justify-end space-x-2">
<Skeleton className="h-10 w-20 rounded" />
<Skeleton className="h-10 w-16 rounded" />
</div>
</TableCell>
</>
),
}}
@@ -143,7 +143,7 @@ export default function DashboardPage() {
{canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && (
<div className="absolute top-4 right-4 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<Link to={`/o/${org.url}/settings`}>
<Link to={`/o/${org.url}/settings/general`}>
<SettingsIcon className="h-4 w-4" />
</Link>
</div>
@@ -212,7 +212,7 @@ export default function DashboardPage() {
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
<div className="absolute top-4 right-4 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<Link to={`/t/${team.url}/settings`}>
<Link to={`/t/${team.url}/settings/general`}>
<SettingsIcon className="h-4 w-4" />
</Link>
</div>
@@ -115,7 +115,7 @@ export default function OrganisationSettingsTeamsPage() {
</div>
<Button asChild>
<Link to={`/o/${organisation.url}/settings`}>
<Link to={`/o/${organisation.url}/settings/general`}>
<Trans>Manage Organisation</Trans>
</Link>
</Button>
@@ -184,7 +184,7 @@ const TeamDropdownMenu = ({ team }: { team: TGetOrganisationSessionResponse[0]['
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/t/${team.url}/settings`}>
<Link to={`/t/${team.url}/settings/general`}>
<SettingsIcon className="mr-2 h-4 w-4" />
<Trans>Settings</Trans>
</Link>
@@ -1,11 +0,0 @@
import { redirect } from 'react-router';
import type { Route } from './+types/_layout';
export function loader({ params }: Route.LoaderArgs) {
if (params.orgUrl) {
throw redirect(`/o/${params.orgUrl}/settings/general`);
}
throw redirect('/');
}
@@ -1,4 +1,5 @@
import { extractCookieFromHeaders } from '@documenso/auth/server/lib/utils/cookies';
import { extractCookieFromDocument } from '@documenso/lib/client-only/cookies';
import type { RouteHandle } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies';
import { msg } from '@lingui/core/macro';
@@ -16,12 +17,29 @@ export const handle: RouteHandle = {
layoutMode: 'settings',
};
/**
* Only runs on the initial document request (SSR) so the first paint has the
* correct preferred team without a hydration mismatch.
*/
export function loader({ request }: Route.LoaderArgs) {
return {
preferredTeamUrl: extractCookieFromHeaders(PREFERRED_TEAM_URL_COOKIE, request.headers),
};
}
/**
* Runs instead of the server loader on client-side navigations, otherwise every
* settings page switch would trigger a `.data` round-trip to the server just to
* read this cookie.
*
* The cookie is not `HttpOnly` so it can be read straight from the document.
*/
export function clientLoader() {
return {
preferredTeamUrl: extractCookieFromDocument(PREFERRED_TEAM_URL_COOKIE),
};
}
export default function OrganisationSettingsLayout({ loaderData }: Route.ComponentProps) {
return <UnifiedSettingsLayout activeScope="organisation" preferredTeamUrl={loaderData.preferredTeamUrl} />;
}
@@ -1,7 +1,6 @@
import { useCurrentOrganisation } 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 { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
@@ -26,16 +25,12 @@ export function meta() {
}
export default function OrganisationSettingsBrandingPage() {
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const team = useOptionalCurrentTeam();
const { t } = useLingui();
const { toast } = useToast();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const [cssWarnings, setCssWarnings] = useState<SanitizeBrandingCssWarning[]>([]);
const {
@@ -122,11 +117,9 @@ export default function OrganisationSettingsBrandingPage() {
const settingsHeaderText = t`Branding Preferences`;
const settingsHeaderSubtitle = isPersonalLayoutMode
? t`Here you can set your general branding preferences.`
: team
? t`Here you can set branding preferences for your team.`
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
const settingsHeaderSubtitle = team
? t`Here you can set branding preferences for your team.`
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
return (
<div>
@@ -181,7 +174,7 @@ export default function OrganisationSettingsBrandingPage() {
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
<Button asChild variant="outline">
<Link to={isPersonalLayoutMode ? '/settings/billing' : `/o/${organisation.url}/settings/billing`}>
<Link to={`/o/${organisation.url}/settings/billing`}>
<Trans>Update Billing</Trans>
</Link>
</Button>
@@ -1,14 +1,10 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useLoaderData } from 'react-router';
import {
DocumentPreferencesForm,
@@ -21,23 +17,12 @@ export function meta() {
return appMetaTags(msg`Document Preferences`);
}
export const loader = () => {
return {
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
};
};
export default function OrganisationSettingsDocumentPage() {
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { toast } = useToast();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
organisationReference: organisation.url,
});
@@ -106,9 +91,7 @@ export default function OrganisationSettingsDocumentPage() {
}
const settingsHeaderText = t`Document Preferences`;
const settingsHeaderSubtitle = isPersonalLayoutMode
? t`Here you can set your general document preferences.`
: t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
const settingsHeaderSubtitle = t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
return (
<div>
@@ -117,7 +100,6 @@ export default function OrganisationSettingsDocumentPage() {
<section>
<DocumentPreferencesForm
canInherit={false}
isAiFeaturesConfigured={isAiFeaturesConfigured}
settings={organisationWithSettings.organisationGlobalSettings}
onFormSubmit={onDocumentPreferencesFormSubmit}
/>
@@ -1,5 +0,0 @@
import { redirect } from 'react-router';
export function loader() {
throw redirect('/settings/profile');
}
@@ -1,4 +1,5 @@
import { extractCookieFromHeaders } from '@documenso/auth/server/lib/utils/cookies';
import { extractCookieFromDocument } from '@documenso/lib/client-only/cookies';
import type { RouteHandle } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies';
import { msg } from '@lingui/core/macro';
@@ -16,12 +17,29 @@ export const handle: RouteHandle = {
layoutMode: 'settings',
};
/**
* Only runs on the initial document request (SSR) so the first paint has the
* correct preferred team without a hydration mismatch.
*/
export function loader({ request }: Route.LoaderArgs) {
return {
preferredTeamUrl: extractCookieFromHeaders(PREFERRED_TEAM_URL_COOKIE, request.headers),
};
}
/**
* Runs instead of the server loader on client-side navigations, otherwise every
* settings page switch would trigger a `.data` round-trip to the server just to
* read this cookie.
*
* The cookie is not `HttpOnly` so it can be read straight from the document.
*/
export function clientLoader() {
return {
preferredTeamUrl: extractCookieFromDocument(PREFERRED_TEAM_URL_COOKIE),
};
}
export default function SettingsLayout({ loaderData }: Route.ComponentProps) {
return <UnifiedSettingsLayout activeScope="account" preferredTeamUrl={loaderData.preferredTeamUrl} />;
}
@@ -1,11 +0,0 @@
import { redirect } from 'react-router';
import type { Route } from './+types/settings._index';
export function loader({ params }: Route.LoaderArgs) {
if (params.teamUrl) {
throw redirect(`/t/${params.teamUrl}/settings/general`);
}
throw redirect('/');
}
@@ -1,10 +1,8 @@
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useLoaderData } from 'react-router';
import {
DocumentPreferencesForm,
@@ -13,15 +11,7 @@ import {
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team';
export const loader = () => {
return {
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
};
};
export default function TeamsSettingsPage() {
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
const team = useCurrentTeam();
const { t } = useLingui();
@@ -103,7 +93,6 @@ export default function TeamsSettingsPage() {
<section>
<DocumentPreferencesForm
canInherit={true}
isAiFeaturesConfigured={isAiFeaturesConfigured}
settings={teamWithSettings.teamSettings}
onFormSubmit={onDocumentPreferencesSubmit}
/>
+1 -1
View File
@@ -26,7 +26,7 @@ export const appMiddleware = async (c: Context, next: Next) => {
}
// PRE-HANDLER CODE: Place code here to execute BEFORE the route handler runs.
const redirectPath = await handleRedirects(c);
const redirectPath = handleRedirects(c);
if (redirectPath) {
debug.log('Redirecting from', path);
+19 -2
View File
@@ -1,7 +1,6 @@
import type { Context } from 'hono';
// eslint-disable-next-line @typescript-eslint/require-await
export const handleRedirects = async (c: Context): Promise<string | null> => {
export const handleRedirects = (c: Context): string | null => {
const { req } = c;
const path = req.path;
@@ -15,5 +14,23 @@ export const handleRedirects = async (c: Context): Promise<string | null> => {
return '/';
}
// The settings paths below have no index routes, land on their first page instead.
// In-app links point directly at the subpages, these only catch direct visits.
if (path === '/settings' || path === '/settings/') {
return '/settings/profile';
}
const orgSettingsMatch = path.match(/^\/o\/([^/]+)\/settings\/?$/);
if (orgSettingsMatch) {
return `/o/${orgSettingsMatch[1]}/settings/general`;
}
const teamSettingsMatch = path.match(/^\/t\/([^/]+)\/settings\/?$/);
if (teamSettingsMatch) {
return `/t/${teamSettingsMatch[1]}/settings/general`;
}
return null;
};