From 8d676b00b06458668e5289bdd4a2c40466255a86 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:37:26 +0700 Subject: [PATCH 1/5] feat(auth): redirect-only OIDC login with admin break-glass escape hatch - /signup auto-redirects when OIDC is the only enabled signup transport (mirrors the upstream /signin behaviour), preserving returnTo - suppress the automatic redirect on IdP error bounce (?error=), ?direct=1 and #embedded=true so failures render a page instead of looping - unauthenticated deep links redirect to /signin?returnTo= so the OIDC round-trip lands back on the originally requested page - break-glass: NEXT_PRIVATE_BREAK_GLASS_EMAILS allowlist keeps password signin reachable via /signin?direct=1 while the OIDC provider is unreachable; enforced server-side in /api/auth/email-password/authorize so regular users keep no password path - unit tests for the allowlist parsing/matching; document the mode in docs/ARCHITECTURE.md and .env.example --- .env.example | 5 ++ .../app/routes/_authenticated+/_layout.tsx | 10 ++++ .../app/routes/_unauthenticated+/signin.tsx | 31 ++++++++-- .../app/routes/_unauthenticated+/signup.tsx | 56 ++++++++++++++++++- docs/ARCHITECTURE.md | 12 ++++ packages/auth/server/routes/email-password.ts | 10 +++- packages/lib/constants/auth.test.ts | 35 ++++++++++++ packages/lib/constants/auth.ts | 27 ++++++++- 8 files changed, 176 insertions(+), 10 deletions(-) create mode 100644 packages/lib/constants/auth.test.ts diff --git a/.env.example b/.env.example index 05cee19c8f..0d462a6e1b 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/remix/app/routes/_authenticated+/_layout.tsx b/apps/remix/app/routes/_authenticated+/_layout.tsx index 9c1054b237..762b1f8942 100644 --- a/apps/remix/app/routes/_authenticated+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/_layout.tsx @@ -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'; @@ -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'); } diff --git a/apps/remix/app/routes/_unauthenticated+/signin.tsx b/apps/remix/app/routes/_unauthenticated+/signin.tsx index 4f9920fc3b..54c1036d38 100644 --- a/apps/remix/app/routes/_unauthenticated+/signin.tsx +++ b/apps/remix/app/routes/_unauthenticated+/signin.tsx @@ -5,6 +5,7 @@ import { IS_MICROSOFT_SSO_ENABLED, IS_OIDC_AUTO_REDIRECT_DISABLED, IS_OIDC_SSO_ENABLED, + isBreakGlassSigninEnabled, isSigninEnabledForProvider, isSignupEnabledForProvider, OIDC_PROVIDER_LABEL, @@ -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 = @@ -66,6 +71,7 @@ export async function loader({ request }: Route.LoaderArgs) { isMicrosoftSSOEnabled, isOIDCSSOEnabled, isSignupEnabled, + isBreakGlassAvailable, oidcProviderLabel, returnTo, shouldAutoRedirectToOIDC, @@ -79,6 +85,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) { isMicrosoftSSOEnabled, isOIDCSSOEnabled, isSignupEnabled, + isBreakGlassAvailable, oidcProviderLabel, returnTo, shouldAutoRedirectToOIDC, @@ -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); @@ -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 (
@@ -140,7 +163,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
{ + if (!shouldRedirectToOIDC) { + return; + } + + void authClient.oidc.signIn({ redirectPath: returnTo ?? '/' }); + }, [shouldRedirectToOIDC, returnTo]); + + if (shouldRedirectToOIDC) { + return ( +
+
+ +

+ Redirecting to {oidcProviderLabel || 'OIDC'}... +

+
+
+ ); + } + return ( Browser Crove Sign (App) DOS.Me ID (Supabase A │<── Redirect to /inbox ────────┤ │ ``` +### 4.3. Redirect-Only Mode & Break-Glass + +When email/password signin and signup are disabled (`NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN/SIGNUP=true`) and OIDC is the only enabled transport, `/signin` and `/signup` skip the button page entirely and auto-redirect to the OIDC provider (opt out with `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=true`). The automatic redirect is suppressed when: + +- The IdP bounced the user back with `?error=...` (prevents a redirect loop; the error alert and a manual retry button are shown instead). +- The URL carries `#embedded=true` (embedded signing widgets must not bounce to the IdP). +- The URL carries `?direct=1` and the break-glass allowlist is configured. + +**Break-glass** (`NEXT_PRIVATE_BREAK_GLASS_EMAILS`, comma-separated admin emails): with password signin disabled suite-wide, allowlisted admins keep a manual password escape hatch via `/signin?direct=1` for use when the OIDC provider is unreachable. The allowlist is enforced server-side in `POST /api/auth/email-password/authorize` (non-allowlisted emails still receive `SigninDisabled`), so the credential-stuffing surface stays limited to the admin emails. Regular users have no password path. + +Deep links are preserved end to end: unauthenticated access to authenticated routes redirects to `/signin?returnTo=`, and `returnTo` is validated (`isValidReturnTo`) and carried through the OIDC round-trip back to the original page. + --- ## 5. Crove OS 2-Tier Hybrid Architecture & Data Sync diff --git a/packages/auth/server/routes/email-password.ts b/packages/auth/server/routes/email-password.ts index 8d890b81a6..6cdda13847 100644 --- a/packages/auth/server/routes/email-password.ts +++ b/packages/auth/server/routes/email-password.ts @@ -1,4 +1,5 @@ import { + isBreakGlassEmail, isDisposableEmail, isEmailDomainAllowedForSignup, isSigninEnabledForProvider, @@ -65,14 +66,17 @@ export const emailPasswordRoute = new Hono() .post('/authorize', sValidator('json', ZSignInSchema), async (c) => { const requestMetadata = c.get('requestMetadata'); - if (!isSigninEnabledForProvider('email')) { + const { email, password, totpCode, backupCode, csrfToken, captchaToken } = c.req.valid('json'); + + // Break-glass: when password signin is disabled suite-wide, allowlisted + // admin emails (see NEXT_PRIVATE_BREAK_GLASS_EMAILS) may still sign in + // via /signin?direct=1 while the OIDC provider is unreachable. + if (!isSigninEnabledForProvider('email') && !isBreakGlassEmail(email)) { throw new AppError(AuthenticationErrorCode.SigninDisabled, { statusCode: 400, }); } - const { email, password, totpCode, backupCode, csrfToken, captchaToken } = c.req.valid('json'); - const loginLimitResult = await loginRateLimit.check({ ip: requestMetadata.ipAddress ?? 'unknown', identifier: email, diff --git a/packages/lib/constants/auth.test.ts b/packages/lib/constants/auth.test.ts new file mode 100644 index 0000000000..06403ab1e8 --- /dev/null +++ b/packages/lib/constants/auth.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getBreakGlassEmails, isBreakGlassEmail, isBreakGlassSigninEnabled } from './auth'; + +describe('break-glass password signin allowlist', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('returns an empty allowlist when the env var is unset or blank', () => { + expect(getBreakGlassEmails()).toEqual([]); + expect(isBreakGlassSigninEnabled()).toBe(false); + + vi.stubEnv('NEXT_PRIVATE_BREAK_GLASS_EMAILS', ''); + + expect(getBreakGlassEmails()).toEqual([]); + expect(isBreakGlassSigninEnabled()).toBe(false); + }); + + it('parses comma-separated emails with surrounding whitespace and mixed case', () => { + vi.stubEnv('NEXT_PRIVATE_BREAK_GLASS_EMAILS', ' Joy@Dos.AI , admin@crove.com ,, '); + + expect(getBreakGlassEmails()).toEqual(['joy@dos.ai', 'admin@crove.com']); + expect(isBreakGlassSigninEnabled()).toBe(true); + }); + + it('matches allowlisted emails case-insensitively and rejects everyone else', () => { + vi.stubEnv('NEXT_PRIVATE_BREAK_GLASS_EMAILS', 'Joy@dos.ai'); + + expect(isBreakGlassEmail('joy@dos.ai')).toBe(true); + expect(isBreakGlassEmail(' JOY@DOS.AI ')).toBe(true); + expect(isBreakGlassEmail('admin@dos.ai')).toBe(false); + expect(isBreakGlassEmail('')).toBe(false); + }); +}); diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts index cb514979b5..20230a0e5f 100644 --- a/packages/lib/constants/auth.ts +++ b/packages/lib/constants/auth.ts @@ -34,6 +34,31 @@ export const OIDC_PROVIDER_LABEL = env('NEXT_PRIVATE_OIDC_PROVIDER_LABEL'); */ export const IS_OIDC_AUTO_REDIRECT_DISABLED = env('NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT') === 'true'; +/** + * Break-glass password signin allowlist for redirect-only OIDC deployments. + * + * When email/password signin is disabled suite-wide (redirect-only DOS ID + * login), these comma-separated admin emails keep a manual password escape + * hatch reachable via `/signin?direct=1`, for use when the OIDC provider is + * unreachable. Regular users have no password path. + */ +export const getBreakGlassEmails = (): string[] => { + const emails = env('NEXT_PRIVATE_BREAK_GLASS_EMAILS'); + + if (!emails) { + return []; + } + + return emails + .split(',') + .map((email) => email.trim().toLowerCase()) + .filter(Boolean); +}; + +export const isBreakGlassSigninEnabled = () => getBreakGlassEmails().length > 0; + +export const isBreakGlassEmail = (email: string) => getBreakGlassEmails().includes(email.trim().toLowerCase()); + export const USER_SECURITY_AUDIT_LOG_MAP: Record = { ACCOUNT_SSO_LINK: 'Linked account to SSO', ACCOUNT_SSO_UNLINK: 'Unlinked account from SSO', @@ -131,7 +156,7 @@ export const isEmailDomainAllowedForSignup = (email: string): boolean => { * pre-normalised (trimmed + lowercased) by the caller. * * Returns `true` when the email is disposable and should be rejected. - * Email format validation is intentionally NOT performed here — that is + * Email format validation is intentionally NOT performed here; that is * handled by Zod upstream. */ export const isDisposableEmail = (email: string, additionalBlockedDomains: string[] = []): boolean => { From 61abbdeaf3803e66ecbb3ae74cfe9d1f13f4d166 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:03:06 +0700 Subject: [PATCH 2/5] fix(auth): address fresh-context review findings - prefix-match the two session-invalidation e2e assertions so the new returnTo-threading redirect (/signin?returnTo=...) does not fail them - hide the forgot-password link when the password form is only reachable via the break-glass door (the forgot flow stays disabled suite-wide) - document the accepted allowlist-membership probe signal and the deliberate override of the NEXT_PUBLIC_DISABLE_SIGNIN master switch --- apps/remix/app/components/forms/signin.tsx | 25 +++++++++++++------ .../app/routes/_unauthenticated+/signin.tsx | 1 + docs/ARCHITECTURE.md | 2 ++ packages/app-tests/e2e/user/password.spec.ts | 4 +-- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/remix/app/components/forms/signin.tsx b/apps/remix/app/components/forms/signin.tsx index 6270bc948c..29f6fd5de7 100644 --- a/apps/remix/app/components/forms/signin.tsx +++ b/apps/remix/app/components/forms/signin.tsx @@ -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 = ({ @@ -76,6 +82,7 @@ export const SignInForm = ({ isOIDCSSOEnabled, oidcProviderLabel, returnTo, + showForgotPasswordLink = true, }: SignInFormProps) => { const { _ } = useLingui(); const { toast } = useToast(); @@ -362,14 +369,16 @@ export const SignInForm = ({ -

- - Forgot your password? - -

+ {showForgotPasswordLink && ( +

+ + Forgot your password? + +

+ )} )} /> diff --git a/apps/remix/app/routes/_unauthenticated+/signin.tsx b/apps/remix/app/routes/_unauthenticated+/signin.tsx index 54c1036d38..3b13832b4b 100644 --- a/apps/remix/app/routes/_unauthenticated+/signin.tsx +++ b/apps/remix/app/routes/_unauthenticated+/signin.tsx @@ -164,6 +164,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) { `, and `returnTo` is validated (`isValidReturnTo`) and carried through the OIDC round-trip back to the original page. --- diff --git a/packages/app-tests/e2e/user/password.spec.ts b/packages/app-tests/e2e/user/password.spec.ts index 6bee822624..f09a811752 100644 --- a/packages/app-tests/e2e/user/password.spec.ts +++ b/packages/app-tests/e2e/user/password.spec.ts @@ -153,7 +153,7 @@ test('[USER] password reset invalidates all sessions', async ({ page }: { page: await page.context().addCookies(initialCookies); await page.goto('http://localhost:3000/settings/profile'); - await expect(page).toHaveURL('http://localhost:3000/signin'); + await expect(page).toHaveURL(/^http:\/\/localhost:3000\/signin/); expect(await checkSessionValid(page)).toBe(false); @@ -209,7 +209,7 @@ test('[USER] password update invalidates other sessions but keeps current', asyn await page.context().clearCookies(); await page.context().addCookies(initialCookies); await page.goto('http://localhost:3000/settings/profile'); - await expect(page).toHaveURL('http://localhost:3000/signin'); + await expect(page).toHaveURL(/^http:\/\/localhost:3000\/signin/); expect(await checkSessionValid(page)).toBe(false); await page.context().clearCookies(); From 0925e8dcf7a43295cfe557389b338e70cc3f2c65 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:14:21 +0700 Subject: [PATCH 3/5] chore(lint): repair pre-existing biome violations blocking CI The Lint job has been failing on main since 72ce0404 (2026-09-14) due to formatting and lint debt in scripts/*.mjs and apps/docs/* - none of it touched by this PR. Apply biome's mechanical fixes (formatting, unused imports/variables, template style, button type) so the job goes green for this PR and subsequent work. --- scripts/deploy-crove-resolver.mjs | 17 +- scripts/generate-comprehensive-vi-po.mjs | 501 ++++++++++++----------- scripts/patch-crove-branding.mjs | 18 +- scripts/sync-upstream.mjs | 8 +- 4 files changed, 294 insertions(+), 250 deletions(-) diff --git a/scripts/deploy-crove-resolver.mjs b/scripts/deploy-crove-resolver.mjs index 176ff4ea75..85dc7b709f 100644 --- a/scripts/deploy-crove-resolver.mjs +++ b/scripts/deploy-crove-resolver.mjs @@ -10,7 +10,6 @@ * node scripts/deploy-crove-resolver.mjs [--network=dos-testnet|dos-mainnet] [--dry-run] */ -import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -64,8 +63,12 @@ async function main() { console.log(`📜 Schema v2 Definition : "${CROVE_SCHEMA_V2}"\n`); console.log('📋 Contract Architecture & Deliverables:'); - console.log(' - CroveAnchorGateway : contracts/gateway/CroveAnchorGateway.sol (Anti-replay, Relayer Access Control, Multi-PDF batching)'); - console.log(' - CroveResolver : contracts/resolver/CroveResolver.sol (Reverse lookup: artifactRoot -> UID[], envelopeHash -> UID[])'); + console.log( + ' - CroveAnchorGateway : contracts/gateway/CroveAnchorGateway.sol (Anti-replay, Relayer Access Control, Multi-PDF batching)', + ); + console.log( + ' - CroveResolver : contracts/resolver/CroveResolver.sol (Reverse lookup: artifactRoot -> UID[], envelopeHash -> UID[])', + ); console.log(' - EAS Base Resolver : contracts/resolver/SchemaResolver.sol'); console.log(' - EAS Interfaces : contracts/interfaces/IEAS.sol, ISchemaResolver.sol'); console.log(' - TypeScript ABI : packages/lib/server-only/blockchain/resolver-abi.ts\n'); @@ -76,11 +79,15 @@ async function main() { console.log(' $env:DEPLOYER_PRIVATE_KEY=""; node scripts/deploy-crove-resolver.mjs --network=dos-testnet\n'); console.log('📌 Ordered Deployment Pipeline:'); console.log(' Step 1: Deploy CroveResolver(easAddress, ownerAddress, address(0))'); - console.log(' Step 2: Register Schema v2 on SchemaRegistry.register(schema, croveResolverAddress, revocable=false)'); + console.log( + ' Step 2: Register Schema v2 on SchemaRegistry.register(schema, croveResolverAddress, revocable=false)', + ); console.log(' Step 3: Deploy CroveAnchorGateway(easAddress, ownerAddress, croveRelayerAddress, schemaUID)'); console.log(' Step 4: Configure CroveResolver.setTrustedGateway(croveAnchorGatewayAddress)'); console.log(' Step 5: Configure CroveResolver.setSchemaUID(schemaUID)'); - console.log(' Step 6: Update CROVE_ANCHOR_GATEWAY_ADDRESS and CROVE_RESOLVER_ADDRESS in .env & docs/ARCHITECTURE.md\n'); + console.log( + ' Step 6: Update CROVE_ANCHOR_GATEWAY_ADDRESS and CROVE_RESOLVER_ADDRESS in .env & docs/ARCHITECTURE.md\n', + ); return; } diff --git a/scripts/generate-comprehensive-vi-po.mjs b/scripts/generate-comprehensive-vi-po.mjs index de7c435d85..f3c6bb8bb5 100644 --- a/scripts/generate-comprehensive-vi-po.mjs +++ b/scripts/generate-comprehensive-vi-po.mjs @@ -12,256 +12,277 @@ const viPoPath = path.join(ROOT_DIR, 'packages', 'lib', 'translations', 'vi', 'w // Comprehensive dictionary for Documenso / Crove Sign e-signing platform const VI_TRANSLATIONS = new Map([ // Navigation & Shell - ["Dashboard", "Bảng điều khiển"], - ["Documents", "Tài liệu"], - ["Templates", "Mẫu tài liệu"], - ["Signatures", "Chữ ký"], - ["Settings", "Cài đặt"], - ["Inbox", "Hộp thư"], - ["Personal Inbox", "Hộp thư cá nhân"], - ["Profile", "Hồ sơ cá nhân"], - ["Account", "Tài khoản"], - ["Team", "Đội nhóm"], - ["Teams", "Các đội nhóm"], - ["Organisation", "Tổ chức"], - ["Organisations", "Các tổ chức"], - ["Organization", "Tổ chức"], - ["Organizations", "Các tổ chức"], - ["General", "Chung"], - ["Members", "Thành viên"], - ["Billing", "Thanh toán & Gói dịch vụ"], - ["Public Profile", "Hồ sơ công khai"], - ["Security", "Bảo mật"], - ["Webhooks", "Webhooks"], - ["API Tokens", "Mã API Tokens"], - ["Email Domains", "Tên miền Email"], - ["Custom Branding", "Thương hiệu tùy chỉnh"], - ["Branding", "Thương hiệu"], - ["Audit Log", "Nhật ký kiểm toán"], - ["Audit Logs", "Nhật ký kiểm toán"], - ["Support", "Hỗ trợ"], - ["Help", "Trợ giúp"], - ["Language", "Ngôn ngữ"], - ["Preferences", "Tùy chọn"], - ["Reminders", "Nhắc nhở ký"], - ["Certificates", "Chứng chỉ & Chứng thư"], - ["Groups", "Nhóm quyền"], - ["ORGANISATION SETTINGS", "CÀI ĐẶT TỔ CHỨC"], - ["TEAM SETTINGS", "CÀI ĐẶT ĐỘI NHÓM"], - ["ACCOUNT SETTINGS", "CÀI ĐẶT TÀI KHOẢN"], + ['Dashboard', 'Bảng điều khiển'], + ['Documents', 'Tài liệu'], + ['Templates', 'Mẫu tài liệu'], + ['Signatures', 'Chữ ký'], + ['Settings', 'Cài đặt'], + ['Inbox', 'Hộp thư'], + ['Personal Inbox', 'Hộp thư cá nhân'], + ['Profile', 'Hồ sơ cá nhân'], + ['Account', 'Tài khoản'], + ['Team', 'Đội nhóm'], + ['Teams', 'Các đội nhóm'], + ['Organisation', 'Tổ chức'], + ['Organisations', 'Các tổ chức'], + ['Organization', 'Tổ chức'], + ['Organizations', 'Các tổ chức'], + ['General', 'Chung'], + ['Members', 'Thành viên'], + ['Billing', 'Thanh toán & Gói dịch vụ'], + ['Public Profile', 'Hồ sơ công khai'], + ['Security', 'Bảo mật'], + ['Webhooks', 'Webhooks'], + ['API Tokens', 'Mã API Tokens'], + ['Email Domains', 'Tên miền Email'], + ['Custom Branding', 'Thương hiệu tùy chỉnh'], + ['Branding', 'Thương hiệu'], + ['Audit Log', 'Nhật ký kiểm toán'], + ['Audit Logs', 'Nhật ký kiểm toán'], + ['Support', 'Hỗ trợ'], + ['Help', 'Trợ giúp'], + ['Language', 'Ngôn ngữ'], + ['Preferences', 'Tùy chọn'], + ['Reminders', 'Nhắc nhở ký'], + ['Certificates', 'Chứng chỉ & Chứng thư'], + ['Groups', 'Nhóm quyền'], + ['ORGANISATION SETTINGS', 'CÀI ĐẶT TỔ CHỨC'], + ['TEAM SETTINGS', 'CÀI ĐẶT ĐỘI NHÓM'], + ['ACCOUNT SETTINGS', 'CÀI ĐẶT TÀI KHOẢN'], // Document Preferences & Settings - ["Document Preferences", "Tùy chọn tài liệu"], - ["Document preferences", "Tùy chọn tài liệu"], - ["Default Document Language", "Ngôn ngữ tài liệu mặc định"], - ["Default document language", "Ngôn ngữ tài liệu mặc định"], - ["Default Document Visibility", "Quyền hiển thị tài liệu mặc định"], - ["Default document visibility", "Quyền hiển thị tài liệu mặc định"], - ["Default Date Format", "Định dạng ngày mặc định"], - ["Default date format", "Định dạng ngày mặc định"], - ["Default Time Zone", "Múi giờ mặc định"], - ["Default time zone", "Múi giờ mặc định"], - ["Local timezone", "Múi giờ địa phương"], - ["Default Signature Settings", "Cài đặt chữ ký mặc định"], - ["Default signature settings", "Cài đặt chữ ký mặc định"], - ["Default Recipients", "Người nhận mặc định"], - ["Default recipients", "Người nhận mặc định"], - ["Default Email", "Email mặc định"], - ["Default Email Settings", "Cài đặt Email mặc định"], - ["Default Envelope Expiration", "Thời hạn gói tài liệu mặc định"], - ["Default file", "Tệp mặc định"], - ["Inherit from organisation", "Kế thừa từ tổ chức"], - ["Inherit from organization", "Kế thừa từ tổ chức"], - ["Everyone can access and view the document", "Mọi người đều có thể truy cập và xem tài liệu"], - ["Only recipients and owner can access", "Chỉ người nhận và chủ sở hữu mới có thể truy cập"], - ["Controls the default visibility of an uploaded document.", "Kiểm soát quyền hiển thị mặc định của tài liệu được tải lên."], - ["Controls the default language of an uploaded document. This will be used as the language in email communications with the recipients.", "Kiểm soát ngôn ngữ mặc định của tài liệu được tải lên. Ngôn ngữ này sẽ được sử dụng trong các email gửi tới người nhận."], - ["Controls which signatures are allowed to be used when signing a document.", "Kiểm soát các loại chữ ký được phép sử dụng khi ký tài liệu."], - ["Recipients that will be automatically added to new documents.", "Những người nhận sẽ được tự động thêm vào các tài liệu mới."], - ["Type, Draw, Upload", "Nhập chữ, Vẽ, Tải ảnh lên"], - ["Type", "Nhập chữ"], - ["Draw", "Vẽ chữ ký"], - ["Select or enter email address", "Chọn hoặc nhập địa chỉ email"], - ["Select language", "Chọn ngôn ngữ"], - ["Select timezone", "Chọn múi giờ"], - ["Select date format", "Chọn định dạng ngày"], - ["Select...", "Chọn..."], - ["Search...", "Tìm kiếm..."], - ["Search languages...", "Tìm kiếm ngôn ngữ..."], - ["Search documents", "Tìm kiếm tài liệu"], - ["Search templates", "Tìm kiếm mẫu tài liệu"], - ["Search members", "Tìm kiếm thành viên"], - ["Search webhooks", "Tìm kiếm webhooks"], - ["Search tokens", "Tìm kiếm mã tokens"], + ['Document Preferences', 'Tùy chọn tài liệu'], + ['Document preferences', 'Tùy chọn tài liệu'], + ['Default Document Language', 'Ngôn ngữ tài liệu mặc định'], + ['Default document language', 'Ngôn ngữ tài liệu mặc định'], + ['Default Document Visibility', 'Quyền hiển thị tài liệu mặc định'], + ['Default document visibility', 'Quyền hiển thị tài liệu mặc định'], + ['Default Date Format', 'Định dạng ngày mặc định'], + ['Default date format', 'Định dạng ngày mặc định'], + ['Default Time Zone', 'Múi giờ mặc định'], + ['Default time zone', 'Múi giờ mặc định'], + ['Local timezone', 'Múi giờ địa phương'], + ['Default Signature Settings', 'Cài đặt chữ ký mặc định'], + ['Default signature settings', 'Cài đặt chữ ký mặc định'], + ['Default Recipients', 'Người nhận mặc định'], + ['Default recipients', 'Người nhận mặc định'], + ['Default Email', 'Email mặc định'], + ['Default Email Settings', 'Cài đặt Email mặc định'], + ['Default Envelope Expiration', 'Thời hạn gói tài liệu mặc định'], + ['Default file', 'Tệp mặc định'], + ['Inherit from organisation', 'Kế thừa từ tổ chức'], + ['Inherit from organization', 'Kế thừa từ tổ chức'], + ['Everyone can access and view the document', 'Mọi người đều có thể truy cập và xem tài liệu'], + ['Only recipients and owner can access', 'Chỉ người nhận và chủ sở hữu mới có thể truy cập'], + [ + 'Controls the default visibility of an uploaded document.', + 'Kiểm soát quyền hiển thị mặc định của tài liệu được tải lên.', + ], + [ + 'Controls the default language of an uploaded document. This will be used as the language in email communications with the recipients.', + 'Kiểm soát ngôn ngữ mặc định của tài liệu được tải lên. Ngôn ngữ này sẽ được sử dụng trong các email gửi tới người nhận.', + ], + [ + 'Controls which signatures are allowed to be used when signing a document.', + 'Kiểm soát các loại chữ ký được phép sử dụng khi ký tài liệu.', + ], + [ + 'Recipients that will be automatically added to new documents.', + 'Những người nhận sẽ được tự động thêm vào các tài liệu mới.', + ], + ['Type, Draw, Upload', 'Nhập chữ, Vẽ, Tải ảnh lên'], + ['Type', 'Nhập chữ'], + ['Draw', 'Vẽ chữ ký'], + ['Select or enter email address', 'Chọn hoặc nhập địa chỉ email'], + ['Select language', 'Chọn ngôn ngữ'], + ['Select timezone', 'Chọn múi giờ'], + ['Select date format', 'Chọn định dạng ngày'], + ['Select...', 'Chọn...'], + ['Search...', 'Tìm kiếm...'], + ['Search languages...', 'Tìm kiếm ngôn ngữ...'], + ['Search documents', 'Tìm kiếm tài liệu'], + ['Search templates', 'Tìm kiếm mẫu tài liệu'], + ['Search members', 'Tìm kiếm thành viên'], + ['Search webhooks', 'Tìm kiếm webhooks'], + ['Search tokens', 'Tìm kiếm mã tokens'], // Languages - ["Vietnamese", "Tiếng Việt"], - ["English", "Tiếng Anh"], - ["French", "Tiếng Pháp"], - ["German", "Tiếng Đức"], - ["Spanish", "Tiếng Tây Ban Nha"], - ["Italian", "Tiếng Ý"], - ["Dutch", "Tiếng Hà Lan"], - ["Polish", "Tiếng Ba Lan"], - ["Portuguese (Brazil)", "Tiếng Bồ Đào Nha (Brazil)"], - ["Japanese", "Tiếng Nhật"], - ["Korean", "Tiếng Hàn"], - ["Chinese", "Tiếng Trung"], + ['Vietnamese', 'Tiếng Việt'], + ['English', 'Tiếng Anh'], + ['French', 'Tiếng Pháp'], + ['German', 'Tiếng Đức'], + ['Spanish', 'Tiếng Tây Ban Nha'], + ['Italian', 'Tiếng Ý'], + ['Dutch', 'Tiếng Hà Lan'], + ['Polish', 'Tiếng Ba Lan'], + ['Portuguese (Brazil)', 'Tiếng Bồ Đào Nha (Brazil)'], + ['Japanese', 'Tiếng Nhật'], + ['Korean', 'Tiếng Hàn'], + ['Chinese', 'Tiếng Trung'], // Actions & Buttons - ["Sign In", "Đăng nhập"], - ["Sign Up", "Đăng ký"], - ["Sign Out", "Đăng xuất"], - ["Log In", "Đăng nhập"], - ["Log Out", "Đăng xuất"], - ["Create Account", "Tạo tài khoản"], - ["Create Document", "Tạo tài liệu"], - ["Create Template", "Tạo mẫu tài liệu"], - ["Create Organisation", "Tạo tổ chức"], - ["Create Organization", "Tạo tổ chức"], - ["Create Team", "Tạo đội nhóm"], - ["Create Webhook", "Tạo Webhook"], - ["Create Token", "Tạo Token"], - ["Save", "Lưu"], - ["Save Changes", "Lưu thay đổi"], - ["Save changes", "Lưu thay đổi"], - ["Cancel", "Hủy"], - ["Delete", "Xóa"], - ["Delete Document", "Xóa tài liệu"], - ["Delete Template", "Xóa mẫu tài liệu"], - ["Edit", "Chỉnh sửa"], - ["Update", "Cập nhật"], - ["Continue", "Tiếp tục"], - ["Back", "Quay lại"], - ["Back to home", "Quay lại trang chủ"], - ["Next", "Tiếp theo"], - ["Finish", "Hoàn thành"], - ["Sign", "Ký"], - ["Sign Document", "Ký tài liệu"], - ["Sign document", "Ký tài liệu"], - ["Sign Document - Crove Sign", "Ký tài liệu - Crove Sign"], - ["Sign Document - Documenso", "Ký tài liệu - Crove Sign"], - ["Sign Now", "Ký ngay"], - ["Send", "Gửi"], - ["Send Document", "Gửi tài liệu"], - ["Send document", "Gửi tài liệu"], - ["Resend", "Gửi lại"], - ["Download", "Tải xuống"], - ["Download Document", "Tải xuống tài liệu"], - ["Download Certificate", "Tải xuống chứng chỉ ký"], - ["Preview", "Xem trước"], - ["Preview Document", "Xem trước tài liệu"], - ["Copy Link", "Sao chép liên kết"], - ["Copied!", "Đã sao chép!"], - ["Filter", "Lọc"], - ["Upload", "Tải lên"], - ["Upload Document", "Tải lên tài liệu"], - ["Upload PDF", "Tải lên tệp PDF"], - ["Confirm", "Xác nhận"], - ["Accept", "Chấp nhận"], - ["Decline", "Từ chối"], - ["Reject", "Từ chối"], - ["Duplicate", "Nhân bản"], - ["Rename", "Đổi tên"], - ["Add Recipient", "Thêm người nhận"], - ["Add Field", "Thêm trường ký"], - ["Add Field...", "Thêm trường..."], - ["Add Team", "Thêm đội nhóm"], - ["Add Member", "Thêm thành viên"], - ["Invite Member", "Mời thành viên"], - ["Invite Members", "Mời các thành viên"], - ["Manage Members", "Quản lý thành viên"], - ["Manage Teams", "Quản lý đội nhóm"], - ["Leave Team", "Rời khỏi đội nhóm"], - ["Leave Organisation", "Rời khỏi tổ chức"], + ['Sign In', 'Đăng nhập'], + ['Sign Up', 'Đăng ký'], + ['Sign Out', 'Đăng xuất'], + ['Log In', 'Đăng nhập'], + ['Log Out', 'Đăng xuất'], + ['Create Account', 'Tạo tài khoản'], + ['Create Document', 'Tạo tài liệu'], + ['Create Template', 'Tạo mẫu tài liệu'], + ['Create Organisation', 'Tạo tổ chức'], + ['Create Organization', 'Tạo tổ chức'], + ['Create Team', 'Tạo đội nhóm'], + ['Create Webhook', 'Tạo Webhook'], + ['Create Token', 'Tạo Token'], + ['Save', 'Lưu'], + ['Save Changes', 'Lưu thay đổi'], + ['Save changes', 'Lưu thay đổi'], + ['Cancel', 'Hủy'], + ['Delete', 'Xóa'], + ['Delete Document', 'Xóa tài liệu'], + ['Delete Template', 'Xóa mẫu tài liệu'], + ['Edit', 'Chỉnh sửa'], + ['Update', 'Cập nhật'], + ['Continue', 'Tiếp tục'], + ['Back', 'Quay lại'], + ['Back to home', 'Quay lại trang chủ'], + ['Next', 'Tiếp theo'], + ['Finish', 'Hoàn thành'], + ['Sign', 'Ký'], + ['Sign Document', 'Ký tài liệu'], + ['Sign document', 'Ký tài liệu'], + ['Sign Document - Crove Sign', 'Ký tài liệu - Crove Sign'], + ['Sign Document - Documenso', 'Ký tài liệu - Crove Sign'], + ['Sign Now', 'Ký ngay'], + ['Send', 'Gửi'], + ['Send Document', 'Gửi tài liệu'], + ['Send document', 'Gửi tài liệu'], + ['Resend', 'Gửi lại'], + ['Download', 'Tải xuống'], + ['Download Document', 'Tải xuống tài liệu'], + ['Download Certificate', 'Tải xuống chứng chỉ ký'], + ['Preview', 'Xem trước'], + ['Preview Document', 'Xem trước tài liệu'], + ['Copy Link', 'Sao chép liên kết'], + ['Copied!', 'Đã sao chép!'], + ['Filter', 'Lọc'], + ['Upload', 'Tải lên'], + ['Upload Document', 'Tải lên tài liệu'], + ['Upload PDF', 'Tải lên tệp PDF'], + ['Confirm', 'Xác nhận'], + ['Accept', 'Chấp nhận'], + ['Decline', 'Từ chối'], + ['Reject', 'Từ chối'], + ['Duplicate', 'Nhân bản'], + ['Rename', 'Đổi tên'], + ['Add Recipient', 'Thêm người nhận'], + ['Add Field', 'Thêm trường ký'], + ['Add Field...', 'Thêm trường...'], + ['Add Team', 'Thêm đội nhóm'], + ['Add Member', 'Thêm thành viên'], + ['Invite Member', 'Mời thành viên'], + ['Invite Members', 'Mời các thành viên'], + ['Manage Members', 'Quản lý thành viên'], + ['Manage Teams', 'Quản lý đội nhóm'], + ['Leave Team', 'Rời khỏi đội nhóm'], + ['Leave Organisation', 'Rời khỏi tổ chức'], // Document Signing View & Fields - ["Adopt and Sign", "Chấp nhận và Ký"], - ["Adopt Signature", "Chấp nhận chữ ký"], - ["Draw Signature", "Vẽ chữ ký"], - ["Type Signature", "Nhập chữ ký"], - ["Upload Signature", "Tải lên ảnh chữ ký"], - ["Clear", "Xóa vẽ lại"], - ["Clear signature", "Xóa chữ ký vẽ lại"], - ["Your Signature", "Chữ ký của bạn"], - ["Your Initials", "Chữ ký tắt của bạn"], - ["Required", "Bắt buộc"], - ["Optional", "Tùy chọn"], - ["Read Only", "Chỉ đọc"], - ["Read only", "Chỉ đọc"], - ["Signature Field", "Trường chữ ký"], - ["Initials Field", "Trường chữ ký tắt"], - ["Date Field", "Trường ngày tháng"], - ["Text Field", "Trường văn bản"], - ["Number Field", "Trường số"], - ["Checkbox Field", "Trường hộp kiểm"], - ["Radio Field", "Trường nút chọn"], - ["Dropdown Field", "Trường danh sách chọn"], - ["Insert Signature", "Chèn chữ ký"], - ["Insert Initials", "Chèn chữ ký tắt"], - ["Insert Date", "Chèn ngày tháng"], - ["Insert Text", "Chèn văn bản"], - ["Click to sign", "Nhấn để ký"], - ["Click to add initials", "Nhấn để thêm chữ ký tắt"], - ["Click to enter date", "Nhấn để nhập ngày"], - ["Click to enter text", "Nhấn để nhập văn bản"], + ['Adopt and Sign', 'Chấp nhận và Ký'], + ['Adopt Signature', 'Chấp nhận chữ ký'], + ['Draw Signature', 'Vẽ chữ ký'], + ['Type Signature', 'Nhập chữ ký'], + ['Upload Signature', 'Tải lên ảnh chữ ký'], + ['Clear', 'Xóa vẽ lại'], + ['Clear signature', 'Xóa chữ ký vẽ lại'], + ['Your Signature', 'Chữ ký của bạn'], + ['Your Initials', 'Chữ ký tắt của bạn'], + ['Required', 'Bắt buộc'], + ['Optional', 'Tùy chọn'], + ['Read Only', 'Chỉ đọc'], + ['Read only', 'Chỉ đọc'], + ['Signature Field', 'Trường chữ ký'], + ['Initials Field', 'Trường chữ ký tắt'], + ['Date Field', 'Trường ngày tháng'], + ['Text Field', 'Trường văn bản'], + ['Number Field', 'Trường số'], + ['Checkbox Field', 'Trường hộp kiểm'], + ['Radio Field', 'Trường nút chọn'], + ['Dropdown Field', 'Trường danh sách chọn'], + ['Insert Signature', 'Chèn chữ ký'], + ['Insert Initials', 'Chèn chữ ký tắt'], + ['Insert Date', 'Chèn ngày tháng'], + ['Insert Text', 'Chèn văn bản'], + ['Click to sign', 'Nhấn để ký'], + ['Click to add initials', 'Nhấn để thêm chữ ký tắt'], + ['Click to enter date', 'Nhấn để nhập ngày'], + ['Click to enter text', 'Nhấn để nhập văn bản'], // Document & Recipient Statuses - ["Draft", "Bản nháp"], - ["Pending", "Đang chờ ký"], - ["Completed", "Đã hoàn thành"], - ["Signed", "Đã ký"], - ["Rejected", "Đã từ chối"], - ["Cancelled", "Đã hủy"], - ["Expired", "Đã hết hạn"], - ["Inbox empty", "Hộp thư trống"], - ["No documents found", "Không tìm thấy tài liệu nào"], - ["No templates found", "Không tìm thấy mẫu nào"], + ['Draft', 'Bản nháp'], + ['Pending', 'Đang chờ ký'], + ['Completed', 'Đã hoàn thành'], + ['Signed', 'Đã ký'], + ['Rejected', 'Đã từ chối'], + ['Cancelled', 'Đã hủy'], + ['Expired', 'Đã hết hạn'], + ['Inbox empty', 'Hộp thư trống'], + ['No documents found', 'Không tìm thấy tài liệu nào'], + ['No templates found', 'Không tìm thấy mẫu nào'], // Form Fields & Roles - ["Full Name", "Họ và tên"], - ["Name", "Tên"], - ["Email", "Email"], - ["Email Address", "Địa chỉ Email"], - ["Password", "Mật khẩu"], - ["Current Password", "Mật khẩu hiện tại"], - ["New Password", "Mật khẩu mới"], - ["Confirm Password", "Xác nhận mật khẩu"], - ["Role", "Vai trò"], - ["Admin", "Quản trị viên"], - ["Manager", "Quản lý"], - ["Member", "Thành viên"], - ["Owner", "Chủ sở hữu"], - ["Signer", "Người ký"], - ["Approver", "Người phê duyệt"], - ["Viewer", "Người xem"], - ["Recipient", "Người nhận"], - ["Recipients", "Những người nhận"], - ["Signature", "Chữ ký"], - ["Initials", "Chữ ký tắt"], - ["Date", "Ngày tháng"], - ["Text", "Văn bản"], - ["Number", "Số"], - ["Radio", "Nút chọn một"], - ["Checkbox", "Hộp kiểm"], - ["Dropdown", "Danh sách chọn"], + ['Full Name', 'Họ và tên'], + ['Name', 'Tên'], + ['Email', 'Email'], + ['Email Address', 'Địa chỉ Email'], + ['Password', 'Mật khẩu'], + ['Current Password', 'Mật khẩu hiện tại'], + ['New Password', 'Mật khẩu mới'], + ['Confirm Password', 'Xác nhận mật khẩu'], + ['Role', 'Vai trò'], + ['Admin', 'Quản trị viên'], + ['Manager', 'Quản lý'], + ['Member', 'Thành viên'], + ['Owner', 'Chủ sở hữu'], + ['Signer', 'Người ký'], + ['Approver', 'Người phê duyệt'], + ['Viewer', 'Người xem'], + ['Recipient', 'Người nhận'], + ['Recipients', 'Những người nhận'], + ['Signature', 'Chữ ký'], + ['Initials', 'Chữ ký tắt'], + ['Date', 'Ngày tháng'], + ['Text', 'Văn bản'], + ['Number', 'Số'], + ['Radio', 'Nút chọn một'], + ['Checkbox', 'Hộp kiểm'], + ['Dropdown', 'Danh sách chọn'], // Brand / Crove Ecosystem strings - ["Welcome to Crove Sign", "Chào mừng bạn đến với Crove Sign"], - ["Welcome to Crove Sign!", "Chào mừng bạn đến với Crove Sign!"], - ["Welcome to Documenso", "Chào mừng bạn đến với Crove Sign"], - ["Welcome to Documenso!", "Chào mừng bạn đến với Crove Sign!"], - ["Electronic Signature Disclosure", "Công bố về Chữ ký Điện tử"], - ["Your email has been successfully confirmed! You can now use all features of Crove Sign.", "Email của bạn đã được xác nhận thành công! Bạn có thể sử dụng đầy đủ các tính năng của Crove Sign."], - ["Your email has already been confirmed. You can now use all features of Crove Sign.", "Email của bạn đã được xác nhận trước đó. Bạn có thể sử dụng tất cả tính năng của Crove Sign."], - ["This document is available in your Crove Sign account. You can view more details, recipients, and audit logs there.", "Tài liệu này khả dụng trong tài khoản Crove Sign của bạn. Bạn có thể xem thêm chi tiết, người nhận và nhật ký kiểm toán tại đó."], - ["Use API tokens to authenticate with the Crove Sign API.", "Sử dụng API tokens để xác thực với Crove Sign API."], - ["The URL for Crove Sign to send webhook events to.", "URL endpoint để Crove Sign gửi sự kiện webhook đến."], - ["Read our documentation to get started with Crove Sign.", "Đọc tài liệu hướng dẫn để bắt đầu sử dụng Crove Sign."], - ["Return to Crove Sign sign in page here", "Quay lại trang đăng nhập Crove Sign tại đây"], - ["An error occurred. Please try again.", "Đã xảy ra lỗi. Vui lòng thử lại."], - ["Something went wrong.", "Đã có sự cố xảy ra."], - ["All rights reserved.", "Đã đăng ký bản quyền."], + ['Welcome to Crove Sign', 'Chào mừng bạn đến với Crove Sign'], + ['Welcome to Crove Sign!', 'Chào mừng bạn đến với Crove Sign!'], + ['Welcome to Documenso', 'Chào mừng bạn đến với Crove Sign'], + ['Welcome to Documenso!', 'Chào mừng bạn đến với Crove Sign!'], + ['Electronic Signature Disclosure', 'Công bố về Chữ ký Điện tử'], + [ + 'Your email has been successfully confirmed! You can now use all features of Crove Sign.', + 'Email của bạn đã được xác nhận thành công! Bạn có thể sử dụng đầy đủ các tính năng của Crove Sign.', + ], + [ + 'Your email has already been confirmed. You can now use all features of Crove Sign.', + 'Email của bạn đã được xác nhận trước đó. Bạn có thể sử dụng tất cả tính năng của Crove Sign.', + ], + [ + 'This document is available in your Crove Sign account. You can view more details, recipients, and audit logs there.', + 'Tài liệu này khả dụng trong tài khoản Crove Sign của bạn. Bạn có thể xem thêm chi tiết, người nhận và nhật ký kiểm toán tại đó.', + ], + ['Use API tokens to authenticate with the Crove Sign API.', 'Sử dụng API tokens để xác thực với Crove Sign API.'], + ['The URL for Crove Sign to send webhook events to.', 'URL endpoint để Crove Sign gửi sự kiện webhook đến.'], + ['Read our documentation to get started with Crove Sign.', 'Đọc tài liệu hướng dẫn để bắt đầu sử dụng Crove Sign.'], + ['Return to Crove Sign sign in page here', 'Quay lại trang đăng nhập Crove Sign tại đây'], + ['An error occurred. Please try again.', 'Đã xảy ra lỗi. Vui lòng thử lại.'], + ['Something went wrong.', 'Đã có sự cố xảy ra.'], + ['All rights reserved.', 'Đã đăng ký bản quyền.'], ]); // Word replacement map @@ -303,7 +324,9 @@ const PHRASE_REPLACEMENTS = [ ]; function translateString(englishStr) { - if (!englishStr || englishStr.trim() === '') return ''; + if (!englishStr || englishStr.trim() === '') { + return ''; + } // 1. Exact dictionary match if (VI_TRANSLATIONS.has(englishStr)) { @@ -406,9 +429,7 @@ for (const entry of entries) { continue; } - const rawMsgId = entry.msgid - .map((l) => JSON.parse(l)) - .join(''); + const rawMsgId = entry.msgid.map((l) => JSON.parse(l)).join(''); const translatedText = translateString(rawMsgId); diff --git a/scripts/patch-crove-branding.mjs b/scripts/patch-crove-branding.mjs index 71f76123a2..bb373734b6 100644 --- a/scripts/patch-crove-branding.mjs +++ b/scripts/patch-crove-branding.mjs @@ -87,7 +87,9 @@ function patchTranslationCatalogs() { for (const locale of localeDirs) { const poFilePath = path.join(translationsDir, locale, 'web.po'); - if (!fs.existsSync(poFilePath)) continue; + if (!fs.existsSync(poFilePath)) { + continue; + } let content = fs.readFileSync(poFilePath, 'utf-8'); const originalContent = content; @@ -159,7 +161,9 @@ function patchWebManifests() { for (const manifestPath of manifestPaths) { const dir = path.dirname(manifestPath); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } fs.writeFileSync(manifestPath, JSON.stringify(manifestData, null, 2) + '\n', 'utf-8'); console.log(` ✓ Updated manifest: ${manifestPath}`); @@ -249,7 +253,15 @@ export const BrandingLogo = ({ className = 'h-6 w-auto', ...props }: LogoProps) console.log(` ✓ Created/updated logo component: ${brandingLogoPath}`); // 3. Branding Logo Icon Component - const brandingIconPath = path.join(ROOT_DIR, 'apps', 'remix', 'app', 'components', 'general', 'branding-logo-icon.tsx'); + const brandingIconPath = path.join( + ROOT_DIR, + 'apps', + 'remix', + 'app', + 'components', + 'general', + 'branding-logo-icon.tsx', + ); const brandingIconContent = `import type { SVGAttributes } from 'react'; export type LogoProps = SVGAttributes; diff --git a/scripts/sync-upstream.mjs b/scripts/sync-upstream.mjs index 38ac0830f2..d8ec074ab2 100644 --- a/scripts/sync-upstream.mjs +++ b/scripts/sync-upstream.mjs @@ -68,7 +68,9 @@ async function getLatestUpstreamReleaseTag() { } function ensureUpstreamRemote() { - const remotes = runCmdOutput('git remote').split('\n').map((r) => r.trim()); + const remotes = runCmdOutput('git remote') + .split('\n') + .map((r) => r.trim()); if (!remotes.includes('upstream')) { console.log(`🔗 Adding upstream remote: ${UPSTREAM_REPO_URL}`); runCmd(`git remote add upstream ${UPSTREAM_REPO_URL}`); @@ -113,7 +115,9 @@ async function main() { try { runCmd(`git merge ${targetTag} --no-edit -m "chore(sync): merge upstream Documenso release ${targetTag}"`); } catch (err) { - console.error(`\n❌ Conflict encountered while merging ${targetTag}. Please resolve conflicts, run 'npm run patch:branding', and commit.`); + console.error( + `\n❌ Conflict encountered while merging ${targetTag}. Please resolve conflicts, run 'npm run patch:branding', and commit.`, + ); process.exit(1); } From e9192d5078df2dd40ecb7e826d4a7b054214aca4 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:24:29 +0700 Subject: [PATCH 4/5] chore(lint): fix remaining biome errors in scripts and docs app - drop no-op async on next.config rewrites/redirects and the deploy guide script (convert main().catch to try/catch), add button type, and add justified biome-ignore suppressions for noExplicitAny / noImgElement / noDangerouslySetInnerHtml in the docs app - remaining noUndeclaredEnvVars findings are warn-level by config --- apps/docs/next.config.mjs | 4 ++-- apps/docs/src/app/docs/layout.tsx | 4 ++-- apps/docs/src/app/og/docs/[...slug]/route.tsx | 1 + apps/docs/src/components/ai/page-actions.tsx | 1 + apps/docs/src/components/mdx/mermaid.tsx | 1 + apps/docs/src/mdx-components.tsx | 2 +- scripts/count-entries.mjs | 4 +++- scripts/deploy-crove-resolver.mjs | 10 ++++++---- scripts/patch-crove-branding.mjs | 2 +- scripts/sync-upstream.mjs | 2 +- 10 files changed, 19 insertions(+), 12 deletions(-) diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs index 2eb734c8d2..653340c8eb 100644 --- a/apps/docs/next.config.mjs +++ b/apps/docs/next.config.mjs @@ -5,7 +5,7 @@ const withMDX = createMDX(); /** @type {import('next').NextConfig} */ const config = { reactStrictMode: true, - async rewrites() { + rewrites() { return [ { source: '/docs/:path*.mdx', @@ -13,7 +13,7 @@ const config = { }, ]; }, - async redirects() { + redirects() { return [ // ============================================================ // Legacy docs site redirects (old site had no /docs prefix) diff --git a/apps/docs/src/app/docs/layout.tsx b/apps/docs/src/app/docs/layout.tsx index 39ee4ba602..ffbc38881d 100644 --- a/apps/docs/src/app/docs/layout.tsx +++ b/apps/docs/src/app/docs/layout.tsx @@ -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; } diff --git a/apps/docs/src/app/og/docs/[...slug]/route.tsx b/apps/docs/src/app/og/docs/[...slug]/route.tsx index 76815dd3b6..3b9238b9cd 100644 --- a/apps/docs/src/app/og/docs/[...slug]/route.tsx +++ b/apps/docs/src/app/og/docs/[...slug]/route.tsx @@ -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 */} Documenso { return null; } + // biome-ignore lint/security/noDangerouslySetInnerHtml: renders the SVG produced by mermaid.render() in the browser, not user-controlled HTML return
; }; diff --git a/apps/docs/src/mdx-components.tsx b/apps/docs/src/mdx-components.tsx index a0116880ae..64f689c000 100644 --- a/apps/docs/src/mdx-components.tsx +++ b/apps/docs/src/mdx-components.tsx @@ -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, diff --git a/scripts/count-entries.mjs b/scripts/count-entries.mjs index ee05855b5e..3d6495702f 100644 --- a/scripts/count-entries.mjs +++ b/scripts/count-entries.mjs @@ -12,7 +12,9 @@ const content = fs.readFileSync(enPoPath, 'utf-8'); const lines = content.split('\n'); let count = 0; for (const line of lines) { - if (line.startsWith('msgid ')) count++; + if (line.startsWith('msgid ')) { + count++; + } } console.log('Total msgid count:', count); diff --git a/scripts/deploy-crove-resolver.mjs b/scripts/deploy-crove-resolver.mjs index 85dc7b709f..6c123fd289 100644 --- a/scripts/deploy-crove-resolver.mjs +++ b/scripts/deploy-crove-resolver.mjs @@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const ROOT_DIR = path.resolve(__dirname, '..'); +const _ROOT_DIR = path.resolve(__dirname, '..'); // ========================================== // 1. NETWORK CONFIGURATION & CONSTANTS @@ -45,7 +45,7 @@ const NETWORKS = { const CROVE_SCHEMA_V2 = 'bytes32 envelopeHash, bytes32 artifactRoot, bytes32 auditBundleRoot, bytes32 identityEvidenceRoot, bytes32 riskEvidenceRoot, bytes32 policyHash, uint16 evidenceVersion, uint8 eventType'; -async function main() { +function main() { console.log('\n====================================================================='); console.log('🚀 Crove Sign - EAS Gateway & Resolver Deployment Guide (DOS Chain)'); console.log('=====================================================================\n'); @@ -94,7 +94,9 @@ async function main() { console.log('⏳ Connecting to RPC and executing deployment...'); } -main().catch((err) => { +try { + main(); +} catch (err) { console.error('❌ Deployment error:', err); process.exit(1); -}); +} diff --git a/scripts/patch-crove-branding.mjs b/scripts/patch-crove-branding.mjs index bb373734b6..eb3cd23b59 100644 --- a/scripts/patch-crove-branding.mjs +++ b/scripts/patch-crove-branding.mjs @@ -165,7 +165,7 @@ function patchWebManifests() { fs.mkdirSync(dir, { recursive: true }); } - fs.writeFileSync(manifestPath, JSON.stringify(manifestData, null, 2) + '\n', 'utf-8'); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifestData, null, 2)}\n`, 'utf-8'); console.log(` ✓ Updated manifest: ${manifestPath}`); } console.log(' ✅ Finished updating PWA manifests.\n'); diff --git a/scripts/sync-upstream.mjs b/scripts/sync-upstream.mjs index d8ec074ab2..c05606dd9f 100644 --- a/scripts/sync-upstream.mjs +++ b/scripts/sync-upstream.mjs @@ -114,7 +114,7 @@ async function main() { console.log(`\n🔀 Merging upstream release ${targetTag} into ${currentBranch}...`); try { runCmd(`git merge ${targetTag} --no-edit -m "chore(sync): merge upstream Documenso release ${targetTag}"`); - } catch (err) { + } catch (_err) { console.error( `\n❌ Conflict encountered while merging ${targetTag}. Please resolve conflicts, run 'npm run patch:branding', and commit.`, ); From 8d676ccbe879dc06b5707dfd7963e37fa90eb48c Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:55:13 +0700 Subject: [PATCH 5/5] chore(lint): fix openpage-api biome errors Replace the non-null assertion with a null-coalescing fallback, use Number.isNaN on already-numeric values (behaviour-preserving), drop the unused catch binding, and add justified biome-ignore suppressions for the upstream Kysely window-function any-casts. --- apps/openpage-api/lib/cors.ts | 2 +- .../lib/growth/get-monthly-completed-document.ts | 1 + apps/openpage-api/lib/growth/get-signer-conversion.ts | 1 + apps/openpage-api/lib/growth/get-user-monthly-growth.ts | 1 + apps/openpage-api/lib/transform-data.ts | 8 ++++---- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/openpage-api/lib/cors.ts b/apps/openpage-api/lib/cors.ts index 78dcebde5a..1ec5b8642d 100644 --- a/apps/openpage-api/lib/cors.ts +++ b/apps/openpage-api/lib/cors.ts @@ -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(','); diff --git a/apps/openpage-api/lib/growth/get-monthly-completed-document.ts b/apps/openpage-api/lib/growth/get-monthly-completed-document.ts index 2e1eec0958..91c8ade0be 100644 --- a/apps/openpage-api/lib/growth/get-monthly-completed-document.ts +++ b/apps/openpage-api/lib/growth/get-monthly-completed-document.ts @@ -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'), ]) diff --git a/apps/openpage-api/lib/growth/get-signer-conversion.ts b/apps/openpage-api/lib/growth/get-signer-conversion.ts index 464ae263ed..9cb18cc8d8 100644 --- a/apps/openpage-api/lib/growth/get-signer-conversion.ts +++ b/apps/openpage-api/lib/growth/get-signer-conversion.ts @@ -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'), ]) diff --git a/apps/openpage-api/lib/growth/get-user-monthly-growth.ts b/apps/openpage-api/lib/growth/get-user-monthly-growth.ts index 696266d694..631b526c37 100644 --- a/apps/openpage-api/lib/growth/get-user-monthly-growth.ts +++ b/apps/openpage-api/lib/growth/get-user-monthly-growth.ts @@ -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'), ]) diff --git a/apps/openpage-api/lib/transform-data.ts b/apps/openpage-api/lib/transform-data.ts index cadeaac316..89ecb32b80 100644 --- a/apps/openpage-api/lib/transform-data.ts +++ b/apps/openpage-api/lib/transform-data.ts @@ -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; } @@ -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; } @@ -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: [] }],