Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@ NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=
# to the OIDC provider (rendering only a spinner). Set to "true" to disable this
# and keep showing the signin page.
NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=
# OPTIONAL: Break-glass password signin for redirect-only OIDC deployments.
# Comma-separated admin emails that may still sign in with a password via
# /signin?direct=1 while the OIDC provider is unreachable. Leave empty to
# disable the escape hatch entirely.
NEXT_PRIVATE_BREAK_GLASS_EMAILS=
# OPTIONAL: Set to true to use internal webapp url in browserless requests.
NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false

Expand Down
4 changes: 2 additions & 2 deletions apps/docs/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ const withMDX = createMDX();
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
async rewrites() {
rewrites() {
return [
{
source: '/docs/:path*.mdx',
destination: '/llms.mdx/docs/:path*',
},
];
},
async redirects() {
redirects() {
return [
// ============================================================
// Legacy docs site redirects (old site had no /docs prefix)
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/src/app/docs/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ const ROOT_SECTIONS = [
];

// Find first page item in folder children
function getFirstPageUrl(children: PageTree.Node[]): string | undefined {
function _getFirstPageUrl(children: PageTree.Node[]): string | undefined {
for (const child of children) {
if (child.type === 'page') {
return child.url;
}
if (child.type === 'folder' && child.children.length > 0) {
const url = getFirstPageUrl(child.children);
const url = _getFirstPageUrl(child.children);
if (url) {
return url;
}
Expand Down
1 change: 1 addition & 0 deletions apps/docs/src/app/og/docs/[...slug]/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
{/* biome-ignore lint/performance/noImgElement: OG image route renders a raw logo bitmap outside the Next Image context */}
<img src={logoSrc} alt="Documenso" height="28" />
<span
style={{
Expand Down
1 change: 1 addition & 0 deletions apps/docs/src/components/ai/page-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function LLMCopyButton({

return (
<button
type="button"
disabled={isLoading}
className={cn(
buttonVariants({
Expand Down
1 change: 1 addition & 0 deletions apps/docs/src/components/mdx/mermaid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,6 @@ const MermaidContent = ({ chart }: { chart: string }) => {
return null;
}

// biome-ignore lint/security/noDangerouslySetInnerHtml: renders the SVG produced by mermaid.render() in the browser, not user-controlled HTML
return <div ref={containerRef} dangerouslySetInnerHTML={{ __html: svg }} />;
};
2 changes: 1 addition & 1 deletion apps/docs/src/mdx-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { MDXComponents } from 'mdx/types';
import { EnvelopeWarning } from '@/components/mdx/envelope-warning';
import { Mermaid } from '@/components/mdx/mermaid';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: MDX component map typing mirrors upstream documenso
export function getMDXComponents(components?: MDXComponents): any {
return {
...defaultMdxComponents,
Expand Down
2 changes: 1 addition & 1 deletion apps/openpage-api/lib/cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ function getAllowedHeaders(req: Request, allowed?: string | string[]) {
const headers = new Headers();

if (!allowed) {
allowed = req.headers.get('Access-Control-Request-Headers')!;
allowed = req.headers.get('Access-Control-Request-Headers') ?? undefined;
headers.append('Vary', 'Access-Control-Request-Headers');
} else if (Array.isArray(allowed)) {
allowed = allowed.join(',');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative'
.sum(fn.count('id'))
// Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: Kysely window-function orderBy cannot be expressed type-safely (upstream workaround)
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']) as any))
.as('cume_count'),
])
Expand Down
1 change: 1 addition & 0 deletions apps/openpage-api/lib/growth/get-signer-conversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' =
fn
.sum(fn.count('Recipient.email').distinct())
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: Kysely window-function orderBy cannot be expressed type-safely (upstream workaround)
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'User.createdAt']) as any))
.as('cume_count'),
])
Expand Down
1 change: 1 addition & 0 deletions apps/openpage-api/lib/growth/get-user-monthly-growth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count
fn
.sum(fn.count('id'))
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: Kysely window-function orderBy cannot be expressed type-safely (upstream workaround)
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'User.createdAt']) as any))
.as('cume_count'),
])
Expand Down
8 changes: 4 additions & 4 deletions apps/openpage-api/lib/transform-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function transformData({ data, metric }: { data: DataEntry; metric: Metri
const [yearA, monthA] = dateA.split('-').map(Number);
const [yearB, monthB] = dateB.split('-').map(Number);

if (isNaN(yearA) || isNaN(monthA) || isNaN(yearB) || isNaN(monthB)) {
if (Number.isNaN(yearA) || Number.isNaN(monthA) || Number.isNaN(yearB) || Number.isNaN(monthB)) {
console.warn(`Invalid date format: ${dateA} or ${dateB}`);
return 0;
}
Expand All @@ -54,7 +54,7 @@ export function transformData({ data, metric }: { data: DataEntry; metric: Metri
try {
const [year, month] = date.split('-');

if (!year || !month || isNaN(Number(year)) || isNaN(Number(month))) {
if (!year || !month || Number.isNaN(Number(year)) || Number.isNaN(Number(month))) {
console.warn(`Invalid date format: ${date}`);
return date;
}
Expand Down Expand Up @@ -83,14 +83,14 @@ export function transformData({ data, metric }: { data: DataEntry; metric: Metri
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
data: sortedEntries.map(([_, stats]) => {
const value = stats[metric];
return typeof value === 'number' && !isNaN(value) ? value : 0;
return typeof value === 'number' && !Number.isNaN(value) ? value : 0;
}),
},
],
};

return addZeroMonth(transformedData, true);
} catch (error) {
} catch {
return {
labels: [],
datasets: [{ label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`, data: [] }],
Expand Down
25 changes: 17 additions & 8 deletions apps/remix/app/components/forms/signin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ export type SignInFormProps = {
isOIDCSSOEnabled?: boolean;
oidcProviderLabel?: string;
returnTo?: string;
/**
* Hide when the password form is only reachable via the break-glass door:
* the forgot-password flow stays disabled suite-wide in that mode, so the
* link would dead-end.
*/
showForgotPasswordLink?: boolean;
};

export const SignInForm = ({
Expand All @@ -76,6 +82,7 @@ export const SignInForm = ({
isOIDCSSOEnabled,
oidcProviderLabel,
returnTo,
showForgotPasswordLink = true,
}: SignInFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
Expand Down Expand Up @@ -362,14 +369,16 @@ export const SignInForm = ({

<FormMessage />

<p className="mt-2 text-right">
<Link
to="/forgot-password"
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
>
<Trans>Forgot your password?</Trans>
</Link>
</p>
{showForgotPasswordLink && (
<p className="mt-2 text-right">
<Link
to="/forgot-password"
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
>
<Trans>Forgot your password?</Trans>
</Link>
</p>
)}
</FormItem>
)}
/>
Expand Down
10 changes: 10 additions & 0 deletions apps/remix/app/routes/_authenticated+/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { OrganisationProvider } from '@documenso/lib/client-only/providers/organ
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
Expand Down Expand Up @@ -34,6 +35,15 @@ export async function loader({ request }: Route.LoaderArgs) {
]);

if (!session.isAuthenticated) {
// Preserve the originally requested path (including query string) so the
// OIDC login round-trip lands back on the page the user wanted.
const requestUrl = new URL(request.url);
const returnTo = `${requestUrl.pathname}${requestUrl.search}`;

if (isValidReturnTo(returnTo)) {
throw redirect(`/signin?returnTo=${encodeURIComponent(normalizeReturnTo(returnTo) ?? returnTo)}`);
}

throw redirect('/signin');
}

Expand Down
32 changes: 28 additions & 4 deletions apps/remix/app/routes/_unauthenticated+/signin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_AUTO_REDIRECT_DISABLED,
IS_OIDC_SSO_ENABLED,
isBreakGlassSigninEnabled,
isSigninEnabledForProvider,
isSignupEnabledForProvider,
OIDC_PROVIDER_LABEL,
Expand Down Expand Up @@ -44,6 +45,10 @@ export async function loader({ request }: Route.LoaderArgs) {

const shouldAutoRedirectToOIDC = isOIDCOnlyTransport && !IS_OIDC_AUTO_REDIRECT_DISABLED;

// Break-glass escape hatch: when password signin is disabled suite-wide,
// allowlisted admins can still reach the password form via ?direct=1.
const isBreakGlassAvailable = isBreakGlassSigninEnabled();

const oidcProviderLabel = OIDC_PROVIDER_LABEL;

const isSignupEnabled =
Expand All @@ -66,6 +71,7 @@ export async function loader({ request }: Route.LoaderArgs) {
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
isSignupEnabled,
isBreakGlassAvailable,
oidcProviderLabel,
returnTo,
shouldAutoRedirectToOIDC,
Expand All @@ -79,6 +85,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
isSignupEnabled,
isBreakGlassAvailable,
oidcProviderLabel,
returnTo,
shouldAutoRedirectToOIDC,
Expand All @@ -92,6 +99,16 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
const errorParam = searchParams.get('error');
const signupError = errorParam ? SIGNUP_ERROR_MESSAGES[errorParam] : undefined;

// Suppress the automatic IdP redirect when the user has explicitly asked
// for the break-glass form, when the IdP bounced us back with an error
// (otherwise we would restart the OIDC dance in a loop), or when embedded
// in a signing widget.
const isBreakGlassRequested = searchParams.get('direct') === '1' && isBreakGlassAvailable;
const hasIdpError = errorParam !== null;

const shouldRedirectToOIDC =
shouldAutoRedirectToOIDC && !isBreakGlassRequested && !hasIdpError && !isEmbeddedRedirect;

useEffect(() => {
const hash = window.location.hash.slice(1);

Expand All @@ -101,14 +118,20 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
}, []);

useEffect(() => {
if (!shouldAutoRedirectToOIDC) {
if (!shouldRedirectToOIDC) {
return;
}

// Guard against the initial render racing the embedded detection above:
// read the hash synchronously so embedded contexts never bounce to the IdP.
if (new URLSearchParams(window.location.hash.slice(1)).get('embedded') === 'true') {
return;
}

void authClient.oidc.signIn({ redirectPath: returnTo ?? '/' });
}, [shouldAutoRedirectToOIDC, returnTo]);
}, [shouldRedirectToOIDC, returnTo]);

if (shouldAutoRedirectToOIDC) {
if (shouldRedirectToOIDC) {
return (
<div className="w-screen max-w-lg px-4">
<div className="flex flex-col items-center justify-center gap-y-4 py-12">
Expand Down Expand Up @@ -140,7 +163,8 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
<hr className="-mx-6 my-4" />

<SignInForm
isEmailPasswordSigninEnabled={isEmailPasswordSigninEnabled}
isEmailPasswordSigninEnabled={isEmailPasswordSigninEnabled || isBreakGlassRequested}
showForgotPasswordLink={!isBreakGlassRequested || isEmailPasswordSigninEnabled}
isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
Expand Down
Loading
Loading