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:
@@ -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(); });
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user