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:
2026-02-14 08:46:51 -05:00
parent d354da5b51
commit e02b681e91
20 changed files with 1506 additions and 295 deletions
+85 -2
View File
@@ -1,12 +1,16 @@
import asyncio
import json
from quart import Blueprint, Response, g, jsonify, request
from fabledassistant.auth import admin_required, login_required, get_current_user_id
from fabledassistant.services.auth import (
create_invitation,
delete_user,
is_registration_open,
list_pending_invitations,
list_users,
revoke_invitation,
set_registration_open,
)
from fabledassistant.services.backup import (
@@ -14,9 +18,10 @@ from fabledassistant.services.backup import (
export_user_backup,
restore_full_backup,
)
from fabledassistant.services.email import SMTP_SETTING_KEYS, get_smtp_config, send_test_email
from fabledassistant.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
from fabledassistant.services.logging import get_logs, get_log_stats, log_audit
from fabledassistant.services.settings import set_settings_batch
from fabledassistant.services.notifications import send_invitation_email
from fabledassistant.services.settings import set_setting, set_settings_batch
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
@@ -175,3 +180,81 @@ async def list_logs():
async def log_stats():
stats = await get_log_stats()
return jsonify(stats)
@admin_bp.route("/base-url", methods=["GET"])
@admin_required
async def get_base_url_setting():
base_url = await get_base_url()
return jsonify({"base_url": base_url})
@admin_bp.route("/base-url", methods=["PUT"])
@admin_required
async def update_base_url():
data = await request.get_json()
url = (data.get("base_url") or "").strip().rstrip("/")
uid = get_current_user_id()
await set_setting(uid, "base_url", url)
await log_audit("base_url_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"base_url": url})
return jsonify({"status": "ok"})
@admin_bp.route("/invitations", methods=["POST"])
@admin_required
async def create_invite():
data = await request.get_json()
email = (data.get("email") or "").strip().lower()
if not email:
return jsonify({"error": "Email is required"}), 400
if not await is_smtp_configured():
return jsonify({"error": "SMTP is not configured. Configure email settings first."}), 400
uid = get_current_user_id()
raw_token = await create_invitation(email, uid)
base_url = await get_base_url()
invite_url = f"{base_url}/register-invite?token={raw_token}"
asyncio.create_task(send_invitation_email(email, invite_url, g.user.username))
await log_audit(
"invitation_created",
user_id=uid,
username=g.user.username,
ip_address=request.remote_addr,
details={"invited_email": email},
)
return jsonify({"status": "ok"}), 201
@admin_bp.route("/invitations", methods=["GET"])
@admin_required
async def get_invitations():
invitations = await list_pending_invitations()
return jsonify({
"invitations": [
{
"id": inv.id,
"email": inv.email,
"created_at": inv.created_at.isoformat(),
"expires_at": inv.expires_at.isoformat(),
}
for inv in invitations
]
})
@admin_bp.route("/invitations/<int:invitation_id>", methods=["DELETE"])
@admin_required
async def delete_invitation(invitation_id: int):
uid = get_current_user_id()
revoked = await revoke_invitation(invitation_id)
if not revoked:
return jsonify({"error": "Invitation not found or already used"}), 404
await log_audit(
"invitation_revoked",
user_id=uid,
username=g.user.username,
ip_address=request.remote_addr,
details={"invitation_id": invitation_id},
)
return jsonify({"status": "ok"})
+45 -3
View File
@@ -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()
+7
View File
@@ -29,6 +29,11 @@ async def list_tasks_route():
limit = request.args.get("limit", 50, type=int)
offset = request.args.get("offset", 0, type=int)
due_before_str = request.args.get("due_before")
due_after_str = request.args.get("due_after")
due_before = date.fromisoformat(due_before_str) if due_before_str else None
due_after = date.fromisoformat(due_after_str) if due_after_str else None
tasks, total = await list_notes(
uid,
q=q,
@@ -36,6 +41,8 @@ async def list_tasks_route():
is_task=True,
status=status,
priority=priority,
due_before=due_before,
due_after=due_after,
sort=sort,
order=order,
limit=limit,