From 797f5c0e79c16bb52ae02cb7390c9c276cea2d87 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 10 Aug 2026 14:57:19 +1000 Subject: [PATCH] fix: improve unified settings (#3160) --- .../forms/document-preferences-form.tsx | 13 +++------- .../forms/organisation-update-form.tsx | 2 +- .../app/components/forms/team-update-form.tsx | 2 +- .../components/general/app-command-menu.tsx | 4 +-- .../components/general/org-menu-switcher.tsx | 10 ++++---- .../general/unified-settings-layout.tsx | 2 +- .../tables/organisation-teams-table.tsx | 2 +- .../tables/user-organisations-table.tsx | 25 +++++++------------ .../app/routes/_authenticated+/dashboard.tsx | 4 +-- .../_authenticated+/o.$orgUrl._index.tsx | 4 +-- .../o.$orgUrl.settings._index.tsx | 11 -------- .../o.$orgUrl.settings._layout.tsx | 18 +++++++++++++ .../o.$orgUrl.settings.branding.tsx | 17 ++++--------- .../o.$orgUrl.settings.document.tsx | 20 +-------------- .../_authenticated+/settings+/_index.tsx | 5 ---- .../_authenticated+/settings+/_layout.tsx | 18 +++++++++++++ .../t.$teamUrl+/settings._index.tsx | 11 -------- .../t.$teamUrl+/settings.document.tsx | 11 -------- apps/remix/server/middleware.ts | 2 +- apps/remix/server/redirects.ts | 21 ++++++++++++++-- packages/auth/server/lib/utils/cookies.ts | 5 +++- packages/lib/client-only/cookies.ts | 17 +++++++++++++ packages/lib/constants/app.ts | 15 ++++++++++- packages/lib/utils/env.ts | 4 +++ 24 files changed, 129 insertions(+), 114 deletions(-) delete mode 100644 apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._index.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/settings+/_index.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx create mode 100644 packages/lib/client-only/cookies.ts diff --git a/apps/remix/app/components/forms/document-preferences-form.tsx b/apps/remix/app/components/forms/document-preferences-form.tsx index f35c9e55f..29e566c79 100644 --- a/apps/remix/app/components/forms/document-preferences-form.tsx +++ b/apps/remix/app/components/forms/document-preferences-form.tsx @@ -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; }; @@ -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({ diff --git a/apps/remix/app/components/forms/organisation-update-form.tsx b/apps/remix/app/components/forms/organisation-update-form.tsx index 5d4c2c88f..1c8a38097 100644 --- a/apps/remix/app/components/forms/organisation-update-form.tsx +++ b/apps/remix/app/components/forms/organisation-update-form.tsx @@ -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({ diff --git a/apps/remix/app/components/forms/team-update-form.tsx b/apps/remix/app/components/forms/team-update-form.tsx index 8dddaee2b..cfe82fc22 100644 --- a/apps/remix/app/components/forms/team-update-form.tsx +++ b/apps/remix/app/components/forms/team-update-form.tsx @@ -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); diff --git a/apps/remix/app/components/general/app-command-menu.tsx b/apps/remix/app/components/general/app-command-menu.tsx index 9746929b0..3a704445e 100644 --- a/apps/remix/app/components/general/app-command-menu.tsx +++ b/apps/remix/app/components/general/app-command-menu.tsx @@ -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`); diff --git a/apps/remix/app/components/general/org-menu-switcher.tsx b/apps/remix/app/components/general/org-menu-switcher.tsx index c50184338..dfa52d964 100644 --- a/apps/remix/app/components/general/org-menu-switcher.tsx +++ b/apps/remix/app/components/general/org-menu-switcher.tsx @@ -168,7 +168,7 @@ export const OrgMenuSwitcher = () => { {canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && (
@@ -221,7 +221,7 @@ export const OrgMenuSwitcher = () => { {canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
@@ -275,10 +275,10 @@ export const OrgMenuSwitcher = () => { Settings diff --git a/apps/remix/app/components/general/unified-settings-layout.tsx b/apps/remix/app/components/general/unified-settings-layout.tsx index 698656a8b..b9211b109 100644 --- a/apps/remix/app/components/general/unified-settings-layout.tsx +++ b/apps/remix/app/components/general/unified-settings-layout.tsx @@ -157,7 +157,7 @@ export const UnifiedSettingsLayout = ({ activeScope, preferredTeamUrl = null }: }} primaryButton={ diff --git a/apps/remix/app/components/tables/organisation-teams-table.tsx b/apps/remix/app/components/tables/organisation-teams-table.tsx index 86d1e2161..3591a33d4 100644 --- a/apps/remix/app/components/tables/organisation-teams-table.tsx +++ b/apps/remix/app/components/tables/organisation-teams-table.tsx @@ -76,7 +76,7 @@ export const OrganisationTeamsTable = () => { cell: ({ row }) => (
diff --git a/apps/remix/app/components/tables/user-organisations-table.tsx b/apps/remix/app/components/tables/user-organisations-table.tsx index 2002e00a3..be34d7e09 100644 --- a/apps/remix/app/components/tables/user-organisations-table.tsx +++ b/apps/remix/app/components/tables/user-organisations-table.tsx @@ -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 = () => {
{canExecuteOrganisationAction('MANAGE_ORGANISATION', row.original.currentOrganisationRole) && ( @@ -99,7 +97,7 @@ export const UserOrganisationsTable = () => { ), }, ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; - }, [isPersonalLayoutMode]); + }, []); return (
@@ -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 = () => { - {!isPersonalLayoutMode && ( - -
- - -
-
- )} + +
+ + +
+
), }} diff --git a/apps/remix/app/routes/_authenticated+/dashboard.tsx b/apps/remix/app/routes/_authenticated+/dashboard.tsx index 757323097..f53dfa43b 100644 --- a/apps/remix/app/routes/_authenticated+/dashboard.tsx +++ b/apps/remix/app/routes/_authenticated+/dashboard.tsx @@ -143,7 +143,7 @@ export default function DashboardPage() { {canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && (
- +
@@ -212,7 +212,7 @@ export default function DashboardPage() { {canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
- +
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx index 792806d2a..dfa60e1bf 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx @@ -115,7 +115,7 @@ export default function OrganisationSettingsTeamsPage() {
@@ -184,7 +184,7 @@ const TeamDropdownMenu = ({ team }: { team: TGetOrganisationSessionResponse[0][' - + Settings diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._index.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._index.tsx deleted file mode 100644 index 305643512..000000000 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._index.tsx +++ /dev/null @@ -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('/'); -} diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx index 4adee72db..aaff6b0d9 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx @@ -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 ; } diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx index 2ea13ac28..9def0b934 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx @@ -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([]); 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 (
@@ -181,7 +174,7 @@ export default function OrganisationSettingsBrandingPage() { {canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && ( diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx index 6b9aa0408..6331e5ece 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx @@ -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(); - - 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 (
@@ -117,7 +100,6 @@ export default function OrganisationSettingsDocumentPage() {
diff --git a/apps/remix/app/routes/_authenticated+/settings+/_index.tsx b/apps/remix/app/routes/_authenticated+/settings+/_index.tsx deleted file mode 100644 index f66113db0..000000000 --- a/apps/remix/app/routes/_authenticated+/settings+/_index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'react-router'; - -export function loader() { - throw redirect('/settings/profile'); -} diff --git a/apps/remix/app/routes/_authenticated+/settings+/_layout.tsx b/apps/remix/app/routes/_authenticated+/settings+/_layout.tsx index 67e426414..1bc929a42 100644 --- a/apps/remix/app/routes/_authenticated+/settings+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/settings+/_layout.tsx @@ -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 ; } diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx deleted file mode 100644 index 41b4e66ea..000000000 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx +++ /dev/null @@ -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('/'); -} diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx index a831978ee..e510ad48b 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx @@ -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(); - const team = useCurrentTeam(); const { t } = useLingui(); @@ -103,7 +93,6 @@ export default function TeamsSettingsPage() {
diff --git a/apps/remix/server/middleware.ts b/apps/remix/server/middleware.ts index 6f97abbba..a343dbc32 100644 --- a/apps/remix/server/middleware.ts +++ b/apps/remix/server/middleware.ts @@ -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); diff --git a/apps/remix/server/redirects.ts b/apps/remix/server/redirects.ts index 2c971d78d..8a11c5727 100644 --- a/apps/remix/server/redirects.ts +++ b/apps/remix/server/redirects.ts @@ -1,7 +1,6 @@ import type { Context } from 'hono'; -// eslint-disable-next-line @typescript-eslint/require-await -export const handleRedirects = async (c: Context): Promise => { +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 => { 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; }; diff --git a/packages/auth/server/lib/utils/cookies.ts b/packages/auth/server/lib/utils/cookies.ts index 15f5134f9..650d03694 100644 --- a/packages/auth/server/lib/utils/cookies.ts +++ b/packages/auth/server/lib/utils/cookies.ts @@ -1,10 +1,13 @@ /** * 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 => { const cookieHeader = headers.get('cookie') || ''; const cookiePairs = cookieHeader.split(';'); - const cookie = cookiePairs.find((pair) => pair.trim().startsWith(cookieName)); + const cookie = cookiePairs.find((pair) => pair.trim().startsWith(`${cookieName}=`)); if (!cookie) { return null; diff --git a/packages/lib/client-only/cookies.ts b/packages/lib/client-only/cookies.ts new file mode 100644 index 000000000..aeefe4094 --- /dev/null +++ b/packages/lib/client-only/cookies.ts @@ -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(); +}; diff --git a/packages/lib/constants/app.ts b/packages/lib/constants/app.ts index 0ab54d826..35887e870 100644 --- a/packages/lib/constants/app.ts +++ b/packages/lib/constants/app.ts @@ -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 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 diff --git a/packages/lib/utils/env.ts b/packages/lib/utils/env.ts index a8fbae65f..6a10456a3 100644 --- a/packages/lib/utils/env.ts +++ b/packages/lib/utils/env.ts @@ -56,4 +56,8 @@ export const createPublicEnv = () => ({ // Derived from the private transport so the client can detect CSC mode for // authoring UI gating without exposing the raw transport value. 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', });