Merge pull request #656 from the-luap/fix/gallery-password-instagram-iab-654

fix(gallery): unbreak password entry in Instagram in-app browser (#654)
This commit is contained in:
Paul Nothaft
2026-06-22 21:38:20 +02:00
committed by GitHub
7 changed files with 202 additions and 5 deletions
@@ -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(); });
@@ -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(); });
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+50 -1
View File
@@ -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<string | null>(null);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(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<string | null>(() => {
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 = () => {
<CardContent className="p-4 sm:p-6">
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
{/* 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' && (
<div
role="alert"
className="mb-4 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/40 p-3 text-sm text-amber-900 dark:text-amber-100"
>
<p className="font-medium">
{t('auth.iab.instagram.title', 'Open this link in your browser')}
</p>
<p className="mt-1 text-xs">
{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).",
)}
</p>
</div>
)}
<form onSubmit={handleLogin} className="space-y-4">
<Input
type="password"
@@ -394,6 +432,17 @@ export const GalleryPage: React.FC = () => {
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"
/>
@@ -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' });
});
});
+51
View File
@@ -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 <version>` 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 };
}