Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling
Introduces RECAP_MODE=multi alongside single-mode self-host: - Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool, anonymous trial minting with per-IP/-64 caps - Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite), prepaid 30-day periods, expiry-reminder emails - Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id - SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single - StartOS actions/versions through 0.2.155
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
<!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 the first fetch — iOS Safari sometimes
|
||||
// aborts the very first request from a cold tab with a generic
|
||||
// "Load failed" TypeError. A single ~500ms retry hides the
|
||||
// flake; server-side errors (4xx/5xx) are not retried because
|
||||
// they're deliberate responses, not transport issues.
|
||||
const reqInit = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
};
|
||||
async function postWithRetry() {
|
||||
try {
|
||||
return await fetch('/auth/request-link', reqInit);
|
||||
} catch (e) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
return await fetch('/auth/request-link', reqInit);
|
||||
}
|
||||
}
|
||||
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>
|
||||
Reference in New Issue
Block a user