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 { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session'; 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 { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document'; import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n'; 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 { DocumentVisibility, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog'; import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog';
import { useOptionalCurrentTeam } from '~/providers/team'; import { useOptionalCurrentTeam } from '~/providers/team';
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox'; import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
import { FormStickySaveBar } from './form-sticky-save-bar'; import { FormStickySaveBar } from './form-sticky-save-bar';
import { InheritableField } from './inheritable-field'; import { InheritableField } from './inheritable-field';
@@ -64,7 +63,6 @@ type SettingsSubset = Pick<
export type DocumentPreferencesFormProps = { export type DocumentPreferencesFormProps = {
settings: SettingsSubset; settings: SettingsSubset;
canInherit: boolean; canInherit: boolean;
isAiFeaturesConfigured?: boolean;
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>; onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
}; };
@@ -83,17 +81,14 @@ const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPr
}; };
}; };
export const DocumentPreferencesForm = ({ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }: DocumentPreferencesFormProps) => {
settings,
onFormSubmit,
canInherit,
isAiFeaturesConfigured = false,
}: DocumentPreferencesFormProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { organisations } = useSession(); const { organisations } = useSession();
const currentOrganisation = useCurrentOrganisation(); const currentOrganisation = useCurrentOrganisation();
const optionalTeam = useOptionalCurrentTeam(); const optionalTeam = useOptionalCurrentTeam();
const isAiFeaturesConfigured = IS_AI_FEATURES_CONFIGURED();
const isPersonalLayoutMode = isPersonalLayout(organisations); const isPersonalLayoutMode = isPersonalLayout(organisations);
const ZDocumentPreferencesFormSchema = z.object({ const ZDocumentPreferencesFormSchema = z.object({
@@ -56,7 +56,7 @@ export const OrganisationUpdateForm = () => {
await refreshSession(); await refreshSession();
if (url !== organisation.url) { if (url !== organisation.url) {
await navigate(`/o/${url}/settings`); await navigate(`/o/${url}/settings/general`);
} }
toast({ toast({
@@ -65,7 +65,7 @@ export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
}); });
if (url !== teamUrl) { if (url !== teamUrl) {
await navigate(`/t/${url}/settings`); await navigate(`/t/${url}/settings/general`);
} }
} catch (err) { } catch (err) {
const error = AppError.parseError(err); const error = AppError.parseError(err);
@@ -287,7 +287,7 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
{ {
id: 'settings-main', id: 'settings-main',
label: msg`Settings`, label: msg`Settings`,
path: '/settings', path: '/settings/profile',
icon: SettingsIcon, icon: SettingsIcon,
shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''), 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 formatChipCount = (count: number, isCapped: boolean) => (isCapped ? `${count}` : `${count}`);
const goToSettings = useCallback(() => push('/settings'), [push]); const goToSettings = useCallback(() => push('/settings/profile'), [push]);
const goToDocuments = useCallback(() => { const goToDocuments = useCallback(() => {
if (teamUrl) { if (teamUrl) {
push(`/t/${teamUrl}/documents?status=ALL`); push(`/t/${teamUrl}/documents?status=ALL`);
@@ -168,7 +168,7 @@ export const OrgMenuSwitcher = () => {
{canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && ( {canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && (
<div className="absolute top-0 right-0 bottom-0 flex items-center justify-center"> <div className="absolute top-0 right-0 bottom-0 flex items-center justify-center">
<Link <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" 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" /> <Settings2Icon className="h-3.5 w-3.5" />
@@ -221,7 +221,7 @@ export const OrgMenuSwitcher = () => {
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && ( {canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
<div className="absolute top-0 right-0 bottom-0 flex items-center justify-center"> <div className="absolute top-0 right-0 bottom-0 flex items-center justify-center">
<Link <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" 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" /> <Settings2Icon className="h-3.5 w-3.5" />
@@ -275,10 +275,10 @@ export const OrgMenuSwitcher = () => {
<Link <Link
to={ to={
canAccessOrganisationSettings canAccessOrganisationSettings
? `/o/${currentOrganisation?.url}/settings` ? `/o/${currentOrganisation?.url}/settings/general`
: canAccessTeamSettings : canAccessTeamSettings
? `/t/${currentTeam?.url}/settings` ? `/t/${currentTeam?.url}/settings/general`
: '/settings' : '/settings/profile'
} }
> >
<Trans>Settings</Trans> <Trans>Settings</Trans>
@@ -157,7 +157,7 @@ export const UnifiedSettingsLayout = ({ activeScope, preferredTeamUrl = null }:
}} }}
primaryButton={ primaryButton={
<Button asChild> <Button asChild>
<Link to="/settings"> <Link to="/settings/profile">
<Trans>Go to your settings</Trans> <Trans>Go to your settings</Trans>
</Link> </Link>
</Button> </Button>
@@ -76,7 +76,7 @@ export const OrganisationTeamsTable = () => {
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex justify-end space-x-2"> <div className="flex justify-end space-x-2">
<Button variant="outline" asChild> <Button variant="outline" asChild>
<Link to={`/t/${row.original.url}/settings`}> <Link to={`/t/${row.original.url}/settings/general`}>
<Trans>Manage</Trans> <Trans>Manage</Trans>
</Link> </Link>
</Button> </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 { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations'; import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; 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 { trpc } from '@documenso/trpc/react';
import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -29,8 +29,6 @@ export const UserOrganisationsTable = () => {
})), })),
}); });
const isPersonalLayoutMode = isPersonalLayout(data);
const results = { const results = {
data: data || [], data: data || [],
perPage: 10, perPage: 10,
@@ -74,7 +72,7 @@ export const UserOrganisationsTable = () => {
<div className="flex justify-end space-x-2"> <div className="flex justify-end space-x-2">
{canExecuteOrganisationAction('MANAGE_ORGANISATION', row.original.currentOrganisationRole) && ( {canExecuteOrganisationAction('MANAGE_ORGANISATION', row.original.currentOrganisationRole) && (
<Button variant="outline" asChild> <Button variant="outline" asChild>
<Link to={`/o/${row.original.url}/settings`}> <Link to={`/o/${row.original.url}/settings/general`}>
<Trans>Manage</Trans> <Trans>Manage</Trans>
</Link> </Link>
</Button> </Button>
@@ -99,7 +97,7 @@ export const UserOrganisationsTable = () => {
), ),
}, },
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, [isPersonalLayoutMode]); }, []);
return ( return (
<div> <div>
@@ -112,9 +110,6 @@ export const UserOrganisationsTable = () => {
error={{ error={{
enable: isLoadingError, enable: isLoadingError,
}} }}
columnVisibility={{
actions: !isPersonalLayoutMode,
}}
skeleton={{ skeleton={{
enable: isLoading, enable: isLoading,
rows: 3, rows: 3,
@@ -136,14 +131,12 @@ export const UserOrganisationsTable = () => {
<TableCell> <TableCell>
<Skeleton className="h-4 w-20 rounded-full" /> <Skeleton className="h-4 w-20 rounded-full" />
</TableCell> </TableCell>
{!isPersonalLayoutMode && ( <TableCell>
<TableCell> <div className="flex flex-row justify-end space-x-2">
<div className="flex flex-row justify-end space-x-2"> <Skeleton className="h-10 w-20 rounded" />
<Skeleton className="h-10 w-20 rounded" /> <Skeleton className="h-10 w-16 rounded" />
<Skeleton className="h-10 w-16 rounded" /> </div>
</div> </TableCell>
</TableCell>
)}
</> </>
), ),
}} }}
@@ -143,7 +143,7 @@ export default function DashboardPage() {
{canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && ( {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"> <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" /> <SettingsIcon className="h-4 w-4" />
</Link> </Link>
</div> </div>
@@ -212,7 +212,7 @@ export default function DashboardPage() {
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && ( {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"> <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" /> <SettingsIcon className="h-4 w-4" />
</Link> </Link>
</div> </div>
@@ -115,7 +115,7 @@ export default function OrganisationSettingsTeamsPage() {
</div> </div>
<Button asChild> <Button asChild>
<Link to={`/o/${organisation.url}/settings`}> <Link to={`/o/${organisation.url}/settings/general`}>
<Trans>Manage Organisation</Trans> <Trans>Manage Organisation</Trans>
</Link> </Link>
</Button> </Button>
@@ -184,7 +184,7 @@ const TeamDropdownMenu = ({ team }: { team: TGetOrganisationSessionResponse[0]['
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to={`/t/${team.url}/settings`}> <Link to={`/t/${team.url}/settings/general`}>
<SettingsIcon className="mr-2 h-4 w-4" /> <SettingsIcon className="mr-2 h-4 w-4" />
<Trans>Settings</Trans> <Trans>Settings</Trans>
</Link> </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 { 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 type { RouteHandle } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies'; import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
@@ -16,12 +17,29 @@ export const handle: RouteHandle = {
layoutMode: 'settings', 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) { export function loader({ request }: Route.LoaderArgs) {
return { return {
preferredTeamUrl: extractCookieFromHeaders(PREFERRED_TEAM_URL_COOKIE, request.headers), 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) { export default function OrganisationSettingsLayout({ loaderData }: Route.ComponentProps) {
return <UnifiedSettingsLayout activeScope="organisation" preferredTeamUrl={loaderData.preferredTeamUrl} />; return <UnifiedSettingsLayout activeScope="organisation" preferredTeamUrl={loaderData.preferredTeamUrl} />;
} }
@@ -1,7 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; 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 { 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 type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
@@ -26,16 +25,12 @@ export function meta() {
} }
export default function OrganisationSettingsBrandingPage() { export default function OrganisationSettingsBrandingPage() {
const { organisations } = useSession();
const organisation = useCurrentOrganisation(); const organisation = useCurrentOrganisation();
const team = useOptionalCurrentTeam(); const team = useOptionalCurrentTeam();
const { t } = useLingui(); const { t } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const [cssWarnings, setCssWarnings] = useState<SanitizeBrandingCssWarning[]>([]); const [cssWarnings, setCssWarnings] = useState<SanitizeBrandingCssWarning[]>([]);
const { const {
@@ -122,11 +117,9 @@ export default function OrganisationSettingsBrandingPage() {
const settingsHeaderText = t`Branding Preferences`; const settingsHeaderText = t`Branding Preferences`;
const settingsHeaderSubtitle = isPersonalLayoutMode const settingsHeaderSubtitle = team
? t`Here you can set your general branding preferences.` ? t`Here you can set branding preferences for your team.`
: team : t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
? 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 ( return (
<div> <div>
@@ -181,7 +174,7 @@ export default function OrganisationSettingsBrandingPage() {
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && ( {canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
<Button asChild variant="outline"> <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> <Trans>Update Billing</Trans>
</Link> </Link>
</Button> </Button>
@@ -1,14 +1,10 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; 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 { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { useLoaderData } from 'react-router';
import { import {
DocumentPreferencesForm, DocumentPreferencesForm,
@@ -21,23 +17,12 @@ export function meta() {
return appMetaTags(msg`Document Preferences`); return appMetaTags(msg`Document Preferences`);
} }
export const loader = () => {
return {
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
};
};
export default function OrganisationSettingsDocumentPage() { export default function OrganisationSettingsDocumentPage() {
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
const { organisations } = useSession();
const organisation = useCurrentOrganisation(); const organisation = useCurrentOrganisation();
const { t } = useLingui(); const { t } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({ const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
organisationReference: organisation.url, organisationReference: organisation.url,
}); });
@@ -106,9 +91,7 @@ export default function OrganisationSettingsDocumentPage() {
} }
const settingsHeaderText = t`Document Preferences`; const settingsHeaderText = t`Document Preferences`;
const settingsHeaderSubtitle = isPersonalLayoutMode const settingsHeaderSubtitle = t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
? 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.`;
return ( return (
<div> <div>
@@ -117,7 +100,6 @@ export default function OrganisationSettingsDocumentPage() {
<section> <section>
<DocumentPreferencesForm <DocumentPreferencesForm
canInherit={false} canInherit={false}
isAiFeaturesConfigured={isAiFeaturesConfigured}
settings={organisationWithSettings.organisationGlobalSettings} settings={organisationWithSettings.organisationGlobalSettings}
onFormSubmit={onDocumentPreferencesFormSubmit} 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 { 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 type { RouteHandle } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies'; import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
@@ -16,12 +17,29 @@ export const handle: RouteHandle = {
layoutMode: 'settings', 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) { export function loader({ request }: Route.LoaderArgs) {
return { return {
preferredTeamUrl: extractCookieFromHeaders(PREFERRED_TEAM_URL_COOKIE, request.headers), 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) { export default function SettingsLayout({ loaderData }: Route.ComponentProps) {
return <UnifiedSettingsLayout activeScope="account" preferredTeamUrl={loaderData.preferredTeamUrl} />; 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 { DocumentSignatureType } from '@documenso/lib/constants/document';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { useLoaderData } from 'react-router';
import { import {
DocumentPreferencesForm, DocumentPreferencesForm,
@@ -13,15 +11,7 @@ import {
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
export const loader = () => {
return {
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
};
};
export default function TeamsSettingsPage() { export default function TeamsSettingsPage() {
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
const team = useCurrentTeam(); const team = useCurrentTeam();
const { t } = useLingui(); const { t } = useLingui();
@@ -103,7 +93,6 @@ export default function TeamsSettingsPage() {
<section> <section>
<DocumentPreferencesForm <DocumentPreferencesForm
canInherit={true} canInherit={true}
isAiFeaturesConfigured={isAiFeaturesConfigured}
settings={teamWithSettings.teamSettings} settings={teamWithSettings.teamSettings}
onFormSubmit={onDocumentPreferencesSubmit} 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. // PRE-HANDLER CODE: Place code here to execute BEFORE the route handler runs.
const redirectPath = await handleRedirects(c); const redirectPath = handleRedirects(c);
if (redirectPath) { if (redirectPath) {
debug.log('Redirecting from', path); debug.log('Redirecting from', path);
+19 -2
View File
@@ -1,7 +1,6 @@
import type { Context } from 'hono'; import type { Context } from 'hono';
// eslint-disable-next-line @typescript-eslint/require-await export const handleRedirects = (c: Context): string | null => {
export const handleRedirects = async (c: Context): Promise<string | null> => {
const { req } = c; const { req } = c;
const path = req.path; const path = req.path;
@@ -15,5 +14,23 @@ export const handleRedirects = async (c: Context): Promise<string | null> => {
return '/'; 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; return null;
}; };
+4 -1
View File
@@ -1,10 +1,13 @@
/** /**
* Todo: Use library for cookies instead. * Todo: Use library for cookies instead.
*
* Server-side counterpart of `extractCookieFromDocument`, keep their matching
* semantics in step.
*/ */
export const extractCookieFromHeaders = (cookieName: string, headers: Headers): string | null => { export const extractCookieFromHeaders = (cookieName: string, headers: Headers): string | null => {
const cookieHeader = headers.get('cookie') || ''; const cookieHeader = headers.get('cookie') || '';
const cookiePairs = cookieHeader.split(';'); const cookiePairs = cookieHeader.split(';');
const cookie = cookiePairs.find((pair) => pair.trim().startsWith(cookieName)); const cookie = cookiePairs.find((pair) => pair.trim().startsWith(`${cookieName}=`));
if (!cookie) { if (!cookie) {
return null; return null;
+17
View File
@@ -0,0 +1,17 @@
/**
* Read a cookie value from `document.cookie`.
*
* Client-side counterpart of `extractCookieFromHeaders`. Only works for cookies that
* are not `HttpOnly`, such as the preferred team URL cookie.
*/
export const extractCookieFromDocument = (cookieName: string): string | null => {
const cookiePairs = document.cookie.split(';');
const cookie = cookiePairs.find((pair) => pair.trim().startsWith(`${cookieName}=`));
if (!cookie) {
return null;
}
return cookie.split('=')[1].trim();
};
+14 -1
View File
@@ -24,7 +24,20 @@ export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@docume
export const USE_INTERNAL_URL_BROWSERLESS = () => env('NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS') === 'true'; export const USE_INTERNAL_URL_BROWSERLESS = () => env('NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS') === 'true';
export const IS_AI_FEATURES_CONFIGURED = () => !!env('GOOGLE_VERTEX_PROJECT_ID') && !!env('GOOGLE_VERTEX_API_KEY'); /**
* Returns whether AI features are configured for this instance.
*
* Platform-aware:
* - On the server, checks the private Vertex credentials are configured.
* - On the client, reads the derived public flag injected via `window.__ENV__`.
*/
export const IS_AI_FEATURES_CONFIGURED = (): boolean => {
if (typeof window === 'undefined') {
return !!env('GOOGLE_VERTEX_PROJECT_ID') && !!env('GOOGLE_VERTEX_API_KEY');
}
return env('NEXT_PUBLIC_AI_FEATURES_ENABLED') === 'true';
};
/** /**
* Temporary flag to toggle between Playwright-based and Konva-based PDF generation * Temporary flag to toggle between Playwright-based and Konva-based PDF generation
+4
View File
@@ -56,4 +56,8 @@ export const createPublicEnv = () => ({
// Derived from the private transport so the client can detect CSC mode for // Derived from the private transport so the client can detect CSC mode for
// authoring UI gating without exposing the raw transport value. // authoring UI gating without exposing the raw transport value.
NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC: process.env.NEXT_PRIVATE_SIGNING_TRANSPORT === 'csc' ? 'true' : 'false', NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC: process.env.NEXT_PRIVATE_SIGNING_TRANSPORT === 'csc' ? 'true' : 'false',
// Derived from the private Vertex credentials so the client can gate AI
// feature UI on a boolean.
NEXT_PUBLIC_AI_FEATURES_ENABLED:
process.env.GOOGLE_VERTEX_PROJECT_ID && process.env.GOOGLE_VERTEX_API_KEY ? 'true' : 'false',
}); });