mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 02:53:32 +10:00
feat(remix): support serving app under a sub-path via NEXT_PUBLIC_BASE_PATH (#2824)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
|
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
|
||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import { zEmail } from '@documenso/lib/utils/zod';
|
import { zEmail } from '@documenso/lib/utils/zod';
|
||||||
@@ -44,7 +45,7 @@ const handleFallbackErrorMessages = (code: string) => {
|
|||||||
return message;
|
return message;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOGIN_REDIRECT_PATH = '/';
|
const LOGIN_REDIRECT_PATH = formatPath('/');
|
||||||
|
|
||||||
export const ZSignInFormSchema = z.object({
|
export const ZSignInFormSchema = z.object({
|
||||||
email: zEmail().min(1),
|
email: zEmail().min(1),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||||
import {
|
import {
|
||||||
DOCUMENTS_PAGE_SHORTCUT,
|
DOCUMENTS_PAGE_SHORTCUT,
|
||||||
@@ -862,7 +863,7 @@ const PromptLanguageCommands = ({
|
|||||||
|
|
||||||
formData.append('lang', lang);
|
formData.append('lang', lang);
|
||||||
|
|
||||||
const response = await fetch('/api/locale', {
|
const response = await fetch(formatPath('/api/locale'), {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-1
@@ -1,4 +1,5 @@
|
|||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||||
@@ -34,7 +35,9 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
redirectPath: formatPath(
|
||||||
|
`/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setIsSigningOut(false);
|
setIsSigningOut(false);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
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';
|
||||||
@@ -21,10 +22,10 @@ export const DocumentSigningAuthPageView = ({ email, emailHasAccount }: Document
|
|||||||
try {
|
try {
|
||||||
setIsSigningOut(true);
|
setIsSigningOut(true);
|
||||||
|
|
||||||
let redirectPath = '/signin';
|
let redirectPath = formatPath('/signin');
|
||||||
|
|
||||||
if (email) {
|
if (email) {
|
||||||
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`;
|
redirectPath = emailHasAccount ? formatPath(`/signin#email=${email}`) : formatPath(`/signup#email=${email}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
|
|||||||
+10
-7
@@ -1,5 +1,6 @@
|
|||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
|
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { getBasePath } from '@documenso/lib/constants/app';
|
||||||
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
||||||
import { createPublicEnv } from '@documenso/lib/utils/env';
|
import { createPublicEnv } from '@documenso/lib/utils/env';
|
||||||
import { extractLocaleData } from '@documenso/lib/utils/i18n';
|
import { extractLocaleData } from '@documenso/lib/utils/i18n';
|
||||||
@@ -20,7 +21,6 @@ import {
|
|||||||
useMatches,
|
useMatches,
|
||||||
} from 'react-router';
|
} from 'react-router';
|
||||||
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
|
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
|
||||||
|
|
||||||
import type { Route } from './+types/root';
|
import type { Route } from './+types/root';
|
||||||
import stylesheet from './app.css?url';
|
import stylesheet from './app.css?url';
|
||||||
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
||||||
@@ -68,6 +68,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
|
|||||||
lang,
|
lang,
|
||||||
theme: getTheme(),
|
theme: getTheme(),
|
||||||
disableAnimations,
|
disableAnimations,
|
||||||
|
basePath: getBasePath(),
|
||||||
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
|
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
|
||||||
// SSR-rendered <script>/<style> elements in this layout (and child
|
// SSR-rendered <script>/<style> elements in this layout (and child
|
||||||
// routes that need it) can carry the matching nonce attribute.
|
// routes that need it) can carry the matching nonce attribute.
|
||||||
@@ -90,10 +91,10 @@ export async function loader({ context, request }: Route.LoaderArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const { theme } = useLoaderData<typeof loader>() || {};
|
const { theme, basePath } = useLoaderData<typeof loader>() || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
|
<ThemeProvider specifiedTheme={theme} themeAction={`${basePath ?? ''}/api/theme`}>
|
||||||
<LayoutContent>{children}</LayoutContent>
|
<LayoutContent>{children}</LayoutContent>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
@@ -111,6 +112,8 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const [theme] = useTheme();
|
const [theme] = useTheme();
|
||||||
|
|
||||||
|
const basePath = data.basePath ?? '';
|
||||||
|
|
||||||
// Recipient routes (signing pages) put `documenso-branded` on <body> so the
|
// Recipient routes (signing pages) put `documenso-branded` on <body> so the
|
||||||
// <style> block from `RecipientBranding` applies to BOTH the main tree and
|
// <style> block from `RecipientBranding` applies to BOTH the main tree and
|
||||||
// any portaled content (Radix dialogs/popovers/dropdowns mount outside the
|
// any portaled content (Radix dialogs/popovers/dropdowns mount outside the
|
||||||
@@ -126,11 +129,11 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
|||||||
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''} suppressHydrationWarning>
|
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href={`${basePath}/apple-touch-icon.png`} />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href={`${basePath}/favicon-32x32.png`} />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href={`${basePath}/favicon-16x16.png`} />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href={`${basePath}/site.webmanifest`} />
|
||||||
<meta name="google" content="notranslate" />
|
<meta name="google" content="notranslate" />
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links nonce={nonce(cspNonce)} />
|
<Links nonce={nonce(cspNonce)} />
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
*
|
*
|
||||||
* No translations required.
|
* No translations required.
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router';
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
export const loader = () => {
|
export const loader = () => {
|
||||||
@@ -147,7 +148,7 @@ export default function EmbedPlaygroundPage() {
|
|||||||
return inputToken;
|
return inputToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('/api/v2/embedding/create-presign-token', {
|
const response = await fetch(formatPath('/api/v2/embedding/create-presign-token'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${inputToken}`,
|
Authorization: `Bearer ${inputToken}`,
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
"short_name": "Documenso",
|
"short_name": "Documenso",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/android-chrome-192x192.png",
|
"src": "./android-chrome-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/android-chrome-512x512.png",
|
"src": "./android-chrome-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,9 @@ import type { Config } from '@react-router/dev/config';
|
|||||||
export default {
|
export default {
|
||||||
appDirectory: 'app',
|
appDirectory: 'app',
|
||||||
ssr: true,
|
ssr: true,
|
||||||
|
// Must never be undefined and must start with the raw Vite `base` value,
|
||||||
|
// otherwise @react-router/dev crashes / exits on `react-router dev`. Both are
|
||||||
|
// kept without a trailing slash so they match exactly, and so the bare
|
||||||
|
// sub-path URL (e.g. "/ESign") still matches the basename at runtime.
|
||||||
|
basename: process.env.NEXT_PUBLIC_BASE_PATH ? process.env.NEXT_PUBLIC_BASE_PATH.replace(/\/$/, '') : '/',
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { type TDetectFieldsRequest, ZNormalizedFieldWithContextSchema } from './detect-fields.types';
|
import { type TDetectFieldsRequest, ZNormalizedFieldWithContextSchema } from './detect-fields.types';
|
||||||
@@ -69,7 +70,7 @@ export const detectFields = async ({
|
|||||||
onError,
|
onError,
|
||||||
signal,
|
signal,
|
||||||
}: DetectFieldsOptions): Promise<void> => {
|
}: DetectFieldsOptions): Promise<void> => {
|
||||||
const response = await fetch('/api/ai/detect-fields', {
|
const response = await fetch(formatPath('/api/ai/detect-fields'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
import { ZDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
import { ZDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ export const detectRecipients = async ({
|
|||||||
onError,
|
onError,
|
||||||
signal,
|
signal,
|
||||||
}: DetectRecipientsOptions): Promise<void> => {
|
}: DetectRecipientsOptions): Promise<void> => {
|
||||||
const response = await fetch('/api/ai/detect-recipients', {
|
const response = await fetch(formatPath('/api/ai/detect-recipients'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -14,9 +14,22 @@ import { getLoadContext } from './hono/server/load-context.js';
|
|||||||
import server from './hono/server/router.js';
|
import server from './hono/server/router.js';
|
||||||
import * as build from './index.js';
|
import * as build from './index.js';
|
||||||
|
|
||||||
|
// Sub-path the app is served under (e.g. "/ESign"). Empty = root.
|
||||||
|
// Must match the basePath used by the Hono router and the Vite `base`/RR
|
||||||
|
// `basename` so that hashed asset URLs like `/ESign/assets/app-xxx.css`
|
||||||
|
// resolve to files on disk at `build/client/assets/app-xxx.css`.
|
||||||
|
const basePath = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/$/, '');
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
serveStatic({
|
serveStatic({
|
||||||
root: 'build/client',
|
root: 'build/client',
|
||||||
|
rewriteRequestPath: (path) => {
|
||||||
|
if (basePath && (path === basePath || path.startsWith(`${basePath}/`))) {
|
||||||
|
const stripped = path.slice(basePath.length);
|
||||||
|
return stripped === '' ? '/' : stripped;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
},
|
||||||
onFound: (path, c) => {
|
onFound: (path, c) => {
|
||||||
if (path.startsWith('build/client/assets')) {
|
if (path.startsWith('build/client/assets')) {
|
||||||
// Hard cache assets with hashed file names.
|
// Hard cache assets with hashed file names.
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ export interface HonoEnv {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = new Hono<HonoEnv>();
|
const basePath = (env('NEXT_PUBLIC_BASE_PATH') ?? '').replace(/\/$/, '');
|
||||||
|
|
||||||
|
const app = new Hono<HonoEnv>().basePath(basePath || '/');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database-backed rate limiting for API routes.
|
* Database-backed rate limiting for API routes.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
|
import { API_V2_BETA_URL, API_V2_URL, formatPath } from '@documenso/lib/constants/app';
|
||||||
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
|
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
|
||||||
import { createTrpcContext } from '@documenso/trpc/server/context';
|
import { createTrpcContext } from '@documenso/trpc/server/context';
|
||||||
import { appRouter } from '@documenso/trpc/server/router';
|
import { appRouter } from '@documenso/trpc/server/router';
|
||||||
@@ -11,8 +11,10 @@ type OpenApiTrpcServerHandlerOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const openApiTrpcServerHandler = async (c: Context, { isBeta }: OpenApiTrpcServerHandlerOptions) => {
|
export const openApiTrpcServerHandler = async (c: Context, { isBeta }: OpenApiTrpcServerHandlerOptions) => {
|
||||||
|
const endpoint = formatPath(isBeta ? API_V2_BETA_URL : API_V2_URL) as `/${string}`;
|
||||||
|
|
||||||
return createOpenApiFetchHandler<typeof appRouter>({
|
return createOpenApiFetchHandler<typeof appRouter>({
|
||||||
endpoint: isBeta ? API_V2_BETA_URL : API_V2_URL,
|
endpoint,
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: async () => createTrpcContext({ c, requestSource: 'apiV2' }),
|
createContext: async () => createTrpcContext({ c, requestSource: 'apiV2' }),
|
||||||
req: c.req.raw,
|
req: c.req.raw,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
import { createTrpcContext } from '@documenso/trpc/server/context';
|
import { createTrpcContext } from '@documenso/trpc/server/context';
|
||||||
import { appRouter } from '@documenso/trpc/server/router';
|
import { appRouter } from '@documenso/trpc/server/router';
|
||||||
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
|
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
|
||||||
@@ -5,10 +6,14 @@ import { trpcServer } from '@hono/trpc-server';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Trpc server for internal routes like /api/trpc/*
|
* Trpc server for internal routes like /api/trpc/*
|
||||||
|
*
|
||||||
|
* `endpoint` must include the sub-path prefix (e.g. "/ESign") because the
|
||||||
|
* @hono/trpc-server adapter slices the prefix off the full URL pathname to
|
||||||
|
* compute the procedure name. Hono's `basePath` doesn't rewrite the URL.
|
||||||
*/
|
*/
|
||||||
export const reactRouterTrpcServer = trpcServer({
|
export const reactRouterTrpcServer = trpcServer({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
endpoint: '/api/trpc',
|
endpoint: formatPath('/api/trpc'),
|
||||||
createContext: async (_, c) => createTrpcContext({ c, requestSource: 'app' }),
|
createContext: async (_, c) => createTrpcContext({ c, requestSource: 'app' }),
|
||||||
onError: (opts) => handleTrpcRouterError(opts, 'trpc'),
|
onError: (opts) => handleTrpcRouterError(opts, 'trpc'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ const cMapsDir = normalizePath(path.join(pdfjsDistPath, 'cmaps'));
|
|||||||
* Do not configure any envs here.
|
* Do not configure any envs here.
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
// No trailing slash: the React Router dev server requires its `basename` to
|
||||||
|
// start with this raw value (see react-router.config.ts). Vite normalizes
|
||||||
|
// and joins asset URLs correctly either way.
|
||||||
|
base: process.env.NEXT_PUBLIC_BASE_PATH ? process.env.NEXT_PUBLIC_BASE_PATH.replace(/\/$/, '') : '/',
|
||||||
css: {
|
css: {
|
||||||
postcss: {
|
postcss: {
|
||||||
plugins: [tailwindcss, autoprefixer],
|
plugins: [tailwindcss, autoprefixer],
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ ENV NEXT_PRIVATE_ENCRYPTION_KEY="$NEXT_PRIVATE_ENCRYPTION_KEY"
|
|||||||
ARG NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
|
ARG NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
|
||||||
ENV NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="$NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY"
|
ENV NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="$NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY"
|
||||||
|
|
||||||
|
# Sub-path the app is served under (e.g. "/ESign"). Empty = root.
|
||||||
|
# Baked into the client bundle by Vite/React Router at build time; also
|
||||||
|
# required at runtime for SSR so window.__ENV__ exposes it to the client.
|
||||||
|
ARG NEXT_PUBLIC_BASE_PATH=""
|
||||||
|
ENV NEXT_PUBLIC_BASE_PATH="$NEXT_PUBLIC_BASE_PATH"
|
||||||
|
|
||||||
# Telemetry credentials (optional, baked into image at build time)
|
# Telemetry credentials (optional, baked into image at build time)
|
||||||
ARG NEXT_PRIVATE_TELEMETRY_KEY=""
|
ARG NEXT_PRIVATE_TELEMETRY_KEY=""
|
||||||
ENV NEXT_PRIVATE_TELEMETRY_KEY="$NEXT_PRIVATE_TELEMETRY_KEY"
|
ENV NEXT_PRIVATE_TELEMETRY_KEY="$NEXT_PRIVATE_TELEMETRY_KEY"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { formatPath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import type { ClientResponse, InferRequestType } from 'hono/client';
|
import type { ClientResponse, InferRequestType } from 'hono/client';
|
||||||
import { hc } from 'hono/client';
|
import { hc } from 'hono/client';
|
||||||
@@ -36,8 +36,6 @@ type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$
|
|||||||
export class AuthClient {
|
export class AuthClient {
|
||||||
public client: AuthClientType;
|
public client: AuthClientType;
|
||||||
|
|
||||||
private signOutredirectPath: string = '/signin';
|
|
||||||
|
|
||||||
constructor(options: { baseUrl: string }) {
|
constructor(options: { baseUrl: string }) {
|
||||||
this.client = hc<AuthAppType>(options.baseUrl);
|
this.client = hc<AuthAppType>(options.baseUrl);
|
||||||
}
|
}
|
||||||
@@ -45,7 +43,7 @@ export class AuthClient {
|
|||||||
public async signOut({ redirectPath }: { redirectPath?: string } = {}) {
|
public async signOut({ redirectPath }: { redirectPath?: string } = {}) {
|
||||||
await this.client.signout.$post();
|
await this.client.signout.$post();
|
||||||
|
|
||||||
window.location.href = redirectPath ?? this.signOutredirectPath;
|
window.location.href = redirectPath ?? formatPath('/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async signOutAllSessions() {
|
public async signOutAllSessions() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { formatPath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import {
|
import {
|
||||||
isDisposableEmail,
|
isDisposableEmail,
|
||||||
isEmailDomainAllowedForSignup,
|
isEmailDomainAllowedForSignup,
|
||||||
@@ -121,7 +121,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
|||||||
|
|
||||||
// Check if signups are disabled for this provider.
|
// Check if signups are disabled for this provider.
|
||||||
if (!isSignupEnabledForProvider(clientOptions.id as 'google' | 'microsoft' | 'oidc')) {
|
if (!isSignupEnabledForProvider(clientOptions.id as 'google' | 'microsoft' | 'oidc')) {
|
||||||
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
|
const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
|
||||||
|
|
||||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
|
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
|||||||
|
|
||||||
// Check domain restriction for new SSO users.
|
// Check domain restriction for new SSO users.
|
||||||
if (!isEmailDomainAllowedForSignup(email)) {
|
if (!isEmailDomainAllowedForSignup(email)) {
|
||||||
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
|
const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
|
||||||
|
|
||||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
|
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
|||||||
const additionalBlockedDomains = await getEmailBlocklistDomains();
|
const additionalBlockedDomains = await getEmailBlocklistDomains();
|
||||||
|
|
||||||
if (isDisposableEmail(email, additionalBlockedDomains)) {
|
if (isDisposableEmail(email, additionalBlockedDomains)) {
|
||||||
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
|
const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
|
||||||
|
|
||||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
|
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
|
||||||
|
|
||||||
@@ -213,15 +213,18 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
|
|||||||
// eslint-disable-next-line prefer-const
|
// eslint-disable-next-line prefer-const
|
||||||
let [redirectState, redirectPath] = storedRedirectPath.split(' ');
|
let [redirectState, redirectPath] = storedRedirectPath.split(' ');
|
||||||
|
|
||||||
|
// The sub-path aware root, e.g. "/" or "/ESign/".
|
||||||
|
const defaultRedirectPath = formatPath('/');
|
||||||
|
|
||||||
if (redirectState !== storedState || !redirectPath) {
|
if (redirectState !== storedState || !redirectPath) {
|
||||||
redirectPath = '/';
|
redirectPath = defaultRedirectPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidReturnTo(redirectPath)) {
|
if (!isValidReturnTo(redirectPath)) {
|
||||||
redirectPath = '/';
|
redirectPath = defaultRedirectPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectPath = normalizeReturnTo(redirectPath) || '/';
|
redirectPath = normalizeReturnTo(redirectPath) || defaultRedirectPath;
|
||||||
|
|
||||||
const tokens = await oAuthClient.validateAuthorizationCode(token_endpoint, code, storedCodeVerifier);
|
const tokens = await oAuthClient.validateAuthorizationCode(token_endpoint, code, storedCodeVerifier);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
|
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
|
||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
import { isDisposableEmail, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
import { isDisposableEmail, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
|
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
|
||||||
@@ -56,7 +57,7 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
|
|||||||
if (existingAccount) {
|
if (existingAccount) {
|
||||||
await onAuthorize({ userId: existingAccount.user.id }, c);
|
await onAuthorize({ userId: existingAccount.user.id }, c);
|
||||||
|
|
||||||
return c.redirect(`/o/${orgUrl}`, 302);
|
return c.redirect(formatPath(`/o/${orgUrl}`), 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
let userToLink = await prisma.user.findFirst({
|
let userToLink = await prisma.user.findFirst({
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the default redirect target ("/" at the root, "/ESign/" when served under a sub-path).
|
||||||
|
*/
|
||||||
|
const getDefaultRedirect = () => {
|
||||||
|
try {
|
||||||
|
const pathname = new URL(NEXT_PUBLIC_WEBAPP_URL()).pathname.replace(/\/$/, '');
|
||||||
|
return `${pathname}/`;
|
||||||
|
} catch {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWebAppOrigin = () => {
|
||||||
|
try {
|
||||||
|
return new URL(NEXT_PUBLIC_WEBAPP_URL()).origin;
|
||||||
|
} catch {
|
||||||
|
return NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an optional redirect path.
|
* Handle an optional redirect path.
|
||||||
*/
|
*/
|
||||||
@@ -10,19 +30,20 @@ export const handleRequestRedirect = (redirectUrl?: string) => {
|
|||||||
|
|
||||||
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
|
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
|
||||||
|
|
||||||
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
|
if (url.origin !== getWebAppOrigin()) {
|
||||||
window.location.href = '/';
|
window.location.href = getDefaultRedirect();
|
||||||
} else {
|
} else {
|
||||||
window.location.href = redirectUrl;
|
window.location.href = redirectUrl;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleSignInRedirect = (redirectUrl: string = '/') => {
|
export const handleSignInRedirect = (redirectUrl?: string) => {
|
||||||
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
|
const target = redirectUrl ?? getDefaultRedirect();
|
||||||
|
const url = new URL(target, NEXT_PUBLIC_WEBAPP_URL());
|
||||||
|
|
||||||
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
|
if (url.origin !== getWebAppOrigin()) {
|
||||||
window.location.href = '/';
|
window.location.href = getDefaultRedirect();
|
||||||
} else {
|
} else {
|
||||||
window.location.href = redirectUrl;
|
window.location.href = target;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export type GetLimitsOptions = {
|
|||||||
export const getLimits = async ({ headers, teamId }: GetLimitsOptions) => {
|
export const getLimits = async ({ headers, teamId }: GetLimitsOptions) => {
|
||||||
const requestHeaders = headers ?? {};
|
const requestHeaders = headers ?? {};
|
||||||
|
|
||||||
const url = new URL('/api/limits', NEXT_PUBLIC_WEBAPP_URL());
|
// Note: the path must be appended rather than passed as the `new URL()` path
|
||||||
|
// argument, since a leading-slash path replaces the sub-path that
|
||||||
|
// NEXT_PUBLIC_WEBAPP_URL may carry (e.g. https://host/ESign).
|
||||||
|
const url = new URL(`${NEXT_PUBLIC_WEBAPP_URL()}/api/limits`);
|
||||||
|
|
||||||
if (teamId) {
|
if (teamId) {
|
||||||
requestHeaders['team-id'] = teamId.toString();
|
requestHeaders['team-id'] = teamId.toString();
|
||||||
|
|||||||
@@ -6,6 +6,42 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = Number(env('NEXT_PUBLIC_DOCUMENT_S
|
|||||||
|
|
||||||
export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
|
export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sub-path the app is served under (no trailing slash), e.g. "/ESign".
|
||||||
|
* Returns an empty string when served at root.
|
||||||
|
*
|
||||||
|
* Prefers the explicit NEXT_PUBLIC_BASE_PATH (which is the same value baked
|
||||||
|
* into the Vite/React Router build). Falls back to the pathname of
|
||||||
|
* NEXT_PUBLIC_WEBAPP_URL so the function still works in dev when the env
|
||||||
|
* variable is unset.
|
||||||
|
*
|
||||||
|
* Avoid using this to build URLs, use {@link formatPath} instead. Reserve this
|
||||||
|
* for cases where the raw prefix itself is needed, such as path comparisons.
|
||||||
|
*/
|
||||||
|
export const getBasePath = (): string => {
|
||||||
|
const explicit = env('NEXT_PUBLIC_BASE_PATH');
|
||||||
|
|
||||||
|
if (explicit) {
|
||||||
|
return explicit.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(NEXT_PUBLIC_WEBAPP_URL()).pathname.replace(/\/$/, '');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefix a root-relative path with the app's base path.
|
||||||
|
*
|
||||||
|
* `formatPath('/api/trpc')` -> `/ESign/api/trpc` under sub-path hosting,
|
||||||
|
* `/api/trpc` otherwise.
|
||||||
|
*/
|
||||||
|
export const formatPath = (path: string): string => {
|
||||||
|
return `${getBasePath()}${path}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const NEXT_PUBLIC_SIGNING_CONTACT_INFO = () =>
|
export const NEXT_PUBLIC_SIGNING_CONTACT_INFO = () =>
|
||||||
env('NEXT_PUBLIC_SIGNING_CONTACT_INFO') ?? NEXT_PUBLIC_WEBAPP_URL();
|
env('NEXT_PUBLIC_SIGNING_CONTACT_INFO') ?? NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL, getBasePath } from '../constants/app';
|
||||||
import { env } from '../utils/env';
|
import { env } from '../utils/env';
|
||||||
|
|
||||||
export const getBaseUrl = () => {
|
export const getBaseUrl = () => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
return '';
|
return getBasePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
const webAppUrl = NEXT_PUBLIC_WEBAPP_URL();
|
const webAppUrl = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { DocumentDataType } from '@prisma/client';
|
|||||||
import { base64 } from '@scure/base';
|
import { base64 } from '@scure/base';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { formatPath } from '../../constants/app';
|
||||||
|
|
||||||
export type GetFileOptions = {
|
export type GetFileOptions = {
|
||||||
type: DocumentDataType;
|
type: DocumentDataType;
|
||||||
data: string;
|
data: string;
|
||||||
@@ -36,7 +38,7 @@ const getFileFromBytes64 = (data: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getFileFromS3 = async (key: string) => {
|
const getFileFromS3 = async (key: string) => {
|
||||||
const getPresignedUrlResponse = await fetch(`/api/files/presigned-get-url`, {
|
const getPresignedUrlResponse = await fetch(formatPath('/api/files/presigned-get-url'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
|
import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
|
||||||
|
|
||||||
|
import { formatPath } from '../../constants/app';
|
||||||
import { AppError } from '../../errors/app-error';
|
import { AppError } from '../../errors/app-error';
|
||||||
|
|
||||||
type File = {
|
type File = {
|
||||||
@@ -38,7 +39,7 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => {
|
|||||||
|
|
||||||
formData.append('file', properFile);
|
formData.append('file', properFile);
|
||||||
|
|
||||||
const response = await fetch('/api/files/upload-pdf', {
|
const response = await fetch(formatPath('/api/files/upload-pdf'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: buildUploadAuthHeaders(options),
|
headers: buildUploadAuthHeaders(options),
|
||||||
body: formData,
|
body: formData,
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { getBasePath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The origin of the web app, ignoring any sub-path NEXT_PUBLIC_WEBAPP_URL carries.
|
||||||
|
*/
|
||||||
|
const getWebAppOrigin = () => {
|
||||||
|
try {
|
||||||
|
return new URL(NEXT_PUBLIC_WEBAPP_URL()).origin;
|
||||||
|
} catch {
|
||||||
|
return NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const isValidReturnTo = (returnTo?: string) => {
|
export const isValidReturnTo = (returnTo?: string) => {
|
||||||
if (!returnTo) {
|
if (!returnTo) {
|
||||||
@@ -10,7 +21,10 @@ export const isValidReturnTo = (returnTo?: string) => {
|
|||||||
const decodedReturnTo = decodeURIComponent(returnTo);
|
const decodedReturnTo = decodeURIComponent(returnTo);
|
||||||
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
|
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
|
||||||
|
|
||||||
if (returnToUrl.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
|
// Compare against the origin, not the raw env value: when the app is served
|
||||||
|
// under a sub-path NEXT_PUBLIC_WEBAPP_URL is e.g. "https://host/ESign", which
|
||||||
|
// never equals a URL's origin ("https://host").
|
||||||
|
if (returnToUrl.origin !== getWebAppOrigin()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +44,17 @@ export const normalizeReturnTo = (returnTo?: string) => {
|
|||||||
const decodedReturnTo = decodeURIComponent(returnTo);
|
const decodedReturnTo = decodeURIComponent(returnTo);
|
||||||
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
|
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
|
||||||
|
|
||||||
return `${returnToUrl.pathname}${returnToUrl.search}${returnToUrl.hash}`;
|
const basePath = getBasePath();
|
||||||
|
|
||||||
|
let pathname = returnToUrl.pathname;
|
||||||
|
|
||||||
|
// A root-relative returnTo ("/inbox") resolves to a pathname without the
|
||||||
|
// sub-path, so re-apply it when it is missing.
|
||||||
|
if (basePath && pathname !== basePath && !pathname.startsWith(`${basePath}/`)) {
|
||||||
|
pathname = `${basePath}${pathname}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${pathname}${returnToUrl.search}${returnToUrl.hash}`;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -23,7 +24,7 @@ export const LanguageSwitcherDialog = ({ open, setOpen }: LanguageSwitcherDialog
|
|||||||
|
|
||||||
formData.append('lang', lang);
|
formData.append('lang', lang);
|
||||||
|
|
||||||
await fetch('/api/locale', {
|
await fetch(formatPath('/api/locale'), {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user