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:
@@ -9,6 +9,7 @@ from sqlalchemy import func, select, update
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.conversation import Conversation
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.invitation import InvitationToken
|
||||
from fabledassistant.models.password_reset import PasswordResetToken
|
||||
from fabledassistant.models.setting import Setting
|
||||
from fabledassistant.models.user import User
|
||||
@@ -220,3 +221,99 @@ async def reset_password_with_token(raw_token: str, new_password: str) -> bool:
|
||||
|
||||
logger.info("Password reset via token for user %d (%s)", user.id, user.username)
|
||||
return True
|
||||
|
||||
|
||||
async def create_invitation(email: str, invited_by: int) -> str:
|
||||
"""Generate an invitation token. Returns the raw token (for the email link)."""
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=7)
|
||||
|
||||
async with async_session() as session:
|
||||
# Invalidate previous unused invitations for same email
|
||||
result = await session.execute(
|
||||
select(InvitationToken).where(
|
||||
func.lower(InvitationToken.email) == email.lower(),
|
||||
InvitationToken.used.is_(False),
|
||||
)
|
||||
)
|
||||
for old_token in result.scalars().all():
|
||||
old_token.used = True
|
||||
|
||||
token = InvitationToken(
|
||||
email=email.lower(),
|
||||
token_hash=token_hash,
|
||||
invited_by=invited_by,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
session.add(token)
|
||||
await session.commit()
|
||||
|
||||
logger.info("Invitation created for %s by user %d", email, invited_by)
|
||||
return raw_token
|
||||
|
||||
|
||||
async def validate_invitation_token(raw_token: str) -> InvitationToken | None:
|
||||
"""Look up by hash, check not used/expired. Returns the token record with email."""
|
||||
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(InvitationToken).where(InvitationToken.token_hash == token_hash)
|
||||
)
|
||||
invitation = result.scalars().first()
|
||||
|
||||
if not invitation:
|
||||
return None
|
||||
if invitation.used:
|
||||
return None
|
||||
if invitation.expires_at < datetime.now(timezone.utc):
|
||||
return None
|
||||
|
||||
return invitation
|
||||
|
||||
|
||||
async def register_with_invitation(raw_token: str, username: str, password: str) -> User | None:
|
||||
"""Validate token, create user with the invitation's email, mark token used."""
|
||||
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(InvitationToken).where(InvitationToken.token_hash == token_hash)
|
||||
)
|
||||
invitation = result.scalars().first()
|
||||
|
||||
if not invitation or invitation.used or invitation.expires_at < datetime.now(timezone.utc):
|
||||
return None
|
||||
|
||||
invitation.used = True
|
||||
await session.commit()
|
||||
|
||||
# Create user outside the invitation session
|
||||
user = await create_user(username, password, invitation.email)
|
||||
logger.info("User '%s' registered via invitation for %s", username, invitation.email)
|
||||
return user
|
||||
|
||||
|
||||
async def list_pending_invitations() -> list[InvitationToken]:
|
||||
"""List unused, non-expired invitations."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(InvitationToken).where(
|
||||
InvitationToken.used.is_(False),
|
||||
InvitationToken.expires_at > datetime.now(timezone.utc),
|
||||
).order_by(InvitationToken.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def revoke_invitation(invitation_id: int) -> bool:
|
||||
"""Mark an invitation as used (revoked)."""
|
||||
async with async_session() as session:
|
||||
invitation = await session.get(InvitationToken, invitation_id)
|
||||
if not invitation or invitation.used:
|
||||
return False
|
||||
invitation.used = True
|
||||
await session.commit()
|
||||
logger.info("Invitation %d revoked", invitation_id)
|
||||
return True
|
||||
|
||||
@@ -55,6 +55,20 @@ async def is_smtp_configured() -> bool:
|
||||
return bool(config.get("smtp_host"))
|
||||
|
||||
|
||||
async def get_base_url() -> str:
|
||||
"""Get the application base URL from admin settings, falling back to Config.BASE_URL."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Setting)
|
||||
.join(User, Setting.user_id == User.id)
|
||||
.where(User.role == "admin", Setting.key == "base_url")
|
||||
)
|
||||
setting = result.scalars().first()
|
||||
if setting and setting.value:
|
||||
return setting.value.rstrip("/")
|
||||
return Config.BASE_URL.rstrip("/")
|
||||
|
||||
|
||||
async def send_email(to: str, subject: str, html_body: str) -> None:
|
||||
"""Send an email via SMTP."""
|
||||
config = await get_smtp_config()
|
||||
|
||||
@@ -51,6 +51,8 @@ async def list_notes(
|
||||
is_task: bool | None = None,
|
||||
status: str | None = None,
|
||||
priority: str | None = None,
|
||||
due_before: date | None = None,
|
||||
due_after: date | None = None,
|
||||
sort: str = "updated_at",
|
||||
order: str = "desc",
|
||||
limit: int = 50,
|
||||
@@ -96,6 +98,13 @@ async def list_notes(
|
||||
query = query.where(Note.priority == priority)
|
||||
count_query = count_query.where(Note.priority == priority)
|
||||
|
||||
if due_before is not None:
|
||||
query = query.where(Note.due_date < due_before)
|
||||
count_query = count_query.where(Note.due_date < due_before)
|
||||
if due_after is not None:
|
||||
query = query.where(Note.due_date >= due_after)
|
||||
count_query = count_query.where(Note.due_date >= due_after)
|
||||
|
||||
sort_col = getattr(Note, sort, Note.updated_at)
|
||||
if order == "asc":
|
||||
query = query.order_by(sort_col.asc())
|
||||
|
||||
@@ -138,6 +138,33 @@ async def send_password_reset_success_email(email: str) -> None:
|
||||
await send_email(email, "Fabled Assistant - Password Changed", html)
|
||||
|
||||
|
||||
async def send_invitation_email(email: str, invite_url: str, invited_by_username: str) -> None:
|
||||
"""Send a branded invitation email with a registration link."""
|
||||
html = f"""
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
|
||||
<div style="background: #6366f1; color: #fff; padding: 16px 24px; border-radius: 8px 8px 0 0; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 20px;">You're Invited!</h1>
|
||||
</div>
|
||||
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0 0 16px; color: #374151; font-size: 15px;">
|
||||
<strong>{invited_by_username}</strong> has invited you to join Fabled Assistant. Click the button below to create your account:
|
||||
</p>
|
||||
<div style="text-align: center; margin: 24px 0;">
|
||||
<a href="{invite_url}" style="display: inline-block; background: #6366f1; color: #fff; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 15px;">
|
||||
Accept Invitation
|
||||
</a>
|
||||
</div>
|
||||
<p style="margin: 0 0 8px; color: #6b7280; font-size: 13px;">This invitation expires in 7 days.</p>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 13px;">If you weren't expecting this invitation, you can safely ignore this email.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
|
||||
<p style="margin: 0; color: #9ca3af; font-size: 12px;">If the button doesn't work, copy and paste this link into your browser:</p>
|
||||
<p style="margin: 4px 0 0; color: #9ca3af; font-size: 12px; word-break: break-all;">{invite_url}</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
await send_email(email, "Fabled Assistant - You're Invited!", html)
|
||||
|
||||
|
||||
async def check_due_tasks() -> None:
|
||||
"""Check for tasks due today and send reminder emails."""
|
||||
if not await is_smtp_configured():
|
||||
|
||||
Reference in New Issue
Block a user