91af0b711e
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.
394 lines
14 KiB
HTML
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>
|