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:
parent
fb8e1340f1
commit
ee3ffcfe90
12 changed files with 551 additions and 15 deletions
221
main.py
221
main.py
|
|
@ -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")
|
||||
|
|
|
|||
24
models.py
24
models.py
|
|
@ -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
BIN
speechify.db
Normal file
Binary file not shown.
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
4
static/favicon.svg
Normal 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 |
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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
204
static/settings.html
Normal 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>
|
||||
|
|
@ -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
35
static/verify-fail.html
Normal 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
35
static/verify-ok.html
Normal 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>
|
||||
Loading…
Reference in a new issue