Add email verification, user Nextcloud settings, Groq TTS, favicon, and settings page

- 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>
This commit is contained in:
ifedan-ed 2026-04-11 04:12:25 +02:00
parent fb8e1340f1
commit ee3ffcfe90
12 changed files with 551 additions and 15 deletions

221
main.py
View file

@ -4,14 +4,25 @@ from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from typing import Optional
import httpx, re, uuid, requests as req_lib
import httpx, re, uuid, requests as req_lib, secrets, smtplib
from datetime import datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from models import get_db, init_db, User, Document, Progress, Setting
from models import get_db, init_db, User, Document, Progress, Setting, VerificationToken, UserSetting
from auth import (
hash_password, verify_password, create_token, get_current_user, require_admin
)
# ── EMAIL CONFIG ─────────────────────────────────────────────────────
SMTP_HOST = "mail.smtp2go.com"
SMTP_PORT = 587
SMTP_USER = "danvics.com"
SMTP_PASS = "YFZ2L8F06YUYBfBR"
SMTP_FROM = "noreply@pedshub.com"
APP_NAME = "Speechify"
APP_URL = "https://read.pedshub.com"
# ── CONFIG ──────────────────────────────────────────────────────────
LITELLM_BASE = "http://localhost:4000"
LITELLM_KEY = "sk-c5c58adcc8dda288067f034ee927be23509142d274fe48418c25009dfb507a52"
@ -144,6 +155,40 @@ TTS_MODELS = {
{"id": "Sulafat", "name": "Sulafat"},
],
},
"groq-playai-tts": {
"name": "Groq PlayAI TTS",
"quality": "Ultra-fast",
"voices": [
{"id": "Fritz-PlayAI", "name": "Fritz"},
{"id": "Celeste-PlayAI", "name": "Celeste"},
{"id": "Ava-PlayAI", "name": "Ava"},
{"id": "Atlas-PlayAI", "name": "Atlas"},
{"id": "Jade-PlayAI", "name": "Jade"},
{"id": "Perseus-PlayAI", "name": "Perseus"},
{"id": "Luna-PlayAI", "name": "Luna"},
{"id": "Caelus-PlayAI", "name": "Caelus"},
{"id": "Orion-PlayAI", "name": "Orion"},
{"id": "Aaliyah-PlayAI", "name": "Aaliyah"},
{"id": "Adelaide-PlayAI", "name": "Adelaide"},
{"id": "Briggs-PlayAI", "name": "Briggs"},
{"id": "Deedee-PlayAI", "name": "Deedee"},
{"id": "Gail-PlayAI", "name": "Gail"},
{"id": "Jennifer-PlayAI", "name": "Jennifer"},
{"id": "Mamaw-PlayAI", "name": "Mamaw"},
{"id": "Mitch-PlayAI", "name": "Mitch"},
{"id": "Nia-PlayAI", "name": "Nia"},
{"id": "Quinn-PlayAI", "name": "Quinn"},
{"id": "Thunder-PlayAI", "name": "Thunder"},
],
},
"groq-playai-tts-arabic": {
"name": "Groq PlayAI Arabic",
"quality": "Ultra-fast · Arabic",
"voices": [
{"id": "Ahmad-PlayAI", "name": "Ahmad"},
{"id": "Amira-PlayAI", "name": "Amira"},
],
},
}
# ── APP ─────────────────────────────────────────────────────────────
@ -169,6 +214,76 @@ def startup():
db.close()
# ── EMAIL ────────────────────────────────────────────────────────────
def send_email(to: str, subject: str, html: str):
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{APP_NAME} <{SMTP_FROM}>"
msg["To"] = to
msg.attach(MIMEText(html, "html"))
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as s:
s.starttls()
s.login(SMTP_USER, SMTP_PASS)
s.sendmail(SMTP_FROM, to, msg.as_string())
except Exception as e:
print(f"Email error: {e}")
def verification_email_html(username: str, link: str) -> str:
return f"""<!DOCTYPE html>
<html><head><meta charset="UTF-8"/></head>
<body style="margin:0;padding:0;background:#0d0d0d;font-family:'Helvetica Neue',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0d0d0d;padding:40px 0;">
<tr><td align="center">
<table width="520" cellpadding="0" cellspacing="0" style="background:#161616;border:1px solid #232323;border-radius:16px;overflow:hidden;">
<!-- Header -->
<tr><td style="background:#7c3aed;padding:28px 40px;text-align:center;">
<div style="display:inline-flex;align-items:center;gap:10px;">
<svg width="32" height="32" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="30" height="30" rx="9" fill="rgba(255,255,255,0.2)"/>
<polygon points="11,8 23,15 11,22" fill="white"/>
</svg>
<span style="font-size:20px;font-weight:700;color:#fff;letter-spacing:-0.3px;">{APP_NAME}</span>
</div>
</td></tr>
<!-- Body -->
<tr><td style="padding:40px;">
<p style="font-size:22px;font-weight:700;color:#e8e8e8;margin:0 0 12px;">Verify your email</p>
<p style="font-size:15px;color:#888;line-height:1.6;margin:0 0 28px;">
Hi <strong style="color:#e8e8e8;">{username}</strong>, thanks for signing up!<br/>
Click the button below to verify your email address and activate your account.
</p>
<table cellpadding="0" cellspacing="0" width="100%"><tr><td align="center">
<a href="{link}" style="display:inline-block;background:#7c3aed;color:#fff;text-decoration:none;
font-size:15px;font-weight:600;padding:14px 36px;border-radius:10px;letter-spacing:0.1px;">
Verify Email Address
</a>
</td></tr></table>
<p style="font-size:13px;color:#555;margin:28px 0 0;line-height:1.6;">
If you didn't create an account, you can safely ignore this email.<br/>
This link will remain valid as long as your account exists.
</p>
</td></tr>
<!-- Footer -->
<tr><td style="border-top:1px solid #232323;padding:20px 40px;text-align:center;">
<p style="font-size:12px;color:#444;margin:0;">{APP_NAME} · <a href="{APP_URL}" style="color:#7c3aed;text-decoration:none;">{APP_URL}</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
def create_verification_token(db: Session, user_id: int) -> str:
# Remove old tokens for this user
db.query(VerificationToken).filter(VerificationToken.user_id == user_id).delete()
token = secrets.token_urlsafe(32)
db.add(VerificationToken(user_id=user_id, token=token))
db.commit()
return token
# ── HELPERS ─────────────────────────────────────────────────────────
def verify_turnstile(token: str, ip: str = "") -> bool:
try:
@ -196,6 +311,28 @@ def set_setting(db: Session, key: str, value: str):
db.commit()
def get_user_setting(db: Session, user_id: int, key: str, default: str = "") -> str:
s = db.query(UserSetting).filter(UserSetting.user_id == user_id, UserSetting.key == key).first()
return s.value if s else default
def set_user_setting(db: Session, user_id: int, key: str, value: str):
s = db.query(UserSetting).filter(UserSetting.user_id == user_id, UserSetting.key == key).first()
if s:
s.value = value
else:
db.add(UserSetting(user_id=user_id, key=key, value=value))
db.commit()
def resolve_nextcloud(db: Session, user: User):
"""Return (url, username, password) using user settings if set, else admin settings."""
url = get_user_setting(db, user.id, "nc_url") or get_setting(db, "nextcloud_url")
uname = get_user_setting(db, user.id, "nc_user") or get_setting(db, "nextcloud_username")
pw = get_user_setting(db, user.id, "nc_pass") or get_setting(db, "nextcloud_password")
return url, uname, pw
# ── PYDANTIC MODELS ──────────────────────────────────────────────────
class LoginRequest(BaseModel):
username: str
@ -230,6 +367,11 @@ class NextcloudConfigRequest(BaseModel):
username: str
password: str
class UserSettingsRequest(BaseModel):
nc_url: Optional[str] = None
nc_user: Optional[str] = None
nc_pass: Optional[str] = None
class NextcloudDownloadRequest(BaseModel):
path: str
@ -258,7 +400,7 @@ async def login(req: LoginRequest, response: Response, request: Request, db: Ses
token = create_token(user.id, user.username, user.role)
response.set_cookie("auth_token", token, httponly=True, samesite="lax", max_age=172800)
return {"username": user.username, "role": user.role}
return {"username": user.username, "role": user.role, "email_verified": user.email_verified}
@app.post("/api/auth/register")
@ -277,14 +419,48 @@ async def register(req: RegisterRequest, response: Response, request: Request, d
email=req.email,
password_hash=hash_password(req.password),
role="user",
email_verified=False,
)
db.add(user)
db.commit()
db.refresh(user)
# Send verification email
vtoken = create_verification_token(db, user.id)
link = f"{APP_URL}/api/auth/verify?token={vtoken}"
html = verification_email_html(user.username, link)
import threading
threading.Thread(target=send_email, args=(user.email, f"Verify your {APP_NAME} account", html), daemon=True).start()
token = create_token(user.id, user.username, user.role)
response.set_cookie("auth_token", token, httponly=True, samesite="lax", max_age=172800)
return {"username": user.username, "role": user.role}
return {"username": user.username, "role": user.role, "email_verified": False}
@app.get("/api/auth/verify")
async def verify_email(token: str, db: Session = Depends(get_db)):
vt = db.query(VerificationToken).filter(VerificationToken.token == token).first()
if not vt:
return FileResponse("static/verify-fail.html")
user = db.query(User).filter(User.id == vt.user_id).first()
if user:
user.email_verified = True
db.commit()
db.delete(vt)
db.commit()
return FileResponse("static/verify-ok.html")
@app.post("/api/auth/resend-verification")
async def resend_verification(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
if user.email_verified:
return {"ok": True, "message": "Already verified"}
vtoken = create_verification_token(db, user.id)
link = f"{APP_URL}/api/auth/verify?token={vtoken}"
html = verification_email_html(user.username, link)
import threading
threading.Thread(target=send_email, args=(user.email, f"Verify your {APP_NAME} account", html), daemon=True).start()
return {"ok": True}
@app.post("/api/auth/logout")
@ -295,7 +471,10 @@ async def logout(response: Response):
@app.get("/api/auth/me")
async def me(user: User = Depends(get_current_user)):
return {"id": user.id, "username": user.username, "email": user.email, "role": user.role}
return {
"id": user.id, "username": user.username, "email": user.email,
"role": user.role, "email_verified": user.email_verified,
}
# ── ADMIN ROUTES ─────────────────────────────────────────────────────
@ -422,12 +601,29 @@ async def save_progress(doc_id: str, req: ProgressRequest,
return {"ok": True}
# ── USER SETTINGS ROUTES ─────────────────────────────────────────────
@app.get("/api/user/settings")
async def get_user_settings(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
return {
"nc_url": get_user_setting(db, user.id, "nc_url"),
"nc_user": get_user_setting(db, user.id, "nc_user"),
# Don't return password
}
@app.post("/api/user/settings")
async def save_user_settings(req: UserSettingsRequest,
user: User = Depends(get_current_user), db: Session = Depends(get_db)):
if req.nc_url is not None: set_user_setting(db, user.id, "nc_url", req.nc_url)
if req.nc_user is not None: set_user_setting(db, user.id, "nc_user", req.nc_user)
if req.nc_pass is not None: set_user_setting(db, user.id, "nc_pass", req.nc_pass)
return {"ok": True}
# ── NEXTCLOUD ROUTES ─────────────────────────────────────────────────
@app.get("/api/nextcloud/files")
async def list_nextcloud_files(path: str = "/", user: User = Depends(get_current_user), db: Session = Depends(get_db)):
nc_url = get_setting(db, "nextcloud_url")
nc_user = get_setting(db, "nextcloud_username")
nc_pass = get_setting(db, "nextcloud_password")
nc_url, nc_user, nc_pass = resolve_nextcloud(db, user)
if not nc_url:
raise HTTPException(status_code=400, detail="Nextcloud not configured")
@ -480,9 +676,7 @@ async def list_nextcloud_files(path: str = "/", user: User = Depends(get_current
@app.post("/api/nextcloud/download")
async def download_nextcloud_file(req: NextcloudDownloadRequest,
user: User = Depends(get_current_user), db: Session = Depends(get_db)):
nc_url = get_setting(db, "nextcloud_url")
nc_user = get_setting(db, "nextcloud_username")
nc_pass = get_setting(db, "nextcloud_password")
nc_url, nc_user, nc_pass = resolve_nextcloud(db, user)
if not nc_url:
raise HTTPException(status_code=400, detail="Nextcloud not configured")
@ -582,13 +776,14 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
async def login_page():
return FileResponse("static/login.html")
@app.get("/settings")
async def settings_page():
return FileResponse("static/settings.html")
@app.get("/")
async def root(request: Request):
# Let the frontend handle auth redirect via /api/auth/me
return FileResponse("static/index.html")
@app.get("/admin")
async def admin_page():
return FileResponse("static/admin.html")

View file

@ -18,8 +18,17 @@ class User(Base):
username = Column(String(50), unique=True, nullable=False, index=True)
email = Column(String(100), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
role = Column(String(10), default="user") # "admin" or "user"
role = Column(String(10), default="user")
is_active = Column(Boolean, default=True)
email_verified = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
class VerificationToken(Base):
__tablename__ = "verification_tokens"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
token = Column(String(64), unique=True, nullable=False, index=True)
created_at = Column(DateTime, default=datetime.utcnow)
@ -29,7 +38,7 @@ class Document(Base):
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
title = Column(String(500))
content = Column(Text)
source_type = Column(String(20)) # text, pdf, url, nextcloud
source_type = Column(String(20))
created_at = Column(DateTime, default=datetime.utcnow)
@ -44,11 +53,22 @@ class Progress(Base):
class Setting(Base):
"""Global admin settings (key/value)"""
__tablename__ = "settings"
key = Column(String(100), primary_key=True)
value = Column(Text)
class UserSetting(Base):
"""Per-user settings (key/value)"""
__tablename__ = "user_settings"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
key = Column(String(100), nullable=False)
value = Column(Text)
__table_args__ = (UniqueConstraint("user_id", "key", name="uq_user_setting"),)
def get_db():
db = SessionLocal()
try:

BIN
speechify.db Normal file

Binary file not shown.

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Speechify — Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

View file

@ -55,6 +55,11 @@ async function initAuth() {
const ddAdmin = $('dd-admin');
if (currentUser.role !== 'admin') ddAdmin.classList.add('hidden');
// Email verification reminder
if (!currentUser.email_verified) {
$('verify-banner').classList.add('show');
}
// User menu dropdown toggle
$('user-btn').addEventListener('click', e => {
e.stopPropagation();
@ -70,6 +75,19 @@ function logout() {
}
window.logout = logout;
async function resendVerification() {
const btn = $('verify-banner').querySelector('button');
btn.textContent = 'Sending…';
btn.disabled = true;
try {
await fetch('/api/auth/resend-verification', { method: 'POST' });
$('verify-banner').querySelector('span').textContent = '✓ Verification email sent — check your inbox.';
} finally {
btn.remove();
}
}
window.resendVerification = resendVerification;
// ── VOICES INIT ────────────────────────────────────────────────────
async function initVoices() {
const resp = await fetch('/api/voices');

4
static/favicon.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
<rect width="30" height="30" rx="9" fill="#7c3aed"/>
<polygon points="11,8 23,15 11,22" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

View file

@ -6,6 +6,7 @@
<title>Speechify</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"/>
<link rel="stylesheet" href="/static/style.css"/>
</head>
<body>
@ -36,12 +37,20 @@
</button>
<div class="user-dropdown" id="user-dropdown">
<a href="/admin" class="dd-item" id="dd-admin">Admin Panel</a>
<a href="/settings" class="dd-item">Settings</a>
<button class="dd-item" onclick="logout()">Sign Out</button>
</div>
</div>
</div>
</header>
<!-- Email verification banner -->
<div class="verify-banner" id="verify-banner">
<span>⚠️ Please verify your email address. Check your inbox for a verification link.</span>
<button onclick="resendVerification()">Resend</button>
<button class="close-btn" onclick="this.parentElement.classList.remove('show')"></button>
</div>
<div class="main-layout">
<!-- Left Sidebar -->

View file

@ -6,6 +6,7 @@
<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; }

204
static/settings.html Normal file
View file

@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Settings — Speechify</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
<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"/>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg:#0d0d0d; --bg2:#161616; --bg3:#212121; --bg4:#2a2a2a;
--accent:#7c3aed; --accent-h:#9d6fff;
--text:#e8e8e8; --text-m:#888; --border:#232323; --success:#4ade80;
}
body { font-family:'Inter',sans-serif; background:var(--bg); color:var(--text); min-height:100vh; }
header {
height:54px; background:var(--bg2); border-bottom:1px solid var(--border);
display:flex; align-items:center; justify-content:space-between; padding:0 24px;
}
.logo { display:flex; align-items:center; gap:10px; font-size:17px; font-weight:700; text-decoration:none; color:var(--text); }
.header-right { display:flex; align-items:center; gap:12px; }
.back-btn, .logout-btn {
background:var(--bg3); border:1px solid var(--border); color:var(--text-m);
border-radius:7px; padding:6px 14px; font-size:13px; font-family:inherit;
cursor:pointer; text-decoration:none; display:inline-block; transition:all .15s;
}
.back-btn:hover, .logout-btn:hover { border-color:var(--accent); color:var(--text); }
.container { max-width:640px; margin:0 auto; padding:40px 24px; }
h2 { font-size:20px; font-weight:700; margin-bottom:6px; }
.subtitle { font-size:13px; color:var(--text-m); margin-bottom:32px; }
h3 { font-size:11px; font-weight:600; color:var(--text-m); text-transform:uppercase;
letter-spacing:.5px; margin-bottom:16px; }
.card { background:var(--bg2); border:1px solid var(--border); border-radius:12px;
padding:24px; margin-bottom:20px; }
.form-row { display:flex; flex-direction:column; gap:6px; margin-bottom:16px; }
.form-row:last-child { margin-bottom:0; }
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:9px 12px; outline:none; width:100%;
}
input:focus { border-color:var(--accent); }
.btn-primary {
background:var(--accent); color:#fff; border:none; border-radius:8px;
padding:10px 20px; font-family:inherit; font-size:14px; font-weight:600; cursor:pointer;
}
.btn-primary:hover { background:var(--accent-h); }
.btn-secondary {
background:var(--bg3); color:var(--text-m); border:1px solid var(--border); border-radius:8px;
padding:10px 20px; font-family:inherit; font-size:14px; font-weight:500; cursor:pointer;
}
.btn-secondary:hover { border-color:var(--accent); color:var(--text); }
.msg { font-size:13px; margin-top:12px; min-height:18px; }
.msg.ok { color:var(--success); }
.msg.err { color:#f87171; }
.verify-banner {
display:none; background:rgba(245,158,11,.1); border:1px solid rgba(245,158,11,.25);
border-radius:10px; padding:14px 18px; margin-bottom:24px;
font-size:13px; color:#fbbf24; line-height:1.6;
}
.verify-banner.show { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.verify-banner button {
background:rgba(245,158,11,.2); border:1px solid rgba(245,158,11,.3); color:#fbbf24;
border-radius:6px; padding:5px 12px; font-size:12px; font-family:inherit; cursor:pointer; white-space:nowrap;
}
.verified-badge {
display:inline-flex; align-items:center; gap:6px; background:rgba(74,222,128,.1);
border:1px solid rgba(74,222,128,.2); color:#4ade80; border-radius:6px;
padding:4px 10px; font-size:12px; font-weight:600; margin-top:8px;
}
</style>
</head>
<body>
<header>
<a href="/" class="logo">
<svg width="28" height="28" 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
</a>
<div class="header-right">
<a href="/" class="back-btn">← Back to App</a>
<button class="logout-btn" onclick="logout()">Sign Out</button>
</div>
</header>
<div class="container">
<h2>Account Settings</h2>
<p class="subtitle" id="subtitle">Manage your personal preferences.</p>
<!-- Email verification banner -->
<div class="verify-banner" id="verify-banner">
<span>⚠️ Your email address is not verified. Check your inbox or resend the link.</span>
<button onclick="resendVerification()">Resend Email</button>
</div>
<!-- Email / account info -->
<div class="card">
<h3>Account</h3>
<div class="form-row">
<label>Username</label>
<input id="acc-username" type="text" readonly style="opacity:.5;cursor:default"/>
</div>
<div class="form-row">
<label>Email</label>
<input id="acc-email" type="email" readonly style="opacity:.5;cursor:default"/>
<div id="email-status"></div>
</div>
<div class="msg" id="acc-msg"></div>
</div>
<!-- Nextcloud -->
<div class="card">
<h3>Nextcloud Integration</h3>
<p style="font-size:13px;color:var(--text-m);margin-bottom:16px;line-height:1.6;">
Connect your personal Nextcloud to browse and read PDFs from the Cloud tab.
Leave blank to use the server-wide default configured by the admin.
</p>
<div class="form-row">
<label>Nextcloud URL</label>
<input id="nc-url" type="url" placeholder="https://cloud.example.com"/>
</div>
<div class="form-row">
<label>Username</label>
<input id="nc-user" type="text"/>
</div>
<div class="form-row">
<label>App Password</label>
<input id="nc-pass" type="password" placeholder="Leave blank to keep existing"/>
</div>
<button class="btn-primary" onclick="saveNextcloud()">Save</button>
<div class="msg" id="nc-msg"></div>
</div>
</div>
<script>
let me = null;
async function init() {
const resp = await fetch('/api/auth/me');
if (resp.status === 401) { window.location.href = '/login'; return; }
me = await resp.json();
document.getElementById('acc-username').value = me.username;
document.getElementById('acc-email').value = me.email;
document.getElementById('subtitle').textContent = `Signed in as ${me.username}`;
if (!me.email_verified) {
document.getElementById('verify-banner').classList.add('show');
document.getElementById('email-status').innerHTML =
'<span style="font-size:12px;color:#f87171;">Not verified</span>';
} else {
document.getElementById('email-status').innerHTML =
'<div class="verified-badge"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg> Verified</div>';
}
// Load Nextcloud settings
const s = await fetch('/api/user/settings').then(r => r.json());
document.getElementById('nc-url').value = s.nc_url || '';
document.getElementById('nc-user').value = s.nc_user || '';
}
async function resendVerification() {
const resp = await fetch('/api/auth/resend-verification', { method: 'POST' });
if (resp.ok) {
document.getElementById('verify-banner').innerHTML =
'<span>✓ Verification email sent — check your inbox.</span>';
}
}
async function saveNextcloud() {
const msg = document.getElementById('nc-msg');
const pass = document.getElementById('nc-pass').value;
const body = {
nc_url: document.getElementById('nc-url').value,
nc_user: document.getElementById('nc-user').value,
};
if (pass) body.nc_pass = pass;
const resp = await fetch('/api/user/settings', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(body),
});
if (resp.ok) {
msg.textContent = '✓ Saved'; msg.className = 'msg ok';
document.getElementById('nc-pass').value = '';
} else {
msg.textContent = '✗ Error saving'; msg.className = 'msg err';
}
setTimeout(() => msg.textContent = '', 3000);
}
function logout() {
fetch('/api/auth/logout', { method: 'POST' }).finally(() => window.location.href = '/login');
}
init();
</script>
</body>
</html>

View file

@ -66,6 +66,20 @@ body {
.dd-item:hover { background: var(--bg3); color: var(--text); }
.dd-item.hidden { display: none; }
/* ── VERIFICATION BANNER ─────────────────── */
.verify-banner {
display: none; align-items: center; gap: 12px; flex-shrink: 0;
background: rgba(245,158,11,.1); border-bottom: 1px solid rgba(245,158,11,.2);
padding: 10px 20px; font-size: 13px; color: #fbbf24;
}
.verify-banner.show { display: flex; }
.verify-banner button {
background: rgba(245,158,11,.2); border: 1px solid rgba(245,158,11,.3); color: #fbbf24;
border-radius: 6px; padding: 4px 10px; font-size: 12px; font-family: inherit; cursor: pointer; white-space: nowrap;
}
.verify-banner .close-btn { margin-left: auto; background: none; border: none; opacity: .6; }
.verify-banner .close-btn:hover { opacity: 1; }
/* ── MAIN LAYOUT ──────────────────────────── */
.app { display: flex; flex-direction: column; height: 100vh; }
.main-layout {

35
static/verify-fail.html Normal file
View file

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Invalid Link — Speechify</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', system-ui, sans-serif; background: #0d0d0d; color: #e8e8e8;
min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.card { background: #161616; border: 1px solid #232323; border-radius: 16px;
padding: 48px 40px; max-width: 400px; width: 100%; text-align: center; }
.icon { width: 56px; height: 56px; background: rgba(239,68,68,.15); border-radius: 50%;
display: flex; align-items: center; justify-content: center; margin: 0 auto 24px; }
h1 { font-size: 20px; font-weight: 700; margin-bottom: 10px; }
p { font-size: 14px; color: #888; line-height: 1.6; margin-bottom: 28px; }
a { display: inline-block; background: #7c3aed; color: #fff; text-decoration: none;
border-radius: 10px; padding: 11px 28px; font-size: 14px; font-weight: 600; }
a:hover { background: #9d6fff; }
</style>
</head>
<body>
<div class="card">
<div class="icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#f87171" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</div>
<h1>Invalid link</h1>
<p>This verification link is invalid or has already been used. Sign in and request a new one from your settings.</p>
<a href="/login">Sign In</a>
</div>
</body>
</html>

35
static/verify-ok.html Normal file
View file

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Email Verified — Speechify</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', system-ui, sans-serif; background: #0d0d0d; color: #e8e8e8;
min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.card { background: #161616; border: 1px solid #232323; border-radius: 16px;
padding: 48px 40px; max-width: 400px; width: 100%; text-align: center; }
.icon { width: 56px; height: 56px; background: rgba(74,222,128,.15); border-radius: 50%;
display: flex; align-items: center; justify-content: center; margin: 0 auto 24px; }
h1 { font-size: 20px; font-weight: 700; margin-bottom: 10px; }
p { font-size: 14px; color: #888; line-height: 1.6; margin-bottom: 28px; }
a { display: inline-block; background: #7c3aed; color: #fff; text-decoration: none;
border-radius: 10px; padding: 11px 28px; font-size: 14px; font-weight: 600; }
a:hover { background: #9d6fff; }
</style>
</head>
<body>
<div class="card">
<div class="icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#4ade80" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
</div>
<h1>Email verified!</h1>
<p>Your email address has been confirmed. You can now use all features of Speechify.</p>
<a href="/">Go to App</a>
</div>
</body>
</html>