- Email verification: send link on register via SMTP2GO (noreply@pedshub.com), verify endpoint, beautiful HTML email - Unverified users see yellow banner in app with resend button; also shown on settings page - User settings page (/settings): personal Nextcloud credentials (override admin-wide config) - Admin and per-user Nextcloud config merged (user settings take priority) - Groq PlayAI TTS: 20 English voices + 2 Arabic voices via groq-playai-tts / groq-playai-tts-arabic - Added groq-playai-tts and groq-playai-tts-arabic to litellm config - Favicon SVG (purple play button) across all pages - Settings link in user dropdown menu - verify-ok.html and verify-fail.html result pages - DB migration: added email_verified column, verification_tokens and user_settings tables Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
148 lines
6.7 KiB
HTML
148 lines
6.7 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>Speechify — Sign In</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
|
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
:root {
|
|
--bg: #0d0d0d; --bg2: #161616; --bg3: #212121;
|
|
--accent: #7c3aed; --accent-h: #9d6fff;
|
|
--text: #e8e8e8; --text-m: #888; --border: #232323;
|
|
}
|
|
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text);
|
|
min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
.card {
|
|
background: var(--bg2); border: 1px solid var(--border); border-radius: 16px;
|
|
padding: 40px 36px; width: 100%; max-width: 400px;
|
|
}
|
|
.logo { display: flex; align-items: center; gap: 10px; font-size: 20px; font-weight: 700; margin-bottom: 32px; }
|
|
.logo svg { flex-shrink: 0; }
|
|
.tabs { display: flex; gap: 4px; margin-bottom: 24px; background: var(--bg3); border-radius: 10px; padding: 4px; }
|
|
.tab {
|
|
flex: 1; padding: 8px; background: none; border: none; border-radius: 7px;
|
|
color: var(--text-m); font-family: inherit; font-size: 14px; font-weight: 500; cursor: pointer;
|
|
}
|
|
.tab.active { background: var(--accent); color: #fff; }
|
|
.form { display: flex; flex-direction: column; gap: 14px; }
|
|
.form.hidden { display: none; }
|
|
label { font-size: 12px; font-weight: 600; color: var(--text-m); text-transform: uppercase; letter-spacing: .5px; }
|
|
input {
|
|
background: var(--bg3); border: 1px solid var(--border); border-radius: 8px;
|
|
color: var(--text); font-family: inherit; font-size: 14px; padding: 10px 12px;
|
|
outline: none; width: 100%; transition: border-color .15s; margin-top: 6px;
|
|
}
|
|
input:focus { border-color: var(--accent); }
|
|
.field { display: flex; flex-direction: column; }
|
|
.turnstile-wrap { display: flex; justify-content: center; }
|
|
.btn {
|
|
background: var(--accent); color: #fff; border: none; border-radius: 8px;
|
|
padding: 11px; font-family: inherit; font-size: 15px; font-weight: 600;
|
|
cursor: pointer; transition: background .15s; width: 100%;
|
|
}
|
|
.btn:hover { background: var(--accent-h); }
|
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
|
.error { color: #f87171; font-size: 13px; text-align: center; min-height: 18px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<div class="logo">
|
|
<svg width="30" height="30" viewBox="0 0 30 30" fill="none">
|
|
<rect width="30" height="30" rx="9" fill="#7c3aed"/>
|
|
<polygon points="11,8 23,15 11,22" fill="white"/>
|
|
</svg>
|
|
Speechify
|
|
</div>
|
|
|
|
<div class="tabs">
|
|
<button class="tab active" id="tab-login" onclick="switchTab('login')">Sign In</button>
|
|
<button class="tab" id="tab-register" onclick="switchTab('register')">Register</button>
|
|
</div>
|
|
|
|
<!-- Login Form -->
|
|
<form class="form" id="form-login" onsubmit="doLogin(event)">
|
|
<div class="field"><label>Username</label><input id="l-user" type="text" required autocomplete="username"/></div>
|
|
<div class="field"><label>Password</label><input id="l-pass" type="password" required autocomplete="current-password"/></div>
|
|
<div class="turnstile-wrap">
|
|
<div class="cf-turnstile" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="dark" id="ts-login"></div>
|
|
</div>
|
|
<div class="error" id="err-login"></div>
|
|
<button class="btn" type="submit" id="btn-login">Sign In</button>
|
|
</form>
|
|
|
|
<!-- Register Form -->
|
|
<form class="form hidden" id="form-register" onsubmit="doRegister(event)">
|
|
<div class="field"><label>Username</label><input id="r-user" type="text" required autocomplete="username"/></div>
|
|
<div class="field"><label>Email</label><input id="r-email" type="email" required autocomplete="email"/></div>
|
|
<div class="field"><label>Password</label><input id="r-pass" type="password" required autocomplete="new-password"/></div>
|
|
<div class="turnstile-wrap">
|
|
<div class="cf-turnstile" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="dark" id="ts-register"></div>
|
|
</div>
|
|
<div class="error" id="err-register"></div>
|
|
<button class="btn" type="submit" id="btn-register">Create Account</button>
|
|
</form>
|
|
</div>
|
|
|
|
<script>
|
|
function switchTab(tab) {
|
|
document.getElementById('tab-login').classList.toggle('active', tab === 'login');
|
|
document.getElementById('tab-register').classList.toggle('active', tab === 'register');
|
|
document.getElementById('form-login').classList.toggle('hidden', tab !== 'login');
|
|
document.getElementById('form-register').classList.toggle('hidden', tab !== 'register');
|
|
}
|
|
|
|
function getTurnstileToken(widgetId) {
|
|
return document.querySelector(`#${widgetId} [name="cf-turnstile-response"]`)?.value || '';
|
|
}
|
|
|
|
async function doLogin(e) {
|
|
e.preventDefault();
|
|
const btn = document.getElementById('btn-login');
|
|
const err = document.getElementById('err-login');
|
|
btn.disabled = true; err.textContent = '';
|
|
try {
|
|
const resp = await fetch('/api/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
username: document.getElementById('l-user').value,
|
|
password: document.getElementById('l-pass').value,
|
|
turnstile_token: getTurnstileToken('ts-login'),
|
|
}),
|
|
});
|
|
if (!resp.ok) { err.textContent = (await resp.json()).detail; }
|
|
else { window.location.href = '/'; }
|
|
} catch { err.textContent = 'Network error'; }
|
|
finally { btn.disabled = false; }
|
|
}
|
|
|
|
async function doRegister(e) {
|
|
e.preventDefault();
|
|
const btn = document.getElementById('btn-register');
|
|
const err = document.getElementById('err-register');
|
|
btn.disabled = true; err.textContent = '';
|
|
try {
|
|
const resp = await fetch('/api/auth/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
username: document.getElementById('r-user').value,
|
|
email: document.getElementById('r-email').value,
|
|
password: document.getElementById('r-pass').value,
|
|
turnstile_token: getTurnstileToken('ts-register'),
|
|
}),
|
|
});
|
|
if (!resp.ok) { err.textContent = (await resp.json()).detail; }
|
|
else { window.location.href = '/'; }
|
|
} catch { err.textContent = 'Network error'; }
|
|
finally { btn.disabled = false; }
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|