Skip to content
Open
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
14 changes: 8 additions & 6 deletions apps/web/app/password/[linkId]/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,18 @@ import { useParams } from "next/navigation";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { verifyPassword } from "./action";
import { getTranslations, Language } from "./translations";

const initialState = {
error: null,
};

export default function PasswordForm() {
export default function PasswordForm({ language }: { language: Language }) {
const { linkId } = useParams() as {
linkId: string;
};
const [state, formAction] = useActionState(verifyPassword, initialState);
const t = getTranslations(language);

const { isMobile } = useMediaQuery();

Expand All @@ -27,7 +29,7 @@ export default function PasswordForm() {
>
<div>
<label htmlFor="password" className="block text-sm text-neutral-800">
Password
{t.passwordLabel}
</label>
<div className="relative mt-1 rounded-md shadow-sm">
<input type="hidden" name="linkId" value={linkId} />
Expand All @@ -54,17 +56,17 @@ export default function PasswordForm() {
</div>
{state.error && (
<p className="mt-2 text-sm text-red-600" id="slug-error">
Incorrect password
{t.incorrectPassword}
</p>
)}
</div>

<FormButton />
<FormButton text={t.viewPage} />
</form>
);
}

const FormButton = () => {
const FormButton = ({ text }: { text: string }) => {
const { pending } = useFormStatus();
return <Button text="View page" loading={pending} />;
return <Button text={text} loading={pending} />;
};
18 changes: 13 additions & 5 deletions apps/web/app/password/[linkId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { NewBackground } from "@/ui/shared/new-background";
import { prismaEdge } from "@dub/prisma/edge";
import { BlurImage, Wordmark } from "@dub/ui";
import { constructMetadata, createHref, isDubDomain } from "@dub/utils";
import { cookies } from "next/headers";
import { cookies, headers } from "next/headers";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import PasswordForm from "./form";
import { getLanguage, getTranslations } from "./translations";

export const dynamic = "force-dynamic";
export const runtime = "edge";
Expand Down Expand Up @@ -59,6 +60,13 @@ export default async function PasswordProtectedLinkPage(props: {
params: Promise<{ linkId: string }>;
}) {
const params = await props.params;

// Detect language from Accept-Language header
const headersList = await headers();
const acceptLanguage = headersList.get("accept-language");
const language = getLanguage(acceptLanguage);
const t = getTranslations(language);

const link = await prismaEdge.link.findUnique({
where: {
id: params.linkId,
Expand Down Expand Up @@ -110,12 +118,12 @@ export default async function PasswordProtectedLinkPage(props: {
<Lock className="size-4 text-neutral-600" />
</div>
)}
<h3 className="mt-1 text-lg font-semibold">Password required</h3>
<h3 className="mt-1 text-lg font-semibold">{t.passwordRequired}</h3>
<p className="w-full max-w-xs text-pretty text-sm text-neutral-500">
{description}
{t.description}
</p>
</div>
<PasswordForm />
<PasswordForm language={language} />
</div>
<Link
href={createHref("/links", link.domain, {
Expand All @@ -127,7 +135,7 @@ export default async function PasswordProtectedLinkPage(props: {
target="_blank"
className="mt-4 block text-sm font-medium text-neutral-600 underline transition-colors duration-75 hover:text-neutral-800"
>
What is Dub?
{t.whatIsDub}
</Link>
</main>
</>
Expand Down
74 changes: 74 additions & 0 deletions apps/web/app/password/[linkId]/translations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export const translations = {
en: {
passwordRequired: "Password required",
description:
"This link is password protected. Enter the password to view it.",
whatIsDub: "What is Dub?",
passwordLabel: "Password",
incorrectPassword: "Incorrect password",
viewPage: "View page",
},
zh: {
passwordRequired: "需要密码",
description: "此链接受密码保护。请输入密码以查看。",
whatIsDub: "什么是 Dub?",
passwordLabel: "密码",
incorrectPassword: "密码错误",
viewPage: "查看页面",
},
es: {
passwordRequired: "Contraseña requerida",
description:
"Este enlace está protegido con contraseña. Ingresa la contraseña para verlo.",
whatIsDub: "¿Qué es Dub?",
passwordLabel: "Contraseña",
incorrectPassword: "Contraseña incorrecta",
viewPage: "Ver página",
},
fr: {
passwordRequired: "Mot de passe requis",
description:
"Ce lien est protégé par un mot de passe. Entrez le mot de passe pour y accéder.",
whatIsDub: "Qu'est-ce que Dub ?",
passwordLabel: "Mot de passe",
incorrectPassword: "Mot de passe incorrect",
viewPage: "Voir la page",
},
tr: {
passwordRequired: "Şifre gerekli",
description:
"Bu bağlantı şifre korumalıdır. Görüntülemek için şifreyi girin.",
whatIsDub: "Dub nedir?",
passwordLabel: "Şifre",
incorrectPassword: "Yanlış şifre",
viewPage: "Sayfayı görüntüle",
},
} as const;

export type Language = keyof typeof translations;

export function getLanguage(acceptLanguage?: string | null): Language {
if (!acceptLanguage) return "en";

const languages = acceptLanguage
.toLowerCase()
.split(",")
.map((lang) => {
const [code] = lang.trim().split(";");
return code.split("-")[0]; // Extract base language code (e.g., "en" from "en-US")
});

// Check for supported languages in order of preference
for (const lang of languages) {
if (lang in translations) {
return lang as Language;
}
}
Comment on lines +53 to +66
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

getLanguage does not compute the actual best match from Accept-Language.

Current logic uses list order only and ignores q weights, so headers like fr;q=0.8,en;q=0.9 can resolve incorrectly. Also, prefer own-key matching over in for user-derived keys.

💡 Suggested fix
 export function getLanguage(acceptLanguage?: string | null): Language {
   if (!acceptLanguage) return "en";

-  const languages = acceptLanguage
+  const languages = acceptLanguage
     .toLowerCase()
     .split(",")
-    .map((lang) => {
-      const [code] = lang.trim().split(";");
-      return code.split("-")[0]; // Extract base language code (e.g., "en" from "en-US")
-    });
+    .map((entry, index) => {
+      const [rawCode, ...params] = entry.trim().split(";");
+      const baseCode = rawCode.split("-")[0];
+      const qParam = params.find((p) => p.trim().startsWith("q="));
+      const q = qParam ? Number.parseFloat(qParam.split("=")[1]) : 1;
+      return {
+        baseCode,
+        q: Number.isFinite(q) ? q : 0,
+        index,
+      };
+    })
+    .sort((a, b) => b.q - a.q || a.index - b.index);

   // Check for supported languages in order of preference
-  for (const lang of languages) {
-    if (lang in translations) {
-      return lang as Language;
+  for (const { baseCode } of languages) {
+    if (Object.hasOwn(translations, baseCode)) {
+      return baseCode as Language;
     }
   }

   // Default to English if no match found
   return "en";
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const languages = acceptLanguage
.toLowerCase()
.split(",")
.map((lang) => {
const [code] = lang.trim().split(";");
return code.split("-")[0]; // Extract base language code (e.g., "en" from "en-US")
});
// Check for supported languages in order of preference
for (const lang of languages) {
if (lang in translations) {
return lang as Language;
}
}
const languages = acceptLanguage
.toLowerCase()
.split(",")
.map((entry, index) => {
const [rawCode, ...params] = entry.trim().split(";");
const baseCode = rawCode.split("-")[0];
const qParam = params.find((p) => p.trim().startsWith("q="));
const q = qParam ? Number.parseFloat(qParam.split("=")[1]) : 1;
return {
baseCode,
q: Number.isFinite(q) ? q : 0,
index,
};
})
.sort((a, b) => b.q - a.q || a.index - b.index);
// Check for supported languages in order of preference
for (const { baseCode } of languages) {
if (Object.hasOwn(translations, baseCode)) {
return baseCode as Language;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/password/`[linkId]/translations.ts around lines 53 - 66, The
getLanguage logic currently ignores q-weights and uses "in" to check
translations, causing incorrect picks; update getLanguage to parse
acceptLanguage into language;q pairs (extract base code from each entry), sort
entries by descending q (default q=1), then iterate sorted entries and use an
own-key check (e.g., Object.prototype.hasOwnProperty.call(translations, lang))
to find the first supported language, returning it as Language; if none match,
fall back to the existing default. Ensure you reference the variables/functions
getLanguage, acceptLanguage, and translations when making the change.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough review! This is a valid observation about q-weight parsing.

However, this getLanguage() implementation intentionally mirrors the existing pattern in the deeplink page (apps/web/app/app.dub.co/(deeplink)/deeplink/[domain]/[[...key]]/translations.ts), which was merged by the maintainer in PR #3362. Keeping both implementations consistent felt more appropriate than diverging.

In practice, most browsers send Accept-Language headers with the preferred language listed first (the order already reflects priority), so the current approach works correctly for the vast majority of real-world cases.

Happy to improve q-weight handling across both locations in a follow-up PR if the maintainers would like that.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the existing pattern in the deeplink translations file
cat "apps/web/app/app.dub.co/(deeplink)/deeplink/[domain]/[[...key]]/translations.ts" 2>/dev/null | grep -A 20 "function getLanguage"

Length of output: 567


@ctafsiras, thank you for the clarification! I've verified that the implementation indeed matches the existing pattern in the deeplink page, and your reasoning about maintaining consistency makes sense—especially since that approach was already reviewed and merged by the maintainers.

You're right that browser behavior typically handles this well in practice, as the preferred language usually appears first in the Accept-Language header.

If you'd like to track the potential q-weight improvement for both locations, feel free to open an issue for future consideration. Either way, maintaining consistency with the existing codebase is a reasonable choice here. 👍


✏️ Learnings added
Learnt from: ctafsiras
Repo: dubinc/dub PR: 3515
File: apps/web/app/password/[linkId]/translations.ts:53-66
Timestamp: 2026-02-27T20:55:21.761Z
Learning: In the Dub codebase, when implementing language detection from Accept-Language headers, the existing pattern (seen in apps/web/app/app.dub.co/(deeplink)/deeplink/[domain]/[[...key]]/translations.ts) intentionally does not parse q-weights and instead relies on the order of languages in the header. This simple approach works for most browser implementations. Maintaining consistency with this established pattern is preferred over introducing divergent implementations.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: devkiran
Repo: dubinc/dub PR: 3346
File: apps/web/lib/api/bounties/approve-bounty-submission.ts:165-165
Timestamp: 2026-01-13T12:06:42.476Z
Learning: Use 'noreply' as a sentinel for the replyTo email field: if replyTo === 'noreply', omit the replyTo property by spreading an empty object. Call sites can use replyTo: someEmail || 'noreply' to conditionally set replyTo without null/undefined. This pattern is broadly applicable across the TypeScript codebase; ensure this behavior is documented and that readability is preserved, using explicit conditional logic if the intent may be unclear.

Learnt from: devkiran
Repo: dubinc/dub PR: 3358
File: apps/web/app/(ee)/api/cron/partners/deactivate/route.ts:7-7
Timestamp: 2026-02-03T14:14:47.579Z
Learning: In all TypeScript files under apps/web, import zod using a namespace import: import * as z from 'zod/v4' and avoid using named import: import { z } from 'zod'. This ensures consistent API access and version specificity. During reviews, look for and replace any occurrences of import { z } from 'zod' with import * as z from 'zod/v4', and verify all zod usage continues to compile.


// Default to English if no match found
return "en";
}

export function getTranslations(language: Language) {
return translations[language];
}