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
+97
View File
@@ -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