diff --git a/.github/workflows/translation-check.yml b/.github/workflows/translation-check.yml
index 30e90872ec27..217991778ecc 100644
--- a/.github/workflows/translation-check.yml
+++ b/.github/workflows/translation-check.yml
@@ -6,19 +6,9 @@ permissions:
on:
pull_request:
types: [opened, synchronize, reopened]
- paths:
- - "apps/web/**/*.ts"
- - "apps/web/**/*.tsx"
- - "apps/web/locales/**/*.json"
- - "scan-translations.ts"
push:
branches:
- main
- paths:
- - "apps/web/**/*.ts"
- - "apps/web/**/*.tsx"
- - "apps/web/locales/**/*.json"
- - "scan-translations.ts"
jobs:
validate-translations:
@@ -33,30 +23,38 @@ jobs:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+ - name: Check for relevant changes
+ id: changes
+ uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
- fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
+ filters: |
+ translations:
+ - 'apps/web/**/*.ts'
+ - 'apps/web/**/*.tsx'
+ - 'apps/web/locales/**/*.json'
+ - 'packages/surveys/src/**/*.{ts,tsx}'
+ - 'packages/surveys/locales/**/*.json'
+ - 'packages/email/**/*.{ts,tsx}'
- name: Setup Node.js 22.x
+ if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 22.x
- name: Install pnpm
+ if: steps.changes.outputs.translations == 'true'
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
+ if: steps.changes.outputs.translations == 'true'
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Validate translation keys
- run: |
- echo ""
- echo "🔍 Validating translation keys..."
- echo ""
- pnpm run scan-translations
-
- - name: Summary
- if: success()
- run: |
- echo ""
- echo "✅ Translation validation completed successfully!"
- echo ""
+ if: steps.changes.outputs.translations == 'true'
+ run: pnpm run scan-translations
+
+ - name: Skip (no translation-related changes)
+ if: steps.changes.outputs.translations != 'true'
+ run: echo "No translation-related files changed — skipping validation."
diff --git a/.husky/pre-commit b/.husky/pre-commit
index a88bfb1c05cc..e02c24e2b5c2 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1,40 +1 @@
-# Load environment variables from .env files
-if [ -f .env ]; then
- set -a
- . .env
- set +a
-fi
-
-pnpm lint-staged
-
-# Run Lingo.dev i18n workflow if LINGODOTDEV_API_KEY is set
-if [ -n "$LINGODOTDEV_API_KEY" ]; then
- echo ""
- echo "🌍 Running Lingo.dev translation workflow..."
- echo ""
-
- # Run translation generation and validation
- if pnpm run i18n; then
- echo ""
- echo "✅ Translation validation passed"
- echo ""
- # Add updated locale files to git
- git add apps/web/locales/*.json
- else
- echo ""
- echo "❌ Translation validation failed!"
- echo ""
- echo "Please fix the translation issues above before committing:"
- echo " • Add missing translation keys to your locale files"
- echo " • Remove unused translation keys"
- echo ""
- echo "Or run 'pnpm i18n' to see the detailed report"
- echo ""
- exit 1
- fi
-else
- echo ""
- echo "⚠️ Skipping translation validation: LINGODOTDEV_API_KEY is not set"
- echo " (This is expected for community contributors)"
- echo ""
-fi
\ No newline at end of file
+pnpm lint-staged
\ No newline at end of file
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/NotificationSwitch.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/NotificationSwitch.tsx
index 5fcf9f8ffe94..ac26aa05097a 100644
--- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/NotificationSwitch.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/NotificationSwitch.tsx
@@ -30,7 +30,7 @@ export const NotificationSwitch = ({
const isChecked =
notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
- : notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true;
+ : notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
const handleSwitchChange = async () => {
setIsLoading(true);
@@ -49,8 +49,11 @@ export const NotificationSwitch = ({
];
}
} else {
- updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] =
- !updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
+ updatedNotificationSettings[notificationType] = {
+ ...updatedNotificationSettings[notificationType],
+ [surveyOrProjectOrOrganizationId]:
+ !updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
+ };
}
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
@@ -78,7 +81,7 @@ export const NotificationSwitch = ({
) {
switch (notificationType) {
case "alert":
- if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) {
+ if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
handleSwitchChange();
toast.success(
t(
diff --git a/apps/web/app/middleware/endpoint-validator.test.ts b/apps/web/app/middleware/endpoint-validator.test.ts
index 2e349e977a29..7e032e1a034d 100644
--- a/apps/web/app/middleware/endpoint-validator.test.ts
+++ b/apps/web/app/middleware/endpoint-validator.test.ts
@@ -257,6 +257,7 @@ describe("endpoint-validator", () => {
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
expect(isAuthProtectedRoute("/")).toBe(false);
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
+ expect(isAuthProtectedRoute("/p/pretty-url")).toBe(false);
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
expect(isAuthProtectedRoute("/health")).toBe(false);
});
@@ -312,6 +313,19 @@ describe("endpoint-validator", () => {
expect(isPublicDomainRoute("/c")).toBe(false);
expect(isPublicDomainRoute("/contact/token")).toBe(false);
});
+
+ test("should return true for pretty URL survey routes", () => {
+ expect(isPublicDomainRoute("/p/pretty123")).toBe(true);
+ expect(isPublicDomainRoute("/p/pretty-name-with-dashes")).toBe(true);
+ expect(isPublicDomainRoute("/p/survey_id_with_underscores")).toBe(true);
+ expect(isPublicDomainRoute("/p/abc123def456")).toBe(true);
+ });
+
+ test("should return false for malformed pretty URL survey routes", () => {
+ expect(isPublicDomainRoute("/p/")).toBe(false);
+ expect(isPublicDomainRoute("/p")).toBe(false);
+ expect(isPublicDomainRoute("/pretty/123")).toBe(false);
+ });
test("should return true for client API routes", () => {
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
@@ -375,6 +389,8 @@ describe("endpoint-validator", () => {
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false);
+ expect(isAdminDomainRoute("/p/pretty123")).toBe(false);
+ expect(isAdminDomainRoute("/p/pretty-name-with-dashes")).toBe(false);
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
});
@@ -390,6 +406,7 @@ describe("endpoint-validator", () => {
test("should allow public routes on public domain", () => {
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
+ expect(isRouteAllowedForDomain("/p/pretty123", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
@@ -426,6 +443,8 @@ describe("endpoint-validator", () => {
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false);
+ expect(isRouteAllowedForDomain("/p/pretty123", false)).toBe(false);
+ expect(isRouteAllowedForDomain("/p/pretty-name-with-dashes", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
});
@@ -440,6 +459,8 @@ describe("endpoint-validator", () => {
test("should handle paths with query parameters and fragments", () => {
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true);
+ expect(isRouteAllowedForDomain("/p/pretty123?param=value", true)).toBe(true);
+ expect(isRouteAllowedForDomain("/p/pretty123#section", true)).toBe(true);
expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false);
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
});
@@ -450,6 +471,7 @@ describe("endpoint-validator", () => {
describe("URL parsing edge cases", () => {
test("should handle paths with query parameters", () => {
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
+ expect(isPublicDomainRoute("/p/pretty123?param=value&other=test")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true);
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
@@ -458,12 +480,14 @@ describe("endpoint-validator", () => {
test("should handle paths with fragments", () => {
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true);
+ expect(isPublicDomainRoute("/p/pretty123#section")).toBe(true);
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
});
test("should handle trailing slashes", () => {
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
+ expect(isPublicDomainRoute("/p/pretty123/")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
isManagementApi: true,
@@ -478,6 +502,9 @@ describe("endpoint-validator", () => {
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true);
+ expect(isPublicDomainRoute("/p/pretty123/preview")).toBe(true);
+ expect(isPublicDomainRoute("/p/pretty123/embed")).toBe(true);
+ expect(isPublicDomainRoute("/p/pretty123/thank-you")).toBe(true);
});
test("should handle nested client API routes", () => {
@@ -529,6 +556,7 @@ describe("endpoint-validator", () => {
test("should handle special characters in survey IDs", () => {
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
+ expect(isPublicDomainRoute("/p/pretty-123_test.v2")).toBe(true);
});
});
@@ -536,6 +564,7 @@ describe("endpoint-validator", () => {
test("should properly validate malicious or injection-like URLs", () => {
// SQL injection-like attempts
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
+ expect(isPublicDomainRoute("/p/'; DROP TABLE users; --")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
@@ -543,10 +572,12 @@ describe("endpoint-validator", () => {
// Path traversal attempts
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
+ expect(isPublicDomainRoute("/p/../../../etc/passwd")).toBe(true);
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
// XSS-like attempts
expect(isPublicDomainRoute("/s/")).toBe(true);
+ expect(isPublicDomainRoute("/p/")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/")).toEqual({
isClientSideApi: true,
isRateLimited: true,
@@ -556,6 +587,7 @@ describe("endpoint-validator", () => {
test("should handle URL encoding", () => {
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
+ expect(isPublicDomainRoute("/p/pretty%20123")).toBe(true);
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
isManagementApi: true,
@@ -591,6 +623,7 @@ describe("endpoint-validator", () => {
// These should not match due to case sensitivity
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
+ expect(isPublicDomainRoute("/P/pretty123")).toBe(false);
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
isClientSideApi: false,
isRateLimited: true,
diff --git a/apps/web/app/middleware/route-config.ts b/apps/web/app/middleware/route-config.ts
index 2891e0c3c214..189283bbdf26 100644
--- a/apps/web/app/middleware/route-config.ts
+++ b/apps/web/app/middleware/route-config.ts
@@ -7,6 +7,7 @@ const PUBLIC_ROUTES = {
SURVEY_ROUTES: [
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
+ /^\/p\/[^/]+/, // /p/[prettyUrl] - pretty URL pages
],
// API routes accessible from public domain
diff --git a/docs/self-hosting/configuration/domain-configuration.mdx b/docs/self-hosting/configuration/domain-configuration.mdx
index 7e427205ab75..14f48e1dbb44 100644
--- a/docs/self-hosting/configuration/domain-configuration.mdx
+++ b/docs/self-hosting/configuration/domain-configuration.mdx
@@ -85,6 +85,7 @@ When PUBLIC_URL is configured, the following routes are automatically served fro
- `/s/{surveyId}` - Individual survey access
- `/c/{jwt}` - Personalized link survey access (JWT-based access)
+- `/p/{survey-slug}` - Pretty URL survey access
- Embedded survey endpoints
#### API Routes