Files
recap/public/auth.html
T
Keysat 91af0b711e Harden iOS sign-in against stale-connection POST failures
iPad users hit a spurious "network error" on the first tap of
"Send sign-in link", with a second tap succeeding. Cause is iOS
Safari dispatching the POST onto a pooled keep-alive socket the
server/proxy already closed; unlike a GET it isn't transparently
re-sent, so it surfaces as a transport TypeError. The single 500ms
auto-retry was too quick and reused the same dead socket.

Both sign-in entry points (auth.html postWithRetry, index.html
fetchWithRetry) now retry 3x with growing backoff (0 -> +400ms ->
+1.6s) to outlast Safari evicting the socket. Frontend-only.

Ships as 0.2.156.
2026-06-15 16:35:13 -05:00

394 lines
14 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Sign in to Recaps</title>
<!-- Match index.html's PWA setup so a user installed via the
Add-to-Home-Screen flow can land on /auth.html (signed-out)
without losing the standalone display + theme colors. -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0a0e1a">
<link rel="apple-touch-icon" href="/assets/icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Recaps">
<meta name="mobile-web-app-capable" content="yes">
<!-- Social preview tags — same as index.html so any shared
/auth.html link previews cleanly too. -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="Recaps">
<meta property="og:title" content="Sign in to Recaps">
<meta property="og:description" content="Summarize any YouTube video or podcast episode into topic-level summaries with timestamps.">
<meta property="og:url" content="https://recaps.cc/auth.html">
<meta property="og:image" content="https://recaps.cc/assets/icon.png">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Sign in to Recaps">
<meta name="twitter:image" content="https://recaps.cc/assets/icon.png">
<link rel="icon" type="image/png" href="/assets/icon.png">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0a0e1a;
color: #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.card {
width: 100%;
max-width: 420px;
background: #121828;
border: 1px solid #1f2942;
border-radius: 12px;
padding: 32px 28px;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 28px;
}
.logo img { width: 32px; height: 32px; border-radius: 6px; }
.logo span { font-size: 18px; font-weight: 600; color: #f5f9ff; }
h1 {
font-size: 22px;
font-weight: 600;
color: #f5f9ff;
margin-bottom: 8px;
}
p.lede {
font-size: 14px;
line-height: 1.55;
color: #94a3b8;
margin-bottom: 24px;
}
label {
display: block;
font-size: 13px;
font-weight: 500;
color: #cbd5e1;
margin-bottom: 8px;
}
input[type=email],
input[type=password] {
width: 100%;
background: #0a0e1a;
border: 1px solid #1f2942;
border-radius: 8px;
padding: 12px 14px;
font-size: 15px;
color: #f5f9ff;
font-family: inherit;
outline: none;
transition: border-color 0.15s ease;
/* Browsers auto-fill password fields with their own bright
background; -webkit-text-fill-color + a long inset shadow
override that so the field stays on-brand. */
-webkit-text-fill-color: #f5f9ff;
-webkit-box-shadow: 0 0 0 1000px #0a0e1a inset;
caret-color: #f5f9ff;
}
input[type=email]:focus,
input[type=password]:focus { border-color: #3b82f6; }
input[type=email]::placeholder,
input[type=password]::placeholder { color: #475569; }
button {
width: 100%;
margin-top: 16px;
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
padding: 12px 14px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
font-family: inherit;
}
button:hover:not(:disabled) { background: #2563eb; }
button:disabled { background: #1e3a8a; cursor: not-allowed; opacity: 0.6; }
.feedback {
margin-top: 20px;
padding: 14px 16px;
border-radius: 8px;
font-size: 14px;
line-height: 1.5;
display: none;
}
.feedback.success {
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
color: #6ee7b7;
display: block;
}
.feedback.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #fca5a5;
display: block;
}
.footer {
margin-top: 28px;
font-size: 12px;
color: #64748b;
text-align: center;
line-height: 1.5;
}
.footer a { color: #94a3b8; text-decoration: none; }
.footer a:hover { color: #cbd5e1; }
/* Password group hidden by default — most users want the magic
link and the optional-password field cluttered the form. The
"Use password instead" link below the submit button reveals
this when needed. */
.password-group[hidden] { display: none; }
.toggle-pwd-row {
text-align: center;
margin-top: 14px;
font-size: 12px;
color: #64748b;
}
.toggle-pwd-row a {
color: #94a3b8;
text-decoration: underline;
cursor: pointer;
}
.toggle-pwd-row a:hover { color: #cbd5e1; }
</style>
</head>
<body>
<div class="card">
<div class="logo">
<img src="/assets/icon.png" alt="Recaps" onerror="this.style.display='none'">
<span>Recaps</span>
</div>
<h1 id="auth-heading">Sign in</h1>
<p class="lede" id="auth-lede">
Enter your email — we'll send a sign-in link.
</p>
<form id="signin-form" autocomplete="on">
<label for="email">Email</label>
<input
type="email"
id="email"
name="email"
required
autocomplete="email"
/>
<div class="password-group" id="password-group" hidden>
<label for="password" style="margin-top:12px;">Password</label>
<input
type="password"
id="password"
name="password"
autocomplete="current-password"
minlength="8"
/>
</div>
<button type="submit" id="submit-btn">Send sign-in link</button>
</form>
<div class="toggle-pwd-row" id="toggle-pwd-row">
<a id="toggle-pwd" role="button" tabindex="0">Use password instead</a>
</div>
<div class="feedback" id="feedback"></div>
<div class="footer">
First time here? We'll create your account when you click the sign-in link.
</div>
</div>
<script>
const form = document.getElementById('signin-form');
const btn = document.getElementById('submit-btn');
const feedback = document.getElementById('feedback');
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
const passwordGroup = document.getElementById('password-group');
const togglePwd = document.getElementById('toggle-pwd');
const togglePwdRow = document.getElementById('toggle-pwd-row');
const heading = document.getElementById('auth-heading');
const lede = document.getElementById('auth-lede');
// Reveal the password field on demand. Most users sign in via
// magic link so the password field is clutter by default; this
// toggle surfaces it when needed. Once revealed it stays open
// for the rest of the session — no "hide again" affordance
// because re-hiding after typing would lose state and confuse.
function revealPasswordField() {
passwordGroup.hidden = false;
togglePwdRow.style.display = 'none';
// Defer focus to give the browser a tick to lay out the field.
setTimeout(() => passwordInput.focus(), 0);
updateBtnLabel();
}
togglePwd.addEventListener('click', (e) => {
e.preventDefault();
revealPasswordField();
});
togglePwd.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
revealPasswordField();
}
});
// ?intent=signup vs ?intent=signin — same form, slightly different
// copy. Signup leans "we'll create your account when you click
// the link"; signin leans "welcome back". If neither param is
// present we default to the generic Sign in copy (current
// behavior).
(function tailorIntent() {
try {
const intent = new URLSearchParams(location.search).get('intent');
if (intent === 'signup') {
document.title = 'Create your Recaps account';
heading.textContent = 'Create your account';
lede.textContent = "Enter your email — we'll send a sign-in link and create your account when you click it. No password to set up.";
} else if (intent === 'signin') {
document.title = 'Sign in to Recaps';
heading.textContent = 'Sign in';
// lede stays as the default
}
} catch {}
})();
function setFeedback(msg, kind) {
feedback.textContent = msg;
feedback.className = 'feedback ' + kind;
}
function clearFeedback() {
feedback.textContent = '';
feedback.className = 'feedback';
}
// Submit button label tracks whether the password field is in
// play. Hidden + empty = "Send sign-in link" (the dominant case);
// revealed + filled = "Sign in"; revealed + empty falls back to
// "Send sign-in link" so the user can change their mind without
// re-toggling the field.
function updateBtnLabel() {
if (btn.disabled) return;
const usingPwd = !passwordGroup.hidden && passwordInput.value.length > 0;
btn.textContent = usingPwd ? 'Sign in' : 'Send sign-in link';
}
passwordInput.addEventListener('input', updateBtnLabel);
updateBtnLabel();
async function signInWithPassword(email, password) {
const res = await fetch('/auth/signin-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (res.ok) {
// Cookie set server-side. Land on the main app.
window.location.href = '/';
return;
}
if (res.status === 429) {
const body = await res.json().catch(() => ({}));
setFeedback(body.message || 'Too many attempts. Try again later.', 'error');
} else if (res.status === 401) {
setFeedback('Email or password is wrong. Leave the password blank to receive a sign-in link instead.', 'error');
} else {
setFeedback('Something went wrong. Please try again.', 'error');
}
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
clearFeedback();
const email = emailInput.value.trim();
// Only honor the password value if the user explicitly opened
// the password section. Otherwise a browser autofill into the
// hidden field would force the form into password-sign-in mode
// against the user's intent.
const password = passwordGroup.hidden ? '' : passwordInput.value;
if (!email) return;
btn.disabled = true;
const usingPassword = password.length > 0;
btn.textContent = usingPassword ? 'Signing in...' : 'Sending...';
if (usingPassword) {
try {
await signInWithPassword(email, password);
} catch (err) {
setFeedback('Network error. Check your connection and try again.', 'error');
} finally {
btn.disabled = false;
updateBtnLabel();
}
return;
}
// Silent retry on transport failures — iOS Safari can dispatch a
// POST onto a pooled keep-alive socket the server (or a proxy in
// front of it) has already closed. Unlike a GET, a non-idempotent
// POST isn't transparently re-sent on a fresh connection; it
// surfaces a "Load failed" TypeError instead. A single quick retry
// tends to reuse the same dead socket and fail again (the reported
// "first tap errors, second works"), so retry a few times with
// growing backoff to outlast Safari evicting the socket. Server
// errors (4xx/5xx) are returned as-is and never retried — they're
// deliberate responses, not transport flakes.
const reqInit = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
};
async function postWithRetry() {
const backoffsMs = [400, 1200];
for (let attempt = 0; ; attempt++) {
try {
return await fetch('/auth/request-link', reqInit);
} catch (e) {
if (attempt >= backoffsMs.length) throw e;
await new Promise((r) => setTimeout(r, backoffsMs[attempt]));
}
}
}
try {
const res = await postWithRetry();
if (res.status === 429) {
const body = await res.json().catch(() => ({}));
setFeedback(body.message || 'Too many requests. Try again later.', 'error');
} else if (res.status === 503) {
const body = await res.json().catch(() => ({}));
setFeedback(
body.message || 'Sign-in is temporarily unavailable.',
'error',
);
} else if (!res.ok) {
setFeedback('Something went wrong. Please try again.', 'error');
} else {
setFeedback(
'Check your email — we sent a sign-in link to ' + email + '. It expires in 15 minutes.',
'success',
);
form.reset();
}
} catch (err) {
setFeedback('Network error. Check your connection and try again.', 'error');
} finally {
btn.disabled = false;
updateBtnLabel();
}
});
</script>
</body>
</html>