]*>([\s\S]*?)<\/body>/i;
+import { findClosingTag, findOpeningTag } from "@/lib/utils/html-opening-tag";
+
const EMAIL_REACT_SERVER_MARKER_PATTERN = //g;
+/**
+ * The body content, or null when the document has no `…`.
+ *
+ * Two scans rather than `]*>([\s\S]*?)<\/body>`. That pattern is quadratic twice over: the
+ * attribute run rescans from every `` follows, and the lazy content group expands
+ * to the end of the document once per `` when no `` follows. Capping fixes neither —
+ * the content group is the whole email and cannot be capped, and capping the attribute run makes an
+ * over-long tag match a LATER `` instead, extracting the wrong span.
+ *
+ * Same result as the regex. It matched the leftmost `` that has a `` after it, and if
+ * none follows the first opening tag then none follows a later one either, so taking the first
+ * opening tag and the first close after it picks exactly the same span.
+ */
+const extractBodyContent = (html: string): string | null => {
+ const openTag = findOpeningTag(html, "body");
+ if (!openTag) return null;
+
+ const contentStart = openTag.index + openTag.length;
+ const contentEnd = findClosingTag(html, "body", contentStart);
+
+ return contentEnd === -1 ? null : html.slice(contentStart, contentEnd);
+};
+
export const extractEmailBodyFragment = (html: string): string => {
- const htmlWithoutDoctype = html.replace(EMAIL_DOCTYPE_PATTERN, "").trim();
- const bodyMatch = EMAIL_BODY_PATTERN.exec(htmlWithoutDoctype);
- const fragment = bodyMatch?.[1].trim() ?? htmlWithoutDoctype;
+ const doctype = findOpeningTag(html, "!DOCTYPE", { requireWordBoundary: false });
+ const htmlWithoutDoctype = (
+ doctype ? html.slice(0, doctype.index) + html.slice(doctype.index + doctype.length) : html
+ ).trim();
+
+ const fragment = extractBodyContent(htmlWithoutDoctype)?.trim() ?? htmlWithoutDoctype;
return fragment.replaceAll(EMAIL_REACT_SERVER_MARKER_PATTERN, "").trim();
};
diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx
index f3f0071f1c81..8cb7d9d3eb77 100644
--- a/apps/web/app/(auth)/layout.tsx
+++ b/apps/web/app/(auth)/layout.tsx
@@ -1,12 +1,5 @@
-import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
-
-const AppLayout = async ({ children }: { children: React.ReactNode }) => {
- return (
- <>
-
- {children}
- >
- );
+const AuthGroupLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
+ return <>{children}>;
};
-export default AppLayout;
+export default AuthGroupLayout;
diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock
index 8b166e79e12c..42eafc5783d6 100644
--- a/apps/web/i18n.lock
+++ b/apps/web/i18n.lock
@@ -96,6 +96,7 @@ checksums:
auth/signup/company_email_required: 997bf11a31512cedf4ab8acb7b4f56c3
auth/signup/have_an_account: 6f9c2d441e93cb6df9ef7dc898e80e05
auth/signup/log_in: 9fb886eff8d1d67d8bb79f5c3f78edde
+ auth/signup/password_requirements: 58d15c4e16b28c51b83977d0c2af17bc
auth/signup/password_validation_contain_at_least_1_number: 58fb33a486aa4dadfc05e3242fc7ea0b
auth/signup/password_validation_minimum_8_and_maximum_128_characters: ec704690a454c7f01e59856c39206259
auth/signup/password_validation_uppercase_and_lowercase: ae98b485024dbff1022f6048e22443cd
@@ -230,6 +231,7 @@ checksums:
common/delete: 8bcf303dd10a645b5baacb02b47d72c9
common/delete_what: 718ddfcc1dec7f3e8b67856fba838267
common/description: e17686a22ffad04cc7bb70524ed4478b
+ common/digit_number_of_total: bd36d550b8797c5a928ede29cb10a0f9
common/disable: 81b754fd7962e0bd9b6ba87f3972e7fc
common/disabled: 0889a3dfd914a7ef638611796b17bf72
common/disallow: 01c8ed3ce545ed836d3ccffc562c8a0c
@@ -274,6 +276,7 @@ checksums:
common/finish: ffa7a10f71182b48fefed7135bee24fa
common/finished_at: 05d2fa31cf3b2e2255729ec7898240c2
common/first_name: cf040a5d6a9fd696be400380cc99f54b
+ common/formbricks_homepage: 95a78e9e1812a97221d1f69015561e68
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
common/gathering_responses: c5914490ed81bd77f13d411739f0c9ef
@@ -286,6 +289,7 @@ checksums:
common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972
common/hide_column: 23ce94db148f2d8e4a0923defead6cf1
+ common/hide_password: dd9813264cfc4a7ae515cd5644943c1b
common/html: f750870203043349d570d8f5865ca0f8
common/id: c8886d38aeea2ed5f785aba4fc96784b
common/image: 048ba7a239de0fbd883ade8558415830
@@ -455,6 +459,7 @@ checksums:
common/settings: 8df6777277469c1fd88cc18dde2f1cc3
common/share_feedback: f3c14bfa149fde4035b6965cb8d3e993
common/show: 16dfe5dc481240cd2819a6394f90df92
+ common/show_password: 8696d19a0f02613a86727810355fef47
common/show_response_count: 609e5dc7c074d57e711a728fa2f8eb79
common/shown: 63e4ffb245c05e04b636446c3dbdd8df
common/size: 227fadeeff951e041ff42031a11a4626
diff --git a/apps/web/lib/utils/client-ip.ts b/apps/web/lib/utils/client-ip.ts
index 7e285fcbc9f5..fbd451188baf 100644
--- a/apps/web/lib/utils/client-ip.ts
+++ b/apps/web/lib/utils/client-ip.ts
@@ -19,7 +19,7 @@ export const UNTRUSTED_CLIENT_IP = "untrusted-client-ip";
const CLIENT_IP_WARNING_INTERVAL_MS = 10 * 60 * 1000;
const VALID_DECIMAL_PORT = /^[1-9]\d{0,4}$/;
-const BRACKETED_ADDRESS = /^\[([^\[\]]+)\](?::([^:]+))?$/;
+const BRACKETED_ADDRESS = /^\[([^[\]]+)\](?::([^:]+))?$/;
const IPV4_SOCKET = /^([^:]+):(\d+)$/;
type ClientIpWarningReason = "disabled" | "invalid-selected-hop" | "missing-chain" | "short-chain";
diff --git a/apps/web/lib/utils/html-opening-tag.test.ts b/apps/web/lib/utils/html-opening-tag.test.ts
new file mode 100644
index 000000000000..c8ed98f6afe5
--- /dev/null
+++ b/apps/web/lib/utils/html-opening-tag.test.ts
@@ -0,0 +1,109 @@
+import { describe, expect, test } from "vitest";
+import { findClosingTag, findOpeningTag, replaceOpeningTags } from "./html-opening-tag";
+
+// The regexes this module replaces, kept here as the oracle every case is compared against.
+const asRegex = (name: string, wordBoundary = true) =>
+ new RegExp(`<${name}${wordBoundary ? String.raw`\b` : ""}([^>]*)>`, "gi");
+
+const CASES: { name: string; wordBoundary?: boolean }[] = [
+ { name: "p" },
+ { name: "li" },
+ { name: "body" },
+ { name: "!DOCTYPE", wordBoundary: false },
+];
+
+const FIXED = [
+ "",
+ "
a
",
+ '
a
',
+ "
spaced
upper
",
+ "
not a p
",
+ "
nested
",
+ "x",
+ "",
+ "multiline",
+ "
",
+ "text with no tags at all",
+ "
",
+ "",
+ // The shapes a length cap got wrong: an over-long run containing another opening tag.
+ `
tail`,
+ `
tail`,
+ `inner`,
+ `rest`,
+ // U+0130 lowercases to two code units; a toLowerCase()-based scan would misalign here.
+ "İ
after a length-changing character
",
+];
+
+const ALPHABET = "<>/plibodyPLIBODY!DCTYPE \n\t\"'=-0_";
+const random = Array.from({ length: 20000 }, () => {
+ const n = Math.floor(Math.random() * 40);
+ let s = "";
+ for (let i = 0; i < n; i++) s += ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
+ return s;
+});
+const CORPUS = [...FIXED, ...random];
+
+describe("html opening tag scanner", () => {
+ test.each(CASES)("replaceOpeningTags matches the regex it replaces ($name)", ({ name, wordBoundary }) => {
+ for (const source of CORPUS) {
+ const viaRegex = source.replace(asRegex(name, wordBoundary), (_m, attrs: string) => `[${attrs}]`);
+ const viaScan = replaceOpeningTags(source, name, (attrs) => `[${attrs}]`, {
+ requireWordBoundary: wordBoundary,
+ });
+
+ expect(viaScan, `input: ${JSON.stringify(source.slice(0, 60))}`).toBe(viaRegex);
+ }
+ });
+
+ test.each(CASES)(
+ "findOpeningTag reports the regex's index and capture ($name)",
+ ({ name, wordBoundary }) => {
+ for (const source of CORPUS) {
+ const expected = asRegex(name, wordBoundary).exec(source);
+ const actual = findOpeningTag(source, name, { requireWordBoundary: wordBoundary });
+
+ if (expected === null) {
+ expect(actual, `input: ${JSON.stringify(source.slice(0, 60))}`).toBeNull();
+ continue;
+ }
+ expect(actual, `input: ${JSON.stringify(source.slice(0, 60))}`).toEqual({
+ index: expected.index,
+ length: expected[0].length,
+ attributes: expected[1],
+ });
+ }
+ }
+ );
+
+ test("findClosingTag matches the case-insensitive close the regex used", () => {
+ const iDot = String.fromCharCode(0x130); // lowercases to two code units
+ const cases = [
+ ["", 0],
+ ["x", 7],
+ ["xrest", 7],
+ ["no close here", -1],
+ // A toLowerCase()-based search would report an index into a longer string here.
+ [`prefix${iDot}suffix`, `prefix${iDot}suffix`.length],
+ [`${iDot.repeat(20)}`, 20],
+ ] as const;
+
+ for (const [source, expected] of cases) {
+ expect(findClosingTag(source, "body"), JSON.stringify(source)).toBe(expected);
+ }
+ });
+
+ test("stays linear where the regex was quadratic", () => {
+ // `
` anywhere: the regex rescans to the end from every occurrence.
+ const pathological = "
`[${attrs}]`);
+ const elapsedMs = performance.now() - startedAt;
+
+ expect(result).toBe(pathological);
+ expect(elapsedMs).toBeLessThan(500);
+ });
+});
diff --git a/apps/web/lib/utils/html-opening-tag.ts b/apps/web/lib/utils/html-opening-tag.ts
new file mode 100644
index 000000000000..50867d47fdf6
--- /dev/null
+++ b/apps/web/lib/utils/html-opening-tag.ts
@@ -0,0 +1,137 @@
+/**
+ * Locate HTML opening tags the way `/]*>/gi` did, without the regex.
+ *
+ * Why not the regex: `[^>]*` is unbounded, so on a run of `
` after it the engine
+ * rescans to the end of the document from every occurrence — O(N^2), measured 7.4s on 200k
+ * characters. Capping the run is not a fix either: when the over-long run itself contains another
+ * `
]*` cannot cross a `>`, so a tag always ends at the first
+ * `>` after its name — one `indexOf` — and if no `>` follows the first opening tag then none follows
+ * a later one either, so the scan stops rather than retrying. Same matches, one pass, no cap.
+ */
+
+/** `\b` after an ASCII-letter tag name: the next character must exist and not be a word character. */
+const isWordCharacterCode = (code: number | undefined): boolean =>
+ code !== undefined &&
+ ((code >= 48 && code <= 57) || // 0-9
+ (code >= 65 && code <= 90) || // A-Z
+ (code >= 97 && code <= 122) || // a-z
+ code === 95); // _
+
+/**
+ * Past the end of the string `codePointAt` yields `undefined`, which never equals another position's
+ * code — the same outcome `charCodeAt`'s `NaN` produced, so the scans below are unchanged.
+ */
+const toAsciiLowerCode = (code: number | undefined): number | undefined =>
+ code !== undefined && code >= 65 && code <= 90 ? code + 32 : code;
+
+/**
+ * ASCII-case-insensitive `indexOf`, matching the `i` flag's behaviour on the ASCII literals used
+ * here. Deliberately not `haystack.toLowerCase().indexOf(...)`: lowercasing is not length-preserving
+ * (U+0130 becomes two code units), so a document containing one would misalign every later index.
+ */
+const indexOfAsciiCaseInsensitive = (haystack: string, needle: string, from: number): number => {
+ const lastStart = haystack.length - needle.length;
+ for (let start = Math.max(0, from); start <= lastStart; start++) {
+ let offset = 0;
+ while (
+ offset < needle.length &&
+ toAsciiLowerCode(haystack.codePointAt(start + offset)) === toAsciiLowerCode(needle.codePointAt(offset))
+ ) {
+ offset++;
+ }
+ if (offset === needle.length) return start;
+ }
+ return -1;
+};
+
+export interface OpeningTagMatch {
+ /** Index of the `<`. */
+ readonly index: number;
+ /** Length of the whole tag, `<` through `>`. */
+ readonly length: number;
+ /** Everything between the tag name and the `>` — the regex's capture group. */
+ readonly attributes: string;
+}
+
+/**
+ * The first `` at or after `from`, or null when there is none.
+ *
+ * `requireWordBoundary` mirrors whether the pattern had `\b` after the name: `
]*>` has no such constraint and matches ``.
+ */
+export const findOpeningTag = (
+ source: string,
+ name: string,
+ { requireWordBoundary = true }: { requireWordBoundary?: boolean } = {},
+ from = 0
+): OpeningTagMatch | null => {
+ const prefix = `<${name}`;
+ let searchFrom = from;
+
+ while (searchFrom <= source.length) {
+ const start = indexOfAsciiCaseInsensitive(source, prefix, searchFrom);
+ if (start === -1) return null;
+
+ const afterPrefix = start + prefix.length;
+ // `\b` fails when the next character continues the word, e.g. `
`. The regex
+ // would then retry one position later, so the scan does too.
+ if (requireWordBoundary && isWordCharacterCode(source.codePointAt(afterPrefix))) {
+ searchFrom = start + 1;
+ continue;
+ }
+
+ const close = source.indexOf(">", afterPrefix);
+ // `[^>]*` cannot cross a `>`, so a tag ends at the first one. No `>` after this opening tag
+ // means none after any later one either — the regex would scan the rest of the document to
+ // discover that; there is nothing left to find.
+ if (close === -1) return null;
+
+ return {
+ index: start,
+ length: close - start + 1,
+ attributes: source.slice(afterPrefix, close),
+ };
+ }
+
+ return null;
+};
+
+/**
+ * Index of the first `
` at or after `from`, or -1. The counterpart to `findOpeningTag`, and
+ * case-insensitive the same way.
+ */
+export const findClosingTag = (source: string, name: string, from = 0): number =>
+ indexOfAsciiCaseInsensitive(source, `${name}>`, from);
+
+/**
+ * Replace every `` with `replace(attributes, tag)`, matching `/]*)>/gi` under
+ * `replaceAll`. Like the regex, scanning resumes after the replaced tag's `>`, so a replacement
+ * that itself contains a tag is never rescanned.
+ *
+ * `tag` is the matched text exactly as it appeared, which callers that pass a tag through unchanged
+ * need: rebuilding it from `name` would normalize `
` to `
`.
+ */
+export const replaceOpeningTags = (
+ source: string,
+ name: string,
+ replace: (attributes: string, tag: string) => string,
+ options: { requireWordBoundary?: boolean } = {}
+): string => {
+ let result = "";
+ let copiedTo = 0;
+
+ for (
+ let match = findOpeningTag(source, name, options, 0);
+ match !== null;
+ match = findOpeningTag(source, name, options, copiedTo)
+ ) {
+ const tag = source.slice(match.index, match.index + match.length);
+ result += source.slice(copiedTo, match.index) + replace(match.attributes, tag);
+ copiedTo = match.index + match.length;
+ }
+
+ return copiedTo === 0 ? source : result + source.slice(copiedTo);
+};
diff --git a/apps/web/lib/utils/recall.ts b/apps/web/lib/utils/recall.ts
index 4de36c2f8e4d..452b002296a3 100644
--- a/apps/web/lib/utils/recall.ts
+++ b/apps/web/lib/utils/recall.ts
@@ -33,10 +33,19 @@ export const extractIds = (text: string): string[] => {
};
// Extracts the fallback value from a string containing the "fallback" pattern.
+// An index scan, not `/fallback:([^#]*)#/`: that pattern is O(N^2) on a long run of `fallback:`
+// with no `#` after it, because the engine rescans to the end from every occurrence. Identical
+// result — `[^#]*` cannot cross a `#`, so the regex ends at the first `#` after the FIRST
+// `fallback:`, and if none follows that one none follows a later one either.
+const FALLBACK_MARKER = "fallback:";
+
export const extractFallbackValue = (text: string): string => {
- const pattern = /fallback:([^#]*)#/;
- const match = text.match(pattern);
- return match?.[1] ?? "";
+ const markerStart = text.indexOf(FALLBACK_MARKER);
+ if (markerStart === -1) return "";
+
+ const valueStart = markerStart + FALLBACK_MARKER.length;
+ const valueEnd = text.indexOf("#", valueStart);
+ return valueEnd === -1 ? "" : text.slice(valueStart, valueEnd);
};
// Extracts the complete recall information (ID and fallback) from a headline string.
diff --git a/apps/web/lib/utils/video-upload.test.ts b/apps/web/lib/utils/video-upload.test.ts
index 62acc1e265a8..60aff235370b 100644
--- a/apps/web/lib/utils/video-upload.test.ts
+++ b/apps/web/lib/utils/video-upload.test.ts
@@ -137,3 +137,37 @@ describe("extractLoomId", () => {
expect(extractLoomId("https://loom.com/invalid/abcdef123456")).toBeNull();
});
});
+
+describe("extractYoutubeId — stored-value denial of service (ENG-2789)", () => {
+ // The pattern list this replaced used `youtube\\.com.*v=(…)`, whose `.*` backtracks once per
+ // `youtube.com`. `ZStorageUrl` is an unbounded `z.string()`, so a value this long persists and
+ // reaches the RESPONDENT renderer, where `element-media.tsx` converts it twice per render.
+ test("a long repeated-host URL resolves fast instead of blocking the thread", () => {
+ const stored = `https://youtube.com/${"youtube.com/".repeat(25_600)}`; // 307,220 characters
+
+ const startedAt = performance.now();
+ const result = extractYoutubeId(stored);
+ const elapsedMs = performance.now() - startedAt;
+
+ expect(result).toBeNull();
+ // Budget chosen from measurements, not a round number: the scan costs 6ms uninstrumented but
+ // ~330ms under the coverage run CI uses, which slows tight character loops far more than it
+ // slows a native regex. The pattern this replaced takes ~3300ms on the same input either way,
+ // so 2000ms sits above the instrumented pass and below the regression it guards against.
+ expect(elapsedMs).toBeLessThan(2000);
+ });
+
+ // Greedy `.*` took the LAST marker on the line and backtracked to an earlier one when the last
+ // had no id after it. Both are load-bearing, so they are pinned rather than left to the corpus.
+ test.each([
+ ["https://www.youtube.com/watch?x=v=FIRST&v=SECOND", "SECOND"],
+ ["https://youtube.com/embed/abc/embed/def", "def"],
+ ["https://youtube.com/watch?v=&v=OK", "OK"],
+ ["https://youtube.com/watch?v=&v=", null],
+ // `.` cannot cross a line terminator, so a marker on the next line is not reachable.
+ ["youtube.com\nv=NEXTLINE", null],
+ ["youtube.com v=SAMELINE\nyoutube.com v=SECOND", "SAMELINE"],
+ ])("resolves %s to %s, as the pattern did", (url, expected) => {
+ expect(extractYoutubeId(url)).toBe(expected);
+ });
+});
diff --git a/apps/web/lib/utils/video-upload.ts b/apps/web/lib/utils/video-upload.ts
index e921679bb9c3..990f37865d64 100644
--- a/apps/web/lib/utils/video-upload.ts
+++ b/apps/web/lib/utils/video-upload.ts
@@ -1,3 +1,7 @@
+import { extractYoutubeId } from "@formbricks/survey-ui/youtube-id";
+
+export { extractYoutubeId };
+
export const checkForYoutubeUrl = (url: string): boolean => {
try {
const youtubeUrl = new URL(url);
@@ -50,29 +54,6 @@ export const checkForLoomUrl = (url: string): boolean => {
}
};
-export const extractYoutubeId = (url: string): string | null => {
- let id = "";
-
- // Regular expressions for various YouTube URL formats
- const regExpList = [
- /youtu\.be\/([a-zA-Z0-9_-]+)/, // youtu.be/
- /youtube\.com.*v=([a-zA-Z0-9_-]+)/, // youtube.com/watch?v=
- /youtube\.com.*embed\/([a-zA-Z0-9_-]+)/, // youtube.com/embed/
- /youtube-nocookie\.com\/embed\/([a-zA-Z0-9_-]+)/, // youtube-nocookie.com/embed/
- ];
-
- regExpList.some((regExp) => {
- const match = regExp.exec(url);
- if (match?.[1]) {
- id = match[1];
- return true;
- }
- return false;
- });
-
- return id || null;
-};
-
export const extractVimeoId = (url: string): string | null => {
const regExp = /vimeo\.com\/(?:video\/)?(\d+)/;
const match = regExp.exec(url);
diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json
index 921c4945c191..7617a271bbc5 100644
--- a/apps/web/locales/de-DE.json
+++ b/apps/web/locales/de-DE.json
@@ -114,6 +114,7 @@
"company_email_required": "Bitte registriere dich mit deiner geschäftlichen E-Mail-Adresse.",
"have_an_account": "Hast du bereits ein Konto?",
"log_in": "Anmelden",
+ "password_requirements": "Passwortanforderungen",
"password_validation_contain_at_least_1_number": "Mindestens 1 Zahl enthalten",
"password_validation_minimum_8_and_maximum_128_characters": "Mindestens 8 & maximal 128 Zeichen",
"password_validation_uppercase_and_lowercase": "Mischung aus Groß- und Kleinbuchstaben",
@@ -259,6 +260,7 @@
"delete": "Löschen",
"delete_what": "{deleteWhat} löschen",
"description": "Beschreibung",
+ "digit_number_of_total": "Ziffer {number} von {total}",
"disable": "Deaktivieren",
"disabled": "Deaktiviert",
"disallow": "Nicht erlauben",
@@ -303,6 +305,7 @@
"finish": "Fertig",
"finished_at": "Beendet um",
"first_name": "Vorname",
+ "formbricks_homepage": "Formbricks-Startseite",
"formbricks_version": "Formbricks-Version",
"full_name": "Vollständiger Name",
"gathering_responses": "Sammle Antworten",
@@ -315,6 +318,7 @@
"hidden_field": "Verstecktes Feld",
"hidden_fields": "Versteckte Felder",
"hide_column": "Spalte ausblenden",
+ "hide_password": "Passwort verbergen",
"html": "HTML",
"id": "ID",
"image": "Bild",
@@ -484,6 +488,7 @@
"settings": "Einstellungen",
"share_feedback": "Feedback teilen",
"show": "Anzeigen",
+ "show_password": "Passwort anzeigen",
"show_response_count": "Antwortanzahl anzeigen",
"shown": "Angezeigt",
"size": "Größe",
diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json
index ce4f8afa4274..d244bd37ad7d 100644
--- a/apps/web/locales/en-US.json
+++ b/apps/web/locales/en-US.json
@@ -114,6 +114,7 @@
"company_email_required": "Please sign up with your company email address.",
"have_an_account": "Have an account?",
"log_in": "Log in",
+ "password_requirements": "Password requirements",
"password_validation_contain_at_least_1_number": "Contain at least 1 number",
"password_validation_minimum_8_and_maximum_128_characters": "Minimum 8 & Maximum 128 characters",
"password_validation_uppercase_and_lowercase": "Mix of uppercase and lowercase",
@@ -259,6 +260,7 @@
"delete": "Delete",
"delete_what": "Delete {deleteWhat}",
"description": "Description",
+ "digit_number_of_total": "Digit {number} of {total}",
"disable": "Disable",
"disabled": "Disabled",
"disallow": "Do not allow",
@@ -303,6 +305,7 @@
"finish": "Finish",
"finished_at": "Finished At",
"first_name": "First Name",
+ "formbricks_homepage": "Formbricks homepage",
"formbricks_version": "Formbricks Version",
"full_name": "Full name",
"gathering_responses": "Gathering responses",
@@ -315,6 +318,7 @@
"hidden_field": "Hidden field",
"hidden_fields": "Hidden fields",
"hide_column": "Hide column",
+ "hide_password": "Hide password",
"html": "HTML",
"id": "ID",
"image": "Image",
@@ -484,6 +488,7 @@
"settings": "Settings",
"share_feedback": "Share feedback",
"show": "Show",
+ "show_password": "Show password",
"show_response_count": "Show response count",
"shown": "Shown",
"size": "Size",
diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json
index 31f7b6da62b3..38462e187aa0 100644
--- a/apps/web/locales/es-ES.json
+++ b/apps/web/locales/es-ES.json
@@ -114,6 +114,7 @@
"company_email_required": "Regístrate con el correo electrónico de tu empresa.",
"have_an_account": "¿Tienes una cuenta?",
"log_in": "Iniciar sesión",
+ "password_requirements": "Requisitos de contraseña",
"password_validation_contain_at_least_1_number": "Contener al menos 1 número",
"password_validation_minimum_8_and_maximum_128_characters": "Mínimo 8 y máximo 128 caracteres",
"password_validation_uppercase_and_lowercase": "Mezcla de mayúsculas y minúsculas",
@@ -259,6 +260,7 @@
"delete": "Eliminar",
"delete_what": "Eliminar {deleteWhat}",
"description": "Descripción",
+ "digit_number_of_total": "Dígito {number} de {total}",
"disable": "Desactivar",
"disabled": "Desactivado",
"disallow": "No permitir",
@@ -303,6 +305,7 @@
"finish": "Finalizar",
"finished_at": "Finalizado el",
"first_name": "Nombre",
+ "formbricks_homepage": "Página de inicio de Formbricks",
"formbricks_version": "Versión de Formbricks",
"full_name": "Nombre completo",
"gathering_responses": "Recopilando respuestas",
@@ -315,6 +318,7 @@
"hidden_field": "Campo oculto",
"hidden_fields": "Campos ocultos",
"hide_column": "Ocultar columna",
+ "hide_password": "Ocultar contraseña",
"html": "HTML",
"id": "ID",
"image": "Imagen",
@@ -484,6 +488,7 @@
"settings": "Ajustes",
"share_feedback": "Compartir comentarios",
"show": "Mostrar",
+ "show_password": "Mostrar contraseña",
"show_response_count": "Mostrar recuento de respuestas",
"shown": "Mostrado",
"size": "Tamaño",
diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json
index 06a82a1983d2..2026d940a5ef 100644
--- a/apps/web/locales/fr-FR.json
+++ b/apps/web/locales/fr-FR.json
@@ -114,6 +114,7 @@
"company_email_required": "Veuillez vous inscrire avec votre adresse e-mail professionnelle.",
"have_an_account": "Avez-vous un compte ?",
"log_in": "Se connecter",
+ "password_requirements": "Exigences du mot de passe",
"password_validation_contain_at_least_1_number": "Contenir au moins 1 chiffre",
"password_validation_minimum_8_and_maximum_128_characters": "Minimum 8 et Maximum 128 caractères",
"password_validation_uppercase_and_lowercase": "Mélange de majuscules et de minuscules",
@@ -259,6 +260,7 @@
"delete": "Supprimer",
"delete_what": "Supprimer {deleteWhat}",
"description": "Description",
+ "digit_number_of_total": "Chiffre {number} sur {total}",
"disable": "Désactiver",
"disabled": "Désactivé",
"disallow": "Ne pas autoriser",
@@ -303,6 +305,7 @@
"finish": "Terminer",
"finished_at": "Terminé le",
"first_name": "Prénom",
+ "formbricks_homepage": "Page d'accueil de Formbricks",
"formbricks_version": "Version de Formbricks",
"full_name": "Nom complet",
"gathering_responses": "Collecte des réponses",
@@ -315,6 +318,7 @@
"hidden_field": "Champ caché",
"hidden_fields": "Champs cachés",
"hide_column": "Cacher la colonne",
+ "hide_password": "Masquer le mot de passe",
"html": "HTML",
"id": "ID",
"image": "Image",
@@ -484,6 +488,7 @@
"settings": "Paramètres",
"share_feedback": "Partager des commentaires",
"show": "Montrer",
+ "show_password": "Afficher le mot de passe",
"show_response_count": "Afficher le nombre de réponses",
"shown": "Montré",
"size": "Taille",
diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json
index 12081d9c11da..9bdb32ecd47e 100644
--- a/apps/web/locales/hu-HU.json
+++ b/apps/web/locales/hu-HU.json
@@ -114,6 +114,7 @@
"company_email_required": "Kérjük, regisztrálj a céges e-mail-címeddel.",
"have_an_account": "Van fiókja?",
"log_in": "Bejelentkezés",
+ "password_requirements": "Jelszókövetelmények",
"password_validation_contain_at_least_1_number": "Legalább 1 számot tartalmazzon",
"password_validation_minimum_8_and_maximum_128_characters": "Legalább 8 és legfeljebb 128 karakter",
"password_validation_uppercase_and_lowercase": "Nagybetűk és kisbetűk vegyesen",
@@ -259,6 +260,7 @@
"delete": "Törlés",
"delete_what": "{deleteWhat} törlése",
"description": "Leírás",
+ "digit_number_of_total": "{number}. számjegy a {total}-ból",
"disable": "Letiltás",
"disabled": "Letiltva",
"disallow": "Ne engedélyezze",
@@ -303,6 +305,7 @@
"finish": "Befejezés",
"finished_at": "Befejezve",
"first_name": "Keresztnév",
+ "formbricks_homepage": "Formbricks kezdőlap",
"formbricks_version": "Formbricks verziója",
"full_name": "Teljes név",
"gathering_responses": "Válaszok összegyűjtése",
@@ -315,6 +318,7 @@
"hidden_field": "Rejtett mező",
"hidden_fields": "Rejtett mezők",
"hide_column": "Oszlop elrejtése",
+ "hide_password": "Jelszó elrejtése",
"html": "HTML",
"id": "Azonosító",
"image": "Kép",
@@ -484,6 +488,7 @@
"settings": "Beállítások",
"share_feedback": "Visszajelzés megosztása",
"show": "Megjelenítés",
+ "show_password": "Jelszó megjelenítése",
"show_response_count": "Válaszok számának megjelenítése",
"shown": "Megjelenítve",
"size": "Méret",
diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json
index 4844bb471ec9..2e4dc07424de 100644
--- a/apps/web/locales/ja-JP.json
+++ b/apps/web/locales/ja-JP.json
@@ -114,6 +114,7 @@
"company_email_required": "会社のメールアドレスで登録してください。",
"have_an_account": "すでにアカウントをお持ちですか?",
"log_in": "ログイン",
+ "password_requirements": "パスワードの要件",
"password_validation_contain_at_least_1_number": "1つ以上の数字を含める",
"password_validation_minimum_8_and_maximum_128_characters": "8文字以上128文字以下",
"password_validation_uppercase_and_lowercase": "大文字と小文字を混ぜる",
@@ -259,6 +260,7 @@
"delete": "削除",
"delete_what": "{deleteWhat}を削除",
"description": "説明",
+ "digit_number_of_total": "{total}桁中{number}桁目",
"disable": "無効にする",
"disabled": "無効",
"disallow": "許可しない",
@@ -303,6 +305,7 @@
"finish": "完了",
"finished_at": "完了日時",
"first_name": "名",
+ "formbricks_homepage": "Formbricksホームページ",
"formbricks_version": "Formbricksバージョン",
"full_name": "氏名",
"gathering_responses": "回答を収集しています",
@@ -315,6 +318,7 @@
"hidden_field": "非表示フィールド",
"hidden_fields": "非表示フィールド",
"hide_column": "列を非表示",
+ "hide_password": "パスワードを非表示",
"html": "HTML",
"id": "ID",
"image": "画像",
@@ -484,6 +488,7 @@
"settings": "設定",
"share_feedback": "フィードバックを共有",
"show": "表示",
+ "show_password": "パスワードを表示",
"show_response_count": "回答数を表示",
"shown": "表示済み",
"size": "サイズ",
diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json
index 6bb8c782e2ea..6bc904b56445 100644
--- a/apps/web/locales/nl-NL.json
+++ b/apps/web/locales/nl-NL.json
@@ -114,6 +114,7 @@
"company_email_required": "Registreer je met je zakelijke e-mailadres.",
"have_an_account": "Heeft u een account?",
"log_in": "Inloggen",
+ "password_requirements": "Wachtwoordvereisten",
"password_validation_contain_at_least_1_number": "Bevat minimaal 1 nummer",
"password_validation_minimum_8_and_maximum_128_characters": "Minimaal 8 en maximaal 128 tekens",
"password_validation_uppercase_and_lowercase": "Mix van hoofdletters en kleine letters",
@@ -259,6 +260,7 @@
"delete": "Verwijderen",
"delete_what": "Verwijder {deleteWhat}",
"description": "Beschrijving",
+ "digit_number_of_total": "Cijfer {number} van {total}",
"disable": "Uitzetten",
"disabled": "Uitgeschakeld",
"disallow": "Niet toestaan",
@@ -303,6 +305,7 @@
"finish": "Finish",
"finished_at": "Voltooid op",
"first_name": "Voornaam",
+ "formbricks_homepage": "Formbricks startpagina",
"formbricks_version": "Formbricks-versie",
"full_name": "Volledige naam",
"gathering_responses": "Reacties verzamelen",
@@ -315,6 +318,7 @@
"hidden_field": "Verborgen veld",
"hidden_fields": "Verborgen velden",
"hide_column": "Kolom verbergen",
+ "hide_password": "Wachtwoord verbergen",
"html": "HTML",
"id": "ID",
"image": "Afbeelding",
@@ -484,6 +488,7 @@
"settings": "Instellingen",
"share_feedback": "Deel feedback",
"show": "Show",
+ "show_password": "Wachtwoord tonen",
"show_response_count": "Toon het aantal reacties",
"shown": "Getoond",
"size": "Maat",
diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json
index 10912feea88e..d90ed2cd7670 100644
--- a/apps/web/locales/pt-BR.json
+++ b/apps/web/locales/pt-BR.json
@@ -114,6 +114,7 @@
"company_email_required": "Cadastre-se com o e-mail da sua empresa.",
"have_an_account": "Já tem uma conta?",
"log_in": "Fazer login",
+ "password_requirements": "Requisitos da senha",
"password_validation_contain_at_least_1_number": "Conter pelo menos 1 número",
"password_validation_minimum_8_and_maximum_128_characters": "Mínimo 8 e Máximo 128 caracteres",
"password_validation_uppercase_and_lowercase": "mistura de maiúsculas e minúsculas",
@@ -259,6 +260,7 @@
"delete": "Apagar",
"delete_what": "Excluir {deleteWhat}",
"description": "Descrição",
+ "digit_number_of_total": "Dígito {number} de {total}",
"disable": "desativar",
"disabled": "Desativado",
"disallow": "Não permita",
@@ -303,6 +305,7 @@
"finish": "Terminar",
"finished_at": "Finalizado em",
"first_name": "Primeiro nome",
+ "formbricks_homepage": "Página inicial do Formbricks",
"formbricks_version": "Versão do Formbricks",
"full_name": "Nome completo",
"gathering_responses": "Recolhendo respostas",
@@ -315,6 +318,7 @@
"hidden_field": "Campo oculto",
"hidden_fields": "Campos ocultos",
"hide_column": "Ocultar coluna",
+ "hide_password": "Ocultar senha",
"html": "HTML",
"id": "ID",
"image": "imagem",
@@ -484,6 +488,7 @@
"settings": "Configurações",
"share_feedback": "Compartilhar feedback",
"show": "Legal",
+ "show_password": "Mostrar senha",
"show_response_count": "Mostrar contagem de respostas",
"shown": "mostrado",
"size": "Tamanho",
diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json
index 6b4ff2ee9186..735da485e906 100644
--- a/apps/web/locales/pt-PT.json
+++ b/apps/web/locales/pt-PT.json
@@ -114,6 +114,7 @@
"company_email_required": "Registe-se com o e-mail da sua empresa.",
"have_an_account": "Tem uma conta?",
"log_in": "Iniciar sessão",
+ "password_requirements": "Requisitos da palavra-passe",
"password_validation_contain_at_least_1_number": "Conter pelo menos 1 número",
"password_validation_minimum_8_and_maximum_128_characters": "Mínimo 8 e Máximo 128 caracteres",
"password_validation_uppercase_and_lowercase": "Mistura de maiúsculas e minúsculas",
@@ -259,6 +260,7 @@
"delete": "Eliminar",
"delete_what": "Eliminar {deleteWhat}",
"description": "Descrição",
+ "digit_number_of_total": "Dígito {number} de {total}",
"disable": "Desativar",
"disabled": "Desativado",
"disallow": "Não permitir",
@@ -303,6 +305,7 @@
"finish": "Concluir",
"finished_at": "Concluído Em",
"first_name": "Primeiro nome",
+ "formbricks_homepage": "Página inicial da Formbricks",
"formbricks_version": "Versão do Formbricks",
"full_name": "Nome completo",
"gathering_responses": "A recolher respostas",
@@ -315,6 +318,7 @@
"hidden_field": "Campo oculto",
"hidden_fields": "Campos ocultos",
"hide_column": "Ocultar coluna",
+ "hide_password": "Ocultar palavra-passe",
"html": "HTML",
"id": "ID",
"image": "Imagem",
@@ -484,6 +488,7 @@
"settings": "Configurações",
"share_feedback": "Partilhar feedback",
"show": "Mostrar",
+ "show_password": "Mostrar palavra-passe",
"show_response_count": "Mostrar contagem de respostas",
"shown": "Mostrado",
"size": "Tamanho",
diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json
index 390ada0ae948..b5be058a9693 100644
--- a/apps/web/locales/ro-RO.json
+++ b/apps/web/locales/ro-RO.json
@@ -114,6 +114,7 @@
"company_email_required": "Înregistrează-te cu adresa de e-mail a companiei.",
"have_an_account": "Ai un cont?",
"log_in": "Conectează-te",
+ "password_requirements": "Cerințe parolă",
"password_validation_contain_at_least_1_number": "Conține cel puțin 1 număr",
"password_validation_minimum_8_and_maximum_128_characters": "Minim 8 & Maxim 128 caractere",
"password_validation_uppercase_and_lowercase": "Amestec de majuscule și minuscule",
@@ -259,6 +260,7 @@
"delete": "Șterge",
"delete_what": "Șterge {deleteWhat}",
"description": "Descriere",
+ "digit_number_of_total": "Cifra {number} din {total}",
"disable": "Dezactivează",
"disabled": "Dezactivat",
"disallow": "Nu permite",
@@ -303,6 +305,7 @@
"finish": "Finalizează",
"finished_at": "Terminat la",
"first_name": "Prenume",
+ "formbricks_homepage": "Pagina principală Formbricks",
"formbricks_version": "Versiunea Formbricks",
"full_name": "Nume complet",
"gathering_responses": "Culegere răspunsuri",
@@ -315,6 +318,7 @@
"hidden_field": "Câmp ascuns",
"hidden_fields": "Câmpuri ascunse",
"hide_column": "Ascunde coloana",
+ "hide_password": "Ascunde parola",
"html": "HTML",
"id": "ID",
"image": "Imagine",
@@ -484,6 +488,7 @@
"settings": "Setări",
"share_feedback": "Împărtășește feedback",
"show": "Afișează",
+ "show_password": "Afișează parola",
"show_response_count": "Afișează numărul de răspunsuri",
"shown": "Afișat",
"size": "Mărime",
diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json
index 1a7756837918..01ba865c3a42 100644
--- a/apps/web/locales/ru-RU.json
+++ b/apps/web/locales/ru-RU.json
@@ -114,6 +114,7 @@
"company_email_required": "Зарегистрируйтесь, используя корпоративный адрес электронной почты.",
"have_an_account": "Уже есть аккаунт?",
"log_in": "Войти",
+ "password_requirements": "Требования к паролю",
"password_validation_contain_at_least_1_number": "Содержит как минимум 1 цифру",
"password_validation_minimum_8_and_maximum_128_characters": "От 8 до 128 символов",
"password_validation_uppercase_and_lowercase": "Сочетание заглавных и строчных букв",
@@ -259,6 +260,7 @@
"delete": "Удалить",
"delete_what": "Удалить {deleteWhat}",
"description": "Описание",
+ "digit_number_of_total": "Цифра {number} из {total}",
"disable": "Отключить",
"disabled": "Отключено",
"disallow": "Не разрешать",
@@ -303,6 +305,7 @@
"finish": "Завершить",
"finished_at": "Завершено",
"first_name": "Имя",
+ "formbricks_homepage": "Домашняя страница Formbricks",
"formbricks_version": "Версия Formbricks",
"full_name": "Полное имя",
"gathering_responses": "Сбор ответов",
@@ -315,6 +318,7 @@
"hidden_field": "Скрытое поле",
"hidden_fields": "Скрытые поля",
"hide_column": "Скрыть столбец",
+ "hide_password": "Скрыть пароль",
"html": "HTML",
"id": "ID",
"image": "Изображение",
@@ -484,6 +488,7 @@
"settings": "Настройки",
"share_feedback": "Поделиться отзывом",
"show": "Показать",
+ "show_password": "Показать пароль",
"show_response_count": "Показать количество ответов",
"shown": "Показано",
"size": "Размер",
diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json
index 636ac3b518f6..caaac8b756e4 100644
--- a/apps/web/locales/sv-SE.json
+++ b/apps/web/locales/sv-SE.json
@@ -114,6 +114,7 @@
"company_email_required": "Registrera dig med din företagsmejladress.",
"have_an_account": "Har du ett konto?",
"log_in": "Logga in",
+ "password_requirements": "Lösenordskrav",
"password_validation_contain_at_least_1_number": "Innehålla minst 1 siffra",
"password_validation_minimum_8_and_maximum_128_characters": "Minst 8 och högst 128 tecken",
"password_validation_uppercase_and_lowercase": "Blandning av stora och små bokstäver",
@@ -259,6 +260,7 @@
"delete": "Ta bort",
"delete_what": "Ta bort {deleteWhat}",
"description": "Beskrivning",
+ "digit_number_of_total": "Siffra {number} av {total}",
"disable": "Inaktivera",
"disabled": "Inaktiverad",
"disallow": "Tillåt inte",
@@ -303,6 +305,7 @@
"finish": "Slutför",
"finished_at": "Avslutad",
"first_name": "Förnamn",
+ "formbricks_homepage": "Formbricks startsida",
"formbricks_version": "Formbricks-version",
"full_name": "Fullständigt namn",
"gathering_responses": "Samlar in svar",
@@ -315,6 +318,7 @@
"hidden_field": "Dolt fält",
"hidden_fields": "Dolda fält",
"hide_column": "Dölj kolumn",
+ "hide_password": "Dölj lösenord",
"html": "HTML",
"id": "ID",
"image": "Bild",
@@ -484,6 +488,7 @@
"settings": "Inställningar",
"share_feedback": "Dela feedback",
"show": "Visa",
+ "show_password": "Visa lösenord",
"show_response_count": "Visa antal svar",
"shown": "Visad",
"size": "Storlek",
diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json
index 065f1cdf2432..d0a6dea92fc1 100644
--- a/apps/web/locales/tr-TR.json
+++ b/apps/web/locales/tr-TR.json
@@ -114,6 +114,7 @@
"company_email_required": "Lütfen şirket e-posta adresinizle kaydolun.",
"have_an_account": "Hesabınız var mı?",
"log_in": "Giriş yap",
+ "password_requirements": "Şifre gereksinimleri",
"password_validation_contain_at_least_1_number": "En az 1 rakam içermeli",
"password_validation_minimum_8_and_maximum_128_characters": "En az 8, en fazla 128 karakter",
"password_validation_uppercase_and_lowercase": "Büyük ve küçük harf karışımı",
@@ -259,6 +260,7 @@
"delete": "Sil",
"delete_what": "{deleteWhat} sil",
"description": "Açıklama",
+ "digit_number_of_total": "{number}/{total}. basamak",
"disable": "Devre dışı bırak",
"disabled": "Devre Dışı",
"disallow": "İzin verme",
@@ -303,6 +305,7 @@
"finish": "Bitir",
"finished_at": "Tamamlanma Zamanı",
"first_name": "Ad",
+ "formbricks_homepage": "Formbricks ana sayfası",
"formbricks_version": "Formbricks Sürümü",
"full_name": "Tam ad",
"gathering_responses": "Yanıtlar toplanıyor",
@@ -315,6 +318,7 @@
"hidden_field": "Gizli alan",
"hidden_fields": "Gizli alanlar",
"hide_column": "Sütunu gizle",
+ "hide_password": "Şifreyi gizle",
"html": "HTML",
"id": "ID",
"image": "Görsel",
@@ -484,6 +488,7 @@
"settings": "Ayarlar",
"share_feedback": "Geri bildirim paylaş",
"show": "Göster",
+ "show_password": "Şifreyi göster",
"show_response_count": "Yanıt sayısını göster",
"shown": "Gösterildi",
"size": "Boyut",
diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json
index 50205a75e0d1..a6543fcea067 100644
--- a/apps/web/locales/zh-Hans-CN.json
+++ b/apps/web/locales/zh-Hans-CN.json
@@ -114,6 +114,7 @@
"company_email_required": "请使用您的公司电子邮箱注册。",
"have_an_account": "有账户?",
"log_in": "登录",
+ "password_requirements": "密码要求",
"password_validation_contain_at_least_1_number": "包含至少 1 个 数字",
"password_validation_minimum_8_and_maximum_128_characters": "至少 8 个 和 最多 128 个 字符",
"password_validation_uppercase_and_lowercase": "大小写混合",
@@ -259,6 +260,7 @@
"delete": "删除",
"delete_what": "删除{deleteWhat}",
"description": "描述",
+ "digit_number_of_total": "第 {number} 位,共 {total} 位",
"disable": "禁用",
"disabled": "已禁用",
"disallow": "不允许",
@@ -303,6 +305,7 @@
"finish": "完成",
"finished_at": "完成时间",
"first_name": "名字",
+ "formbricks_homepage": "Formbricks 主页",
"formbricks_version": "Formbricks 版本",
"full_name": "全名",
"gathering_responses": "收集反馈",
@@ -315,6 +318,7 @@
"hidden_field": "隐藏 字段",
"hidden_fields": "隐藏 字段",
"hide_column": "隐藏 列",
+ "hide_password": "隐藏密码",
"html": "HTML",
"id": "ID",
"image": "图片",
@@ -484,6 +488,7 @@
"settings": "设置",
"share_feedback": "分享 反馈",
"show": "显示",
+ "show_password": "显示密码",
"show_response_count": "显示 响应 计数",
"shown": "显示",
"size": "尺寸",
diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json
index b09e118fb172..bd49670114ed 100644
--- a/apps/web/locales/zh-Hant-TW.json
+++ b/apps/web/locales/zh-Hant-TW.json
@@ -114,6 +114,7 @@
"company_email_required": "請使用您的公司電子郵件地址註冊。",
"have_an_account": "已有帳戶?",
"log_in": "登入",
+ "password_requirements": "密碼要求",
"password_validation_contain_at_least_1_number": "包含至少 1 個數字",
"password_validation_minimum_8_and_maximum_128_characters": "最少 8 個 & 最多 128 個字元",
"password_validation_uppercase_and_lowercase": "混合使用大小寫字母",
@@ -259,6 +260,7 @@
"delete": "刪除",
"delete_what": "刪除{deleteWhat}",
"description": "描述",
+ "digit_number_of_total": "第 {number} 位數字,共 {total} 位",
"disable": "停用",
"disabled": "已停用",
"disallow": "不允許",
@@ -303,6 +305,7 @@
"finish": "完成",
"finished_at": "完成時間",
"first_name": "名字",
+ "formbricks_homepage": "Formbricks 首頁",
"formbricks_version": "Formbricks 版本",
"full_name": "全名",
"gathering_responses": "收集回應中",
@@ -315,6 +318,7 @@
"hidden_field": "隱藏欄位",
"hidden_fields": "隱藏欄位",
"hide_column": "隱藏欄位",
+ "hide_password": "隱藏密碼",
"html": "HTML",
"id": "ID",
"image": "圖片",
@@ -484,6 +488,7 @@
"settings": "設定",
"share_feedback": "分享回饋",
"show": "顯示",
+ "show_password": "顯示密碼",
"show_response_count": "顯示回應數",
"shown": "已顯示",
"size": "大小",
diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx
index 98344726160e..69184e9bad44 100644
--- a/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx
+++ b/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx
@@ -12,7 +12,7 @@ import { getSurveyDateFormatMap } from "@/lib/utils/date-display";
import { parseRecallInfo } from "@/lib/utils/recall";
import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
-import { isValidValue } from "../util";
+import { isValidValue, splitRecallHighlights } from "../util";
import { ElementSkip } from "./ElementSkip";
import { HiddenFields } from "./HiddenFields";
import { RenderResponse } from "./RenderResponse";
@@ -37,9 +37,7 @@ export const SingleResponseCardBody = ({
const isFirstElementAnswered = elements[0] ? !!response.data[elements[0].id] : false;
const { t } = useTranslation();
const formatTextWithSlashes = (text: string) => {
- // Updated regex to match content between #/ and \#
- const regex = /#\/(.*?)\\#/g;
- const parts = text.split(regex);
+ const parts = splitRecallHighlights(text);
return parts.map((part, index) => {
// Check if the part was inside #/ and \#
diff --git a/apps/web/modules/analysis/components/SingleResponseCard/util.test.ts b/apps/web/modules/analysis/components/SingleResponseCard/util.test.ts
index ebfc8f55303f..d17354ab73f3 100644
--- a/apps/web/modules/analysis/components/SingleResponseCard/util.test.ts
+++ b/apps/web/modules/analysis/components/SingleResponseCard/util.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
-import { isSubmissionTimeMoreThan5Minutes, isValidValue } from "./util";
+import { isSubmissionTimeMoreThan5Minutes, isValidValue, splitRecallHighlights } from "./util";
describe("isValidValue", () => {
test("returns false for an empty string", () => {
@@ -49,3 +49,53 @@ describe("isSubmissionTimeMoreThan5Minutes", () => {
expect(isSubmissionTimeMoreThan5Minutes(recentTime)).toBe(false);
});
});
+
+describe("splitRecallHighlights", () => {
+ // The regex this replaces, as the oracle every case is compared against.
+ const viaRegex = (text: string): string[] => text.split(/#\/(.*?)\\#/g);
+
+ const FIXED = [
+ "",
+ "plain text",
+ "before #/name\\# after",
+ "#/a\\# and #/b\\#",
+ "#/\\#",
+ "#/unclosed",
+ "#/spans\nnewline\\#",
+ "#/one\\#\n#/two\\#",
+ "text with \\# but no opener",
+ "#/back\\slash inside\\#",
+ "#/#/nested\\#",
+ "#/a\\#trailing",
+ "##//weird\\#",
+ "#/\r\n\\#",
+ "#/\u2028\\#",
+ // What a length cap got wrong: an over-long span containing another opener.
+ `#/${"a".repeat(2000)}#/short\\#tail`,
+ ];
+ const ALPHABET = "#/\\ab\n\r ";
+ const random = Array.from({ length: 30000 }, () => {
+ const n = Math.floor(Math.random() * 24);
+ let s = "";
+ for (let i = 0; i < n; i++) s += ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
+ return s;
+ });
+
+ test("matches the regex it replaces", () => {
+ for (const text of [...FIXED, ...random]) {
+ expect(splitRecallHighlights(text), `input: ${JSON.stringify(text)}`).toEqual(viaRegex(text));
+ }
+ });
+
+ test("stays linear where the regex was quadratic", () => {
+ // `#/` repeated with no closing `\#`: the regex expands to the end from every opener.
+ const pathological = "#/".repeat(100000);
+
+ const startedAt = performance.now();
+ const parts = splitRecallHighlights(pathological);
+ const elapsedMs = performance.now() - startedAt;
+
+ expect(parts).toEqual([pathological]);
+ expect(elapsedMs).toBeLessThan(500);
+ });
+});
diff --git a/apps/web/modules/analysis/components/SingleResponseCard/util.ts b/apps/web/modules/analysis/components/SingleResponseCard/util.ts
index af76d0a68b61..f9bc4920e019 100644
--- a/apps/web/modules/analysis/components/SingleResponseCard/util.ts
+++ b/apps/web/modules/analysis/components/SingleResponseCard/util.ts
@@ -15,3 +15,60 @@ export const isSubmissionTimeMoreThan5Minutes = (submissionTimeISOString: Date)
const timeDifference: number = (currentTime.getTime() - submissionTime.getTime()) / (1000 * 60); // Convert milliseconds to minutes
return timeDifference > 5;
};
+
+const RECALL_HIGHLIGHT_OPEN = "#/";
+const RECALL_HIGHLIGHT_CLOSE = String.raw`\#`;
+
+/** The characters `.` excludes, so a highlighted span can never cross one. */
+const isLineTerminator = (character: string): boolean =>
+ character === "\n" || character === "\r" || character === "\u2028" || character === "\u2029";
+
+/**
+ * Split recall-highlighted text into alternating plain and highlighted parts, exactly as
+ * `text.split(/#\/(.*?)\\#/g)` did: even indices are plain text, odd indices are the highlighted
+ * spans.
+ *
+ * A scan rather than the regex. `(.*?)` is unbounded, so on a run of `#/` with no `\#` after it the
+ * engine expands to the end of the text from every one — O(N^2), measured 4.9s on 200k characters,
+ * over text that carries respondent-submitted answers into the admin's browser. Capping the span is
+ * not a fix: when the over-long span contains another `#/`, the match restarts there and highlights
+ * a different range.
+ *
+ * Linear because a failed span skips to the next line rather than to the next character: `.` cannot
+ * cross a line terminator, so if no `\#` precedes the next one, no later `#/` on that line has one
+ * either.
+ */
+export const splitRecallHighlights = (text: string): string[] => {
+ const parts: string[] = [];
+ let copiedTo = 0;
+ let searchFrom = 0;
+
+ while (searchFrom <= text.length) {
+ const open = text.indexOf(RECALL_HIGHLIGHT_OPEN, searchFrom);
+ if (open === -1) break;
+
+ const contentStart = open + RECALL_HIGHLIGHT_OPEN.length;
+ let close = -1;
+ let scan = contentStart;
+ while (scan < text.length && !isLineTerminator(text[scan])) {
+ if (text.startsWith(RECALL_HIGHLIGHT_CLOSE, scan)) {
+ close = scan;
+ break;
+ }
+ scan++;
+ }
+
+ if (close === -1) {
+ // No close before the line ends, so nothing on the rest of this line can close either.
+ searchFrom = scan < text.length ? scan + 1 : text.length + 1;
+ continue;
+ }
+
+ parts.push(text.slice(copiedTo, open), text.slice(contentStart, close));
+ copiedTo = close + RECALL_HIGHLIGHT_CLOSE.length;
+ searchFrom = copiedTo;
+ }
+
+ parts.push(text.slice(copiedTo));
+ return parts;
+};
diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts
index a40664a0da5e..f36defe3f5af 100644
--- a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts
+++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts
@@ -378,7 +378,7 @@ export const updateUser = async (
const results = await prisma.$transaction(operations);
// Retrieve the updated user result. Since the update was the last operation, it is the last item.
- updatedUser = results[results.length - 1];
+ updatedUser = results.at(-1);
}
await reconcileOrganizationMembership(organizationId, updatedUser.id);
diff --git a/apps/web/modules/auth/components/back-to-login-button.tsx b/apps/web/modules/auth/components/back-to-login-button.tsx
index 09b76074a642..9beba3c54e78 100644
--- a/apps/web/modules/auth/components/back-to-login-button.tsx
+++ b/apps/web/modules/auth/components/back-to-login-button.tsx
@@ -13,10 +13,10 @@ export const BackToLoginButton = async ({ callbackUrl }: Readonly<{ callbackUrl?
const t = await getTranslate();
const href = callbackUrl ? `/auth/login?callbackUrl=${encodeURIComponent(callbackUrl)}` : "/auth/login";
return (
-