Add invitation system, table of contents, actionable dashboard, and settings improvements
Invitation system (Phase 5.7): - invitation_tokens table (migration 0012) with SHA256-hashed tokens, 7-day expiry - Admin CRUD endpoints: POST/GET/DELETE /api/admin/invitations - Branded invitation email with registration link - /register-invite frontend view with token validation and account creation - Admin UI: invite form + pending invitations table with revoke - Configurable base URL setting for email links (replaces hardcoded Config.BASE_URL) Table of contents + markdown improvements (Phase 5.8): - TableOfContents component: sticky sidebar, heading parsing, smooth-scroll - Custom marked renderer adds id attributes to headings for anchor links - stripFirstLineTags() prevents leading #tags from rendering as headings - NoteViewerView/TaskViewerView flex layout with TOC sidebar (hidden ≤1200px) - Model catalog refresh: added llama3.2/3.3, gemma3, qwen3, phi4, deepseek-r1, qwen2.5-coder, dolphin3; added Reasoning category; removed discontinued models - Settings model section split into Installed/Available tabs Actionable dashboard (Phase 5.9): - due_before/due_after query params on /api/tasks (exclusive < / inclusive >=) - HomeView rewritten: overdue (red accent), due today, in progress, chats, notes - 5 parallel API calls via Promise.allSettled - Client-side done filtering and in-progress deduplication - Task sections hidden when empty; status toggle removes done tasks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,6 @@ import asyncio
|
||||
from quart import Blueprint, g, jsonify, request, session
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.auth import (
|
||||
authenticate,
|
||||
change_password,
|
||||
@@ -14,7 +13,9 @@ from fabledassistant.services.auth import (
|
||||
get_user_by_username,
|
||||
get_user_count,
|
||||
is_registration_open,
|
||||
register_with_invitation,
|
||||
reset_password_with_token,
|
||||
validate_invitation_token,
|
||||
)
|
||||
from fabledassistant.services.logging import log_audit
|
||||
from fabledassistant.services.notifications import (
|
||||
@@ -22,7 +23,7 @@ from fabledassistant.services.notifications import (
|
||||
send_password_reset_email,
|
||||
send_password_reset_success_email,
|
||||
)
|
||||
from fabledassistant.services.email import is_smtp_configured
|
||||
from fabledassistant.services.email import get_base_url, is_smtp_configured
|
||||
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||
|
||||
@@ -142,7 +143,8 @@ async def forgot_password():
|
||||
user = await get_user_by_email(email)
|
||||
if user and await is_smtp_configured():
|
||||
raw_token = await create_password_reset_token(user.id)
|
||||
reset_url = f"{Config.BASE_URL}/reset-password?token={raw_token}"
|
||||
base_url = await get_base_url()
|
||||
reset_url = f"{base_url}/reset-password?token={raw_token}"
|
||||
asyncio.create_task(send_password_reset_email(user.email, reset_url))
|
||||
await log_audit(
|
||||
"password_reset_requested",
|
||||
@@ -196,6 +198,46 @@ async def reset_password():
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@auth_bp.route("/invitation/<token>", methods=["GET"])
|
||||
async def check_invitation(token: str):
|
||||
invitation = await validate_invitation_token(token)
|
||||
if not invitation:
|
||||
return jsonify({"valid": False})
|
||||
return jsonify({"valid": True, "email": invitation.email})
|
||||
|
||||
|
||||
@auth_bp.route("/register-with-invite", methods=["POST"])
|
||||
async def register_with_invite():
|
||||
data = await request.get_json()
|
||||
raw_token = (data.get("token") or "").strip()
|
||||
username = (data.get("username") or "").strip()
|
||||
password = data.get("password") or ""
|
||||
|
||||
if not raw_token:
|
||||
return jsonify({"error": "Invitation token is required"}), 400
|
||||
if not username:
|
||||
return jsonify({"error": "Username is required"}), 400
|
||||
if len(password) < 8:
|
||||
return jsonify({"error": "Password must be at least 8 characters"}), 400
|
||||
|
||||
try:
|
||||
user = await register_with_invitation(raw_token, username, password)
|
||||
except Exception:
|
||||
return jsonify({"error": "Username already taken"}), 409
|
||||
|
||||
if not user:
|
||||
return jsonify({"error": "Invalid or expired invitation"}), 400
|
||||
|
||||
session["user_id"] = user.id
|
||||
await log_audit(
|
||||
"register_with_invite",
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
return jsonify(user.to_dict()), 201
|
||||
|
||||
|
||||
@auth_bp.route("/status", methods=["GET"])
|
||||
async def status():
|
||||
count = await get_user_count()
|
||||
|
||||
Reference in New Issue
Block a user