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 pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import Optional 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 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 ( from auth import (
hash_password, verify_password, create_token, get_current_user, require_admin 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 ────────────────────────────────────────────────────────── # ── CONFIG ──────────────────────────────────────────────────────────
LITELLM_BASE = "http://localhost:4000" LITELLM_BASE = "http://localhost:4000"
LITELLM_KEY = "sk-c5c58adcc8dda288067f034ee927be23509142d274fe48418c25009dfb507a52" LITELLM_KEY = "sk-c5c58adcc8dda288067f034ee927be23509142d274fe48418c25009dfb507a52"
@ -144,6 +155,40 @@ TTS_MODELS = {
{"id": "Sulafat", "name": "Sulafat"}, {"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 ───────────────────────────────────────────────────────────── # ── APP ─────────────────────────────────────────────────────────────
@ -169,6 +214,76 @@ def startup():
db.close() 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 ───────────────────────────────────────────────────────── # ── HELPERS ─────────────────────────────────────────────────────────
def verify_turnstile(token: str, ip: str = "") -> bool: def verify_turnstile(token: str, ip: str = "") -> bool:
try: try:
@ -196,6 +311,28 @@ def set_setting(db: Session, key: str, value: str):
db.commit() 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 ────────────────────────────────────────────────── # ── PYDANTIC MODELS ──────────────────────────────────────────────────
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
@ -230,6 +367,11 @@ class NextcloudConfigRequest(BaseModel):
username: str username: str
password: str password: str
class UserSettingsRequest(BaseModel):
nc_url: Optional[str] = None
nc_user: Optional[str] = None
nc_pass: Optional[str] = None
class NextcloudDownloadRequest(BaseModel): class NextcloudDownloadRequest(BaseModel):
path: str 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) token = create_token(user.id, user.username, user.role)
response.set_cookie("auth_token", token, httponly=True, samesite="lax", max_age=172800) 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") @app.post("/api/auth/register")
@ -277,14 +419,48 @@ async def register(req: RegisterRequest, response: Response, request: Request, d
email=req.email, email=req.email,
password_hash=hash_password(req.password), password_hash=hash_password(req.password),
role="user", role="user",
email_verified=False,
) )
db.add(user) db.add(user)
db.commit() db.commit()
db.refresh(user) 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) token = create_token(user.id, user.username, user.role)
response.set_cookie("auth_token", token, httponly=True, samesite="lax", max_age=172800) 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") @app.post("/api/auth/logout")
@ -295,7 +471,10 @@ async def logout(response: Response):
@app.get("/api/auth/me") @app.get("/api/auth/me")
async def me(user: User = Depends(get_current_user)): 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 ───────────────────────────────────────────────────── # ── ADMIN ROUTES ─────────────────────────────────────────────────────
@ -422,12 +601,29 @@ async def save_progress(doc_id: str, req: ProgressRequest,
return {"ok": True} 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 ───────────────────────────────────────────────── # ── NEXTCLOUD ROUTES ─────────────────────────────────────────────────
@app.get("/api/nextcloud/files") @app.get("/api/nextcloud/files")
async def list_nextcloud_files(path: str = "/", user: User = Depends(get_current_user), db: Session = Depends(get_db)): 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_url, nc_user, nc_pass = resolve_nextcloud(db, user)
nc_user = get_setting(db, "nextcloud_username")
nc_pass = get_setting(db, "nextcloud_password")
if not nc_url: if not nc_url:
raise HTTPException(status_code=400, detail="Nextcloud not configured") 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") @app.post("/api/nextcloud/download")
async def download_nextcloud_file(req: NextcloudDownloadRequest, async def download_nextcloud_file(req: NextcloudDownloadRequest,
user: User = Depends(get_current_user), db: Session = Depends(get_db)): user: User = Depends(get_current_user), db: Session = Depends(get_db)):
nc_url = get_setting(db, "nextcloud_url") nc_url, nc_user, nc_pass = resolve_nextcloud(db, user)
nc_user = get_setting(db, "nextcloud_username")
nc_pass = get_setting(db, "nextcloud_password")
if not nc_url: if not nc_url:
raise HTTPException(status_code=400, detail="Nextcloud not configured") 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(): async def login_page():
return FileResponse("static/login.html") return FileResponse("static/login.html")
@app.get("/settings")
async def settings_page():
return FileResponse("static/settings.html")
@app.get("/") @app.get("/")
async def root(request: Request): async def root(request: Request):
# Let the frontend handle auth redirect via /api/auth/me
return FileResponse("static/index.html") return FileResponse("static/index.html")
@app.get("/admin") @app.get("/admin")
async def admin_page(): async def admin_page():
return FileResponse("static/admin.html") return FileResponse("static/admin.html")

View file

@ -18,8 +18,17 @@ class User(Base):
username = Column(String(50), unique=True, nullable=False, index=True) username = Column(String(50), unique=True, nullable=False, index=True)
email = Column(String(100), unique=True, nullable=False) email = Column(String(100), unique=True, nullable=False)
password_hash = Column(String(255), 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) 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) created_at = Column(DateTime, default=datetime.utcnow)
@ -29,7 +38,7 @@ class Document(Base):
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
title = Column(String(500)) title = Column(String(500))
content = Column(Text) content = Column(Text)
source_type = Column(String(20)) # text, pdf, url, nextcloud source_type = Column(String(20))
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
@ -44,11 +53,22 @@ class Progress(Base):
class Setting(Base): class Setting(Base):
"""Global admin settings (key/value)"""
__tablename__ = "settings" __tablename__ = "settings"
key = Column(String(100), primary_key=True) key = Column(String(100), primary_key=True)
value = Column(Text) 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(): def get_db():
db = SessionLocal() db = SessionLocal()
try: 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"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Speechify — Admin</title> <title>Speechify — Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/> <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"/> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

View file

@ -55,6 +55,11 @@ async function initAuth() {
const ddAdmin = $('dd-admin'); const ddAdmin = $('dd-admin');
if (currentUser.role !== 'admin') ddAdmin.classList.add('hidden'); 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 menu dropdown toggle
$('user-btn').addEventListener('click', e => { $('user-btn').addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
@ -70,6 +75,19 @@ function logout() {
} }
window.logout = 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 ──────────────────────────────────────────────────── // ── VOICES INIT ────────────────────────────────────────────────────
async function initVoices() { async function initVoices() {
const resp = await fetch('/api/voices'); 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> <title>Speechify</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/> <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 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"/> <link rel="stylesheet" href="/static/style.css"/>
</head> </head>
<body> <body>
@ -36,12 +37,20 @@
</button> </button>
<div class="user-dropdown" id="user-dropdown"> <div class="user-dropdown" id="user-dropdown">
<a href="/admin" class="dd-item" id="dd-admin">Admin Panel</a> <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> <button class="dd-item" onclick="logout()">Sign Out</button>
</div> </div>
</div> </div>
</div> </div>
</header> </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"> <div class="main-layout">
<!-- Left Sidebar --> <!-- Left Sidebar -->

View file

@ -6,6 +6,7 @@
<title>Speechify — Sign In</title> <title>Speechify — Sign In</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/> <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 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> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::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:hover { background: var(--bg3); color: var(--text); }
.dd-item.hidden { display: none; } .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 ──────────────────────────── */ /* ── MAIN LAYOUT ──────────────────────────── */
.app { display: flex; flex-direction: column; height: 100vh; } .app { display: flex; flex-direction: column; height: 100vh; }
.main-layout { .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>