diff --git a/backend/__tests__/routes/slideshowAdmin.test.js b/backend/__tests__/routes/slideshowAdmin.test.js index 45c08fbe..ca277004 100644 --- a/backend/__tests__/routes/slideshowAdmin.test.js +++ b/backend/__tests__/routes/slideshowAdmin.test.js @@ -57,6 +57,9 @@ async function insertEvent(db, adminId, over = {}) { describe('admin Live Slideshow endpoints', () => { let db; let cleanup; let app; let adminId; let token; + // Match slideshowPublic.test.js — bootCrmDb's full migration run intermittently + // exceeds Jest's default 5s `beforeAll` timeout on slower CI runners; raise + // it so this doesn't block PRs. beforeAll(async () => { ({ db, cleanup } = await bootCrmDb()); ({ adminId } = await seedMinimal(db)); @@ -72,7 +75,7 @@ describe('admin Live Slideshow endpoints', () => { app.use((err, req, res, next) => { res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code }); }); - }); + }, 30000); afterAll(async () => { await cleanup(); }); diff --git a/backend/__tests__/routes/slideshowPublic.test.js b/backend/__tests__/routes/slideshowPublic.test.js index 64e1d185..5bb6633a 100644 --- a/backend/__tests__/routes/slideshowPublic.test.js +++ b/backend/__tests__/routes/slideshowPublic.test.js @@ -67,6 +67,11 @@ async function insertEvent(db, over = {}) { describe('public Live Slideshow routes', () => { let db; let cleanup; let app; + // bootCrmDb runs the full migration set against a fresh SQLite file, which + // takes <2s locally but has been observed to exceed Jest's default 5s + // `beforeAll` timeout on slower GitHub Actions runners (~5.4s — runner-to- + // runner I/O variance). Raise the hook timeout so this doesn't intermittently + // block PRs on CI; doesn't affect happy-path local runs. beforeAll(async () => { ({ db, cleanup } = await bootCrmDb()); await seedMinimal(db); @@ -81,7 +86,7 @@ describe('public Live Slideshow routes', () => { app.use((err, req, res, next) => { res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code }); }); - }); + }, 30000); afterAll(async () => { await cleanup(); }); diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 17b6d5be..afaf7b03 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -693,7 +693,14 @@ "wrongPassword": "Falsches Passwort. Bitte überprüfen Sie Ihr Passwort und versuchen Sie es erneut.", "tooManyAttempts": "Zu viele fehlgeschlagene Anmeldeversuche. Bitte versuchen Sie es später erneut.", "pleaseEnterPassword": "Bitte geben Sie ein Passwort ein", - "passwordHint": "Das Passwort wurde vom Veranstalter bereitgestellt. Kontaktieren Sie ihn, wenn Sie es nicht haben." + "passwordHint": "Das Passwort wurde vom Veranstalter bereitgestellt. Kontaktieren Sie ihn, wenn Sie es nicht haben.", + "iab": { + "instagram": { + "title": "Diesen Link im Browser öffnen", + "ios": "Der eingebaute Browser von Instagram blockiert das Passwort-Login manchmal. Oben rechts auf das ⋯-Menü tippen und „Im externen Browser öffnen“ wählen (oder den Link kopieren und in Safari einfügen).", + "android": "Der eingebaute Browser von Instagram blockiert das Passwort-Login manchmal. Oben rechts auf das ⋮-Menü tippen und „Im externen Browser öffnen“ wählen (oder den Link kopieren und in Chrome einfügen)." + } + } }, "gallery": { "expires": "Läuft ab", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index ab160ca8..600f953e 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -249,7 +249,14 @@ "wrongPassword": "Incorrect password. Please check your password and try again.", "tooManyAttempts": "Too many failed login attempts. Please try again later.", "pleaseEnterPassword": "Please enter a password", - "passwordHint": "The password was provided by the event organizer. Contact them if you don't have it." + "passwordHint": "The password was provided by the event organizer. Contact them if you don't have it.", + "iab": { + "instagram": { + "title": "Open this link in your browser", + "ios": "Instagram's built-in browser sometimes blocks the password login. Tap the ⋯ menu in the top right, then \"Open in external browser\" (or copy the link and paste into Safari).", + "android": "Instagram's built-in browser sometimes blocks the password login. Tap the ⋮ menu in the top right, then \"Open in external browser\" (or copy the link and paste into Chrome)." + } + } }, "gallery": { "expires": "Expires", diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index a59bdc6d..811f25b7 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -16,6 +16,7 @@ import { galleryService } from '../services'; import { GALLERY_THEME_PRESETS } from '../types/theme.types'; import { buildResourceUrl } from '../utils/url'; import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl'; +import { detectInAppBrowser } from '../utils/inAppBrowser'; export const GalleryPage: React.FC = () => { const { slug: rawSlug, token: rawToken } = useParams<{ slug: string; token?: string }>(); @@ -28,6 +29,9 @@ export const GalleryPage: React.FC = () => { const [loginError, setLoginError] = useState(null); const [recaptchaToken, setRecaptchaToken] = useState(null); const [autoLoginAttempted, setAutoLoginAttempted] = useState(false); + // Evaluate once per mount — UA doesn't change at runtime, and using useMemo + // avoids re-running detection on every render of the form. + const iabDetection = React.useMemo(() => detectInAppBrowser(), []); const [resolvedSlug, setResolvedSlug] = useState(() => { if (rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug)) { return null; @@ -212,7 +216,14 @@ export const GalleryPage: React.FC = () => { return; } - await login(resolvedSlug, requiresPassword ? password : '', recaptchaToken); + // Trim the password before sending. The Instagram in-app browser's + // predictive-text keyboard frequently appends a trailing space when the + // user taps the submit button, which then fails byte-exact bcrypt + // compare on the backend with no visible cause (#654). Event-gallery + // passwords don't legitimately carry leading/trailing whitespace, so + // trimming silently is safe. + const submittedPassword = requiresPassword ? password.trim() : ''; + await login(resolvedSlug, submittedPassword, recaptchaToken); if (requiresPassword) { analyticsService.trackGalleryEvent('password_entry', { @@ -385,6 +396,33 @@ export const GalleryPage: React.FC = () => {

{t('auth.enterPassword')}

+ {/* Instagram in-app browser warning (#654). The IAB's keyboard + bridge silently mangles password inputs (autocaps overrides, + predictive-text-appended trailing spaces, stale autofill). + Detect it and surface a "open in your normal browser" hint + so the user can self-rescue. */} + {iabDetection.app === 'instagram' && ( +
+

+ {t('auth.iab.instagram.title', 'Open this link in your browser')} +

+

+ {iabDetection.platform === 'ios' + ? t( + 'auth.iab.instagram.ios', + "Instagram's built-in browser sometimes blocks the password login. Tap the ⋯ menu in the top right, then \"Open in external browser\" (or copy the link and paste into Safari).", + ) + : t( + 'auth.iab.instagram.android', + "Instagram's built-in browser sometimes blocks the password login. Tap the ⋮ menu in the top right, then \"Open in external browser\" (or copy the link and paste into Chrome).", + )} +

+
+ )} +
{ onChange={(e) => setPassword(e.target.value)} error={loginError || undefined} autoFocus + // Defend against in-app-browser keyboard mangling (#654): + // - autoCapitalize: stop iOS autocaps turning `wedding2026` + // into `Wedding2026` inside IAB WKWebViews + // - autoCorrect / spellCheck: stop predictive-text rewrites + // - autoComplete: tell password managers this is the + // current-password slot so they autofill the right value + // (vs the IAB's older saved-password store) + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + autoComplete="current-password" className="text-sm sm:text-base" /> diff --git a/frontend/src/utils/__tests__/inAppBrowser.test.ts b/frontend/src/utils/__tests__/inAppBrowser.test.ts new file mode 100644 index 00000000..6aa738f4 --- /dev/null +++ b/frontend/src/utils/__tests__/inAppBrowser.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, afterEach, vi } from 'vitest'; +import { detectInAppBrowser } from '../inAppBrowser'; + +/** + * Tests for the Instagram IAB detection helper (#654). The detector is the + * gate that decides whether to surface the "open in browser" banner on the + * gallery password page, so we pin a handful of representative real-world + * UA strings here. + */ + +function stubUserAgent(ua: string) { + vi.stubGlobal('navigator', { userAgent: ua }); +} + +describe('detectInAppBrowser', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('detects Instagram on iOS', () => { + stubUserAgent( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Instagram 327.0.0.42.122', + ); + expect(detectInAppBrowser()).toEqual({ app: 'instagram', platform: 'ios' }); + }); + + it('detects Instagram on Android', () => { + stubUserAgent( + 'Mozilla/5.0 (Linux; Android 14; SM-S921B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 Instagram 327.0.0.42.122 Android (34/14; 480dpi; 1080x2208; samsung; SM-S921B; e1q; qcom; en_US; 565243795)', + ); + expect(detectInAppBrowser()).toEqual({ app: 'instagram', platform: 'android' }); + }); + + it('returns null for plain Safari on iPhone', () => { + stubUserAgent( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + ); + expect(detectInAppBrowser()).toEqual({ app: null, platform: 'ios' }); + }); + + it('returns null for plain Chrome on Android', () => { + stubUserAgent( + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.144 Mobile Safari/537.36', + ); + expect(detectInAppBrowser()).toEqual({ app: null, platform: 'android' }); + }); + + it('returns null for desktop Chrome', () => { + stubUserAgent( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + ); + expect(detectInAppBrowser()).toEqual({ app: null, platform: 'other' }); + }); + + it('matches `Instagram` case-insensitively (defensive against UA quirks)', () => { + stubUserAgent( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 instagram 327.0.0', + ); + expect(detectInAppBrowser().app).toBe('instagram'); + }); + + it('does NOT match "Instagram" as a substring of an unrelated token', () => { + // Word-boundary match prevents matching, e.g. "FooInstagrambar" — vanishingly + // unlikely in real UAs but the regex should still be conservative. + stubUserAgent( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 myInstagramReader/1.0', + ); + expect(detectInAppBrowser().app).toBe(null); + }); + + it('returns app:null when navigator is undefined (SSR safety)', () => { + vi.stubGlobal('navigator', undefined); + expect(detectInAppBrowser()).toEqual({ app: null, platform: 'other' }); + }); +}); diff --git a/frontend/src/utils/inAppBrowser.ts b/frontend/src/utils/inAppBrowser.ts new file mode 100644 index 00000000..c9f64468 --- /dev/null +++ b/frontend/src/utils/inAppBrowser.ts @@ -0,0 +1,51 @@ +/** + * Detect when the page is loaded inside a known social-app in-app browser + * (#654). These WKWebView / WebView wrappers — Instagram's IAB especially — + * mangle password inputs in ways the host app can't override: keyboard + * autocapitalisation overrides, predictive-text-appended trailing spaces, + * stale saved-password autofill, smart-quote substitution. The result is + * "Incorrect Password" on a byte-correct user input, with no visible cause. + * + * The cheapest mitigation is to detect the IAB UA and surface a banner + * asking the user to open the link in their device's normal browser; the + * gallery password form behaves correctly once we're out of the IAB. + * + * Detection uses navigator.userAgent. UA spoofing is possible but irrelevant + * here — the banner is advisory; the legitimate IAB UA strings are stable. + */ + +export type InAppBrowser = 'instagram'; + +export interface InAppBrowserDetection { + // The detected IAB family, or null when the UA doesn't match. + app: InAppBrowser | null; + // Best-effort guess at the host platform so we can show the right copy + // for the "open in browser" instructions (the menu lives in different + // places on iOS vs Android). + platform: 'ios' | 'android' | 'other'; +} + +/** + * Detect whether the current navigator.userAgent matches a known IAB the + * gallery password form has trouble with. SSR-safe (returns app: null + * when window is undefined). + */ +export function detectInAppBrowser(): InAppBrowserDetection { + if (typeof navigator === 'undefined' || typeof navigator.userAgent !== 'string') { + return { app: null, platform: 'other' }; + } + const ua = navigator.userAgent; + + let platform: InAppBrowserDetection['platform'] = 'other'; + if (/iPhone|iPad|iPod/i.test(ua)) platform = 'ios'; + else if (/Android/i.test(ua)) platform = 'android'; + + // Instagram's IAB tags its UA with `Instagram ` on both iOS and + // Android. Match case-insensitively so versioning quirks (`Instagram` vs + // `instagram`) don't slip past. + if (/\bInstagram\b/i.test(ua)) { + return { app: 'instagram', platform }; + } + + return { app: null, platform }; +}