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
@@ -0,0 +1,26 @@
"""Add invitation_tokens table."""
from alembic import op
revision = "0012"
down_revision = "0011"
def upgrade() -> None:
op.execute("""
CREATE TABLE invitation_tokens (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
invited_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
op.execute("CREATE INDEX ix_invitation_tokens_token_hash ON invitation_tokens (token_hash)")
op.execute("CREATE INDEX ix_invitation_tokens_email ON invitation_tokens (email)")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS invitation_tokens")
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed } from "vue";
import { slugify } from "@/utils/markdown";
const props = defineProps<{ body: string }>();
interface TocEntry {
depth: number;
text: string;
id: string;
}
const headings = computed<TocEntry[]>(() => {
const entries: TocEntry[] = [];
const re = /^(#{1,6})\s+(.+)$/gm;
let match;
while ((match = re.exec(props.body)) !== null) {
entries.push({
depth: match[1].length,
text: match[2].trim(),
id: slugify(match[2].trim()),
});
}
return entries;
});
function scrollTo(id: string) {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: "smooth" });
}
</script>
<template>
<nav v-if="headings.length" class="toc">
<h3 class="toc-title">Contents</h3>
<ul class="toc-list">
<li
v-for="(h, i) in headings"
:key="i"
:style="{ paddingLeft: (h.depth - 1) * 0.75 + 'rem' }"
>
<a class="toc-link" @click.prevent="scrollTo(h.id)" :href="`#${h.id}`">
{{ h.text }}
</a>
</li>
</ul>
</nav>
</template>
<style scoped>
.toc {
position: sticky;
top: 1rem;
width: 200px;
flex-shrink: 0;
align-self: flex-start;
font-size: 0.85rem;
line-height: 1.4;
}
.toc-title {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--color-text-muted);
margin: 0 0 0.5rem;
letter-spacing: 0.05em;
}
.toc-list {
list-style: none;
margin: 0;
padding: 0;
}
.toc-list li {
margin-bottom: 0.25rem;
}
.toc-link {
color: var(--color-text-muted);
text-decoration: none;
cursor: pointer;
}
.toc-link:hover {
color: var(--color-primary);
}
</style>
+6
View File
@@ -34,6 +34,12 @@ const router = createRouter({
component: () => import("@/views/ResetPasswordView.vue"),
meta: { public: true },
},
{
path: "/register-invite",
name: "register-invite",
component: () => import("@/views/RegisterInviteView.vue"),
meta: { public: true },
},
{
path: "/notes",
name: "notes",
+32 -2
View File
@@ -8,8 +8,37 @@ function decodeEntities(text: string): string {
return textarea.value;
}
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/<[^>]+>/g, "")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/\s+/g, "-");
}
export function stripFirstLineTags(text: string): string {
const match = text.match(/^[ \t]*((?:(?<!\w)#[\w]+(?:\/[\w]+)*[ \t]*)+)\n?/);
if (!match) return text;
// Verify every #-token is a tag, not a heading (headings have "# " with space)
const tokens = match[1].trim().split(/\s+/);
const allTags = tokens.every((t) => /^#[\w]+(?:\/[\w]+)*$/.test(t));
if (!allTags) return text;
return text.slice(match[0].length);
}
const headingRenderer = {
heading({ text, depth }: { text: string; depth: number }): string {
const id = slugify(text);
return `<h${depth} id="${id}">${text}</h${depth}>`;
},
};
marked.use({ renderer: headingRenderer });
export function renderMarkdown(text: string): string {
const decoded = decodeEntities(text);
const stripped = stripFirstLineTags(text);
const decoded = decodeEntities(stripped);
const html = marked(decoded) as string;
const withTags = linkifyTags(html);
const withLinks = linkifyWikilinks(withTags);
@@ -21,7 +50,8 @@ export function renderMarkdown(text: string): string {
}
export function renderPreview(text: string): string {
const decoded = decodeEntities(text);
const stripped = stripFirstLineTags(text);
const decoded = decodeEntities(stripped);
const html = marked(decoded) as string;
const withTags = linkifyTags(html);
const withLinks = linkifyWikilinks(withTags);
+122 -43
View File
@@ -13,47 +13,96 @@ import { useChatStore } from "@/stores/chat";
const router = useRouter();
const recentNotes = ref<Note[]>([]);
const recentTasks = ref<Task[]>([]);
const overdueTasks = ref<Task[]>([]);
const dueTodayTasks = ref<Task[]>([]);
const inProgressTasks = ref<Task[]>([]);
const recentChats = ref<Conversation[]>([]);
const loading = ref(true);
const tasksStore = useTasksStore();
function todayStr(): string {
const d = new Date();
return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
}
function tomorrowStr(): string {
const d = new Date();
d.setDate(d.getDate() + 1);
return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
}
onMounted(async () => {
try {
const notesData = await apiGet<NoteListResponse>(
"/api/notes?sort=updated_at&order=desc&limit=5"
);
recentNotes.value = notesData.notes;
} catch (e) {
console.error("Failed to load recent notes:", e);
const today = todayStr();
const tomorrow = tomorrowStr();
const [notesRes, overdueRes, dueTodayRes, inProgressRes, chatsRes] =
await Promise.allSettled([
apiGet<NoteListResponse>("/api/notes?sort=updated_at&order=desc&limit=5"),
apiGet<TaskListResponse>(
`/api/tasks?due_before=${today}&sort=due_date&order=asc&limit=15`
),
apiGet<TaskListResponse>(
`/api/tasks?due_after=${today}&due_before=${tomorrow}&sort=priority&order=desc&limit=10`
),
apiGet<TaskListResponse>(
`/api/tasks?status=in_progress&sort=updated_at&order=desc&limit=5`
),
apiGet<{ conversations: Conversation[]; total: number }>(
"/api/chat/conversations?limit=3&offset=0"
),
]);
if (notesRes.status === "fulfilled") {
recentNotes.value = notesRes.value.notes;
}
if (chatsRes.status === "fulfilled") {
recentChats.value = chatsRes.value.conversations;
}
try {
const tasksData = await apiGet<TaskListResponse>(
"/api/tasks?sort=updated_at&order=desc&limit=5"
// Filter out done tasks from overdue and due-today
if (overdueRes.status === "fulfilled") {
overdueTasks.value = overdueRes.value.tasks.filter(
(t) => t.status !== "done"
);
}
if (dueTodayRes.status === "fulfilled") {
dueTodayTasks.value = dueTodayRes.value.tasks.filter(
(t) => t.status !== "done"
);
recentTasks.value = tasksData.tasks;
} catch (e) {
console.error("Failed to load recent tasks:", e);
}
try {
const chatData = await apiGet<{ conversations: Conversation[]; total: number }>(
"/api/chat/conversations?limit=3&offset=0"
// Deduplicate in-progress against overdue and due-today
if (inProgressRes.status === "fulfilled") {
const seen = new Set<number>();
for (const t of overdueTasks.value) seen.add(t.id);
for (const t of dueTodayTasks.value) seen.add(t.id);
inProgressTasks.value = inProgressRes.value.tasks.filter(
(t) => !seen.has(t.id)
);
recentChats.value = chatData.conversations;
} catch (e) {
console.error("Failed to load recent chats:", e);
}
loading.value = false;
});
function removeFromAllLists(id: number) {
overdueTasks.value = overdueTasks.value.filter((t) => t.id !== id);
dueTodayTasks.value = dueTodayTasks.value.filter((t) => t.id !== id);
inProgressTasks.value = inProgressTasks.value.filter((t) => t.id !== id);
}
function onStatusToggle(id: number, status: TaskStatus) {
tasksStore.patchStatus(id, status).then((updated) => {
const idx = recentTasks.value.findIndex((t) => t.id === updated.id);
if (idx !== -1) {
recentTasks.value[idx] = updated;
if (updated.status === "done") {
removeFromAllLists(updated.id);
} else {
// Update in whichever list contains it
for (const list of [overdueTasks, dueTodayTasks, inProgressTasks]) {
const idx = list.value.findIndex((t) => t.id === updated.id);
if (idx !== -1) {
list.value[idx] = updated;
break;
}
}
}
});
}
@@ -73,6 +122,51 @@ async function newChat() {
<p v-if="loading" class="loading">Loading...</p>
<template v-else>
<section v-if="overdueTasks.length" class="section section-overdue">
<div class="section-header">
<h2>Overdue</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div class="cards">
<TaskCard
v-for="task in overdueTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
<section v-if="dueTodayTasks.length" class="section">
<div class="section-header">
<h2>Due Today</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div class="cards">
<TaskCard
v-for="task in dueTodayTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
<section v-if="inProgressTasks.length" class="section">
<div class="section-header">
<h2>In Progress</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div class="cards">
<TaskCard
v-for="task in inProgressTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
<section class="section">
<div class="section-header">
<h2>Recent Chats</h2>
@@ -97,7 +191,7 @@ async function newChat() {
<section class="section">
<div class="section-header">
<h2>Recent Notes</h2>
<h2>Recently Edited</h2>
<router-link to="/notes" class="see-all">See all</router-link>
</div>
<div v-if="recentNotes.length" class="cards">
@@ -112,25 +206,6 @@ async function newChat() {
<router-link to="/notes/new" class="btn-cta">+ New Note</router-link>
</div>
</section>
<section class="section">
<div class="section-header">
<h2>Recent Tasks</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div v-if="recentTasks.length" class="cards">
<TaskCard
v-for="task in recentTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
<div v-else class="empty-state">
<p class="empty-text">No tasks yet.</p>
<router-link to="/tasks/new" class="btn-cta">+ New Task</router-link>
</div>
</section>
</template>
</main>
</template>
@@ -150,6 +225,10 @@ async function newChat() {
.section {
margin-bottom: 2rem;
}
.section-overdue {
border-left: 3px solid var(--color-overdue, #e74c3c);
padding-left: 0.75rem;
}
.section-header {
display: flex;
justify-content: space-between;
+82 -58
View File
@@ -7,6 +7,7 @@ import { relativeTime } from "@/composables/useRelativeTime";
import { apiPost } from "@/api/client";
import type { Note } from "@/types/note";
import TagPill from "@/components/TagPill.vue";
import TableOfContents from "@/components/TableOfContents.vue";
const route = useRoute();
const router = useRouter();
@@ -100,72 +101,95 @@ async function convertToTask() {
</script>
<template>
<main class="viewer">
<p v-if="store.loading">Loading...</p>
<template v-else-if="store.currentNote">
<div class="toolbar">
<router-link to="/notes" class="btn-back">Back</router-link>
<router-link
:to="`/notes/${store.currentNote.id}/edit`"
class="btn-edit"
>
Edit
</router-link>
<button
v-if="!store.currentNote.is_task"
class="btn-convert"
@click="convertToTask"
:disabled="converting"
>
{{ converting ? "Converting..." : "Convert to Task" }}
</button>
</div>
<h1>{{ store.currentNote.title || "Untitled" }}</h1>
<p class="meta">
Updated {{ relativeTime(store.currentNote.updated_at) }}
&middot;
Created {{ relativeTime(store.currentNote.created_at) }}
</p>
<div class="tags" v-if="store.currentNote.tags.length">
<TagPill
v-for="tag in store.currentNote.tags"
:key="tag"
:tag="tag"
@click="onTagClick"
/>
</div>
<div
ref="bodyEl"
class="body prose"
v-html="renderedBody"
@click="onBodyClick"
></div>
<div class="viewer-layout">
<main class="viewer">
<p v-if="store.loading">Loading...</p>
<template v-else-if="store.currentNote">
<div class="toolbar">
<router-link to="/notes" class="btn-back">Back</router-link>
<router-link
:to="`/notes/${store.currentNote.id}/edit`"
class="btn-edit"
>
Edit
</router-link>
<button
v-if="!store.currentNote.is_task"
class="btn-convert"
@click="convertToTask"
:disabled="converting"
>
{{ converting ? "Converting..." : "Convert to Task" }}
</button>
</div>
<h1>{{ store.currentNote.title || "Untitled" }}</h1>
<p class="meta">
Updated {{ relativeTime(store.currentNote.updated_at) }}
&middot;
Created {{ relativeTime(store.currentNote.created_at) }}
</p>
<div class="tags" v-if="store.currentNote.tags.length">
<TagPill
v-for="tag in store.currentNote.tags"
:key="tag"
:tag="tag"
@click="onTagClick"
/>
</div>
<div
ref="bodyEl"
class="body prose"
v-html="renderedBody"
@click="onBodyClick"
></div>
<div v-if="backlinks.length" class="backlinks">
<h2>Backlinks</h2>
<ul class="backlinks-list">
<li v-for="link in backlinks" :key="`${link.type}-${link.id}`" class="backlink-item">
<span class="backlink-type">{{ link.type }}</span>
<router-link
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
class="backlink-link"
>
{{ link.title || "Untitled" }}
</router-link>
</li>
</ul>
</div>
</template>
<p v-else>Note not found.</p>
</main>
<div v-if="backlinks.length" class="backlinks">
<h2>Backlinks</h2>
<ul class="backlinks-list">
<li v-for="link in backlinks" :key="`${link.type}-${link.id}`" class="backlink-item">
<span class="backlink-type">{{ link.type }}</span>
<router-link
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
class="backlink-link"
>
{{ link.title || "Untitled" }}
</router-link>
</li>
</ul>
</div>
</template>
<p v-else>Note not found.</p>
</main>
<TableOfContents
v-if="store.currentNote?.body"
:body="store.currentNote.body"
class="toc-sidebar"
/>
</div>
</template>
<style scoped>
.viewer-layout {
display: flex;
max-width: 1200px;
margin: 0 auto;
gap: 2rem;
}
.viewer {
flex: 1;
min-width: 0;
max-width: 960px;
margin: 2rem auto;
margin: 2rem 0;
padding: 0 1rem;
}
.toc-sidebar {
margin-top: 2rem;
}
@media (max-width: 1200px) {
.toc-sidebar {
display: none;
}
}
.toolbar {
display: flex;
gap: 0.75rem;
+277
View File
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { apiGet, apiPost } from "@/api/client";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const token = computed(() => (route.query.token as string) || "");
const email = ref("");
const username = ref("");
const password = ref("");
const confirmPassword = ref("");
const error = ref("");
const submitting = ref(false);
const validating = ref(true);
const valid = ref(false);
const passwordMismatch = computed(
() => confirmPassword.value.length > 0 && password.value !== confirmPassword.value
);
const canSubmit = computed(
() =>
!submitting.value &&
!passwordMismatch.value &&
username.value.trim().length > 0 &&
password.value.length >= 8 &&
!!token.value
);
onMounted(async () => {
if (!token.value) {
validating.value = false;
return;
}
try {
const data = await apiGet<{ valid: boolean; email?: string }>(
`/api/auth/invitation/${token.value}`
);
valid.value = data.valid;
if (data.email) {
email.value = data.email;
}
} catch {
valid.value = false;
} finally {
validating.value = false;
}
});
async function handleSubmit() {
if (passwordMismatch.value) {
error.value = "Passwords do not match";
return;
}
error.value = "";
submitting.value = true;
try {
await apiPost("/api/auth/register-with-invite", {
token: token.value,
username: username.value.trim(),
password: password.value,
});
await authStore.checkAuth();
router.push("/");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Registration failed";
} else {
error.value = "Registration failed";
}
} finally {
submitting.value = false;
}
}
</script>
<template>
<main class="auth-page">
<div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Accept Invitation</h1></div>
<div v-if="validating" class="loading-msg">Validating invitation...</div>
<div v-else-if="!token || !valid" class="error-block">
<p>This invitation link is invalid or has expired.</p>
<p class="auth-footer">
<router-link to="/login">Back to Sign In</router-link>
</p>
</div>
<template v-else>
<form @submit.prevent="handleSubmit">
<div class="field">
<label for="email">Email</label>
<input
id="email"
:value="email"
type="email"
class="input"
disabled
/>
</div>
<div class="field">
<label for="username">Username</label>
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
required
class="input"
/>
</div>
<div class="field">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="new-password"
required
minlength="8"
class="input"
/>
<p class="field-hint">Must be at least 8 characters</p>
</div>
<div class="field">
<label for="confirm-password">Confirm Password</label>
<input
id="confirm-password"
v-model="confirmPassword"
type="password"
autocomplete="new-password"
required
class="input"
:class="{ 'input-error': passwordMismatch }"
/>
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
</div>
<p v-if="error" class="error-msg">{{ error }}</p>
<button type="submit" class="btn-submit" :disabled="!canSubmit">
{{ submitting ? "Creating Account..." : "Create Account" }}
</button>
</form>
</template>
<p class="auth-footer">
<router-link to="/login">Back to Sign In</router-link>
</p>
</div>
</main>
</template>
<style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.loading-msg {
text-align: center;
color: var(--color-text-muted);
font-size: 0.95rem;
padding: 1rem 0;
}
.error-block {
text-align: center;
color: var(--color-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.error-block p {
margin: 0.5rem 0;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.95rem;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.input:focus {
outline: none;
border-color: var(--color-primary);
}
.input-error {
border-color: var(--color-danger);
}
.input-error:focus {
border-color: var(--color-danger);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-danger);
}
.error-msg {
color: var(--color-danger);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.btn-submit {
width: 100%;
padding: 0.6rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
}
.btn-submit:disabled {
opacity: 0.6;
cursor: default;
}
.btn-submit:hover:not(:disabled) {
opacity: 0.9;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--color-primary);
}
</style>
+246 -97
View File
@@ -46,88 +46,110 @@ const sendingTest = ref(false);
const MODEL_CATALOG: ModelInfo[] = [
// — General Purpose —
{
name: "llama3.2",
description: "Meta's Llama 3.2 — lightweight models (1B/3B) with 128K context. Great for low-resource setups.",
size: "2.0 GB",
bestFor: "Light tasks, fast responses, low RAM",
category: "General Purpose",
},
{
name: "llama3.1",
description: "Meta's Llama 3.1 8B — strong general-purpose model with good instruction following.",
size: "4.7 GB",
description: "Meta's Llama 3.1 8B — strong general-purpose model with good instruction following. 128K context.",
size: "4.9 GB",
bestFor: "General chat, writing, Q&A",
category: "General Purpose",
},
{
name: "llama3.1:70b",
description: "Meta's Llama 3.1 70B — significantly more capable, better reasoning and nuance.",
size: "40 GB",
name: "llama3.3",
description: "Meta's Llama 3.3 70B — 405B-level performance in a 70B model. 128K context. Top tier open model.",
size: "43 GB",
bestFor: "Complex reasoning, detailed analysis",
category: "General Purpose",
},
{
name: "mistral",
description: "Mistral 7B — fast and efficient with strong performance for its size.",
size: "4.1 GB",
bestFor: "Fast responses, general tasks",
name: "gemma3",
description: "Google's Gemma 3 — multimodal (vision + text), 1B to 27B. 128K context, 140+ languages.",
size: "5.2 GB",
bestFor: "Multilingual, vision, general tasks",
category: "General Purpose",
},
{
name: "qwen3",
description: "Alibaba's Qwen 3 — latest generation, 0.6B to 235B. Up to 256K context. Hybrid thinking modes.",
size: "4.7 GB",
bestFor: "Multilingual, reasoning, code, math",
category: "General Purpose",
},
{
name: "qwen2.5",
description: "Alibaba's Qwen 2.5 7B — multilingual model with strong coding and math skills.",
description: "Alibaba's Qwen 2.5 7B — multilingual model with strong coding and math skills. 32K context.",
size: "4.7 GB",
bestFor: "Multilingual, code, math",
category: "General Purpose",
},
{
name: "phi3",
description: "Microsoft Phi-3 Mini — compact model with surprising capability for its size.",
size: "2.3 GB",
bestFor: "Light tasks, low resource usage",
category: "General Purpose",
},
{
name: "neural-chat",
description: "Intel's fine-tune optimized for natural conversation. Lighter filtering than base models.",
name: "mistral",
description: "Mistral 7B v0.3 — fast and efficient with function calling support. 32K context.",
size: "4.1 GB",
bestFor: "Natural conversation, general tasks",
bestFor: "Fast responses, general tasks",
category: "General Purpose",
},
{
name: "yi",
description: "01.AI's Yi 6B — Chinese-developed model, more permissive on creative content.",
size: "3.5 GB",
bestFor: "Creative content, multilingual",
name: "phi4",
description: "Microsoft Phi-4 14B — strong reasoning and math for its size. Successor to Phi-3.",
size: "9.1 GB",
bestFor: "Reasoning, math, structured tasks",
category: "General Purpose",
},
{
name: "command-r",
description: "Cohere's Command R 35B — enterprise-grade model with light content filtering.",
description: "Cohere's Command R 35B — enterprise-grade model with 128K context and light content filtering.",
size: "20 GB",
bestFor: "RAG, conversation, creative tasks",
category: "General Purpose",
},
// — Reasoning —
{
name: "deepseek-r1",
description: "DeepSeek R1 — chain-of-thought reasoning model. Distilled versions from 1.5B to 70B run locally.",
size: "4.7 GB",
bestFor: "Step-by-step reasoning, math, logic",
category: "Reasoning",
},
// — Coding —
{
name: "codellama",
description: "Meta's Code Llama — specialized for code generation and understanding.",
size: "3.8 GB",
bestFor: "Code generation, debugging, technical docs",
name: "qwen2.5-coder",
description: "Alibaba's Qwen 2.5 Coder — 0.5B to 32B. 32B version rivals GPT-4o on coding benchmarks.",
size: "4.7 GB",
bestFor: "Code generation, refactoring, debugging",
category: "Coding",
},
{
name: "deepseek-coder-v2",
description: "DeepSeek Coder V2 — state-of-the-art coding model with strong math ability.",
description: "DeepSeek Coder V2 16B — strong coding model with 160K context and math ability.",
size: "8.9 GB",
bestFor: "Code, math, technical problem solving",
category: "Coding",
},
// — Uncensored / Creative Writing —
{
name: "dolphin3",
description: "Eric Hartford's Dolphin 3 — next-gen uncensored model based on Llama 3.1 8B. 128K context.",
size: "4.9 GB",
bestFor: "Uncensored chat, coding, creative writing",
category: "Uncensored / Creative Writing",
},
{
name: "dolphin-mistral",
description: "Eric Hartford's Dolphin fine-tune of Mistral 7B. Safety/refusal data removed from training.",
description: "Dolphin fine-tune of Mistral 7B. Safety/refusal data removed from training. 32K context.",
size: "4.1 GB",
bestFor: "Uncensored general chat, creative writing",
category: "Uncensored / Creative Writing",
},
{
name: "dolphin-llama3",
description: "Dolphin fine-tune of Llama 3 8B. Uncensored training on a stronger base model.",
description: "Dolphin fine-tune of Llama 3 8B. Uncensored training on a strong base model.",
size: "4.7 GB",
bestFor: "Uncensored chat, strong reasoning",
category: "Uncensored / Creative Writing",
@@ -153,30 +175,15 @@ const MODEL_CATALOG: ModelInfo[] = [
bestFor: "Helpful assistant, minimal filtering",
category: "Uncensored / Creative Writing",
},
{
name: "nollama/mythomax-l2-13b",
description: "MythoMax L2 13B — a merge of multiple fine-tunes for creative and narrative writing with strong coherence.",
size: "7.4 GB",
bestFor: "Creative fiction, roleplay, narrative writing",
category: "Uncensored / Creative Writing",
},
{
name: "mattw/mythalion",
description: "Mythalion 13B — a Gryphe merge combining Mythologic and Pygmalion for expressive creative output.",
size: "7.4 GB",
bestFor: "Creative writing, character dialogue",
category: "Uncensored / Creative Writing",
},
{
name: "samantha-mistral",
description: "Eric Hartford's Samantha personality model. Designed as a helpful companion without refusals.",
size: "4.1 GB",
bestFor: "Companion chat, unrestricted conversation",
category: "Uncensored / Creative Writing",
},
];
const selectedModel = ref("");
const modelTab = ref<"installed" | "available">("installed");
// Base URL setting (admin only)
const baseUrl = ref("");
const savingBaseUrl = ref(false);
const baseUrlSaved = ref(false);
const modelStatuses = computed(() => {
const installed = new Set(
@@ -189,6 +196,10 @@ const modelStatuses = computed(() => {
}));
});
const installedModels = computed(() =>
modelStatuses.value.filter((m) => m.installed)
);
const categories = computed(() => {
const cats: string[] = [];
for (const m of modelStatuses.value) {
@@ -224,7 +235,7 @@ onMounted(async () => {
notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false";
}
// Load SMTP config if admin
// Load admin settings
if (authStore.isAdmin) {
try {
const smtpConfig = await apiGet<Record<string, string>>("/api/admin/smtp");
@@ -232,6 +243,12 @@ onMounted(async () => {
} catch {
// SMTP not configured yet
}
try {
const urlConfig = await apiGet<{ base_url: string }>("/api/admin/base-url");
baseUrl.value = urlConfig.base_url;
} catch {
// base URL not configured yet
}
}
});
@@ -411,6 +428,20 @@ async function sendTestEmail() {
}
}
async function saveBaseUrl() {
savingBaseUrl.value = true;
baseUrlSaved.value = false;
try {
await apiPut("/api/admin/base-url", { base_url: baseUrl.value.trim() });
baseUrlSaved.value = true;
setTimeout(() => (baseUrlSaved.value = false), 2000);
} catch {
toastStore.show("Failed to save application URL", "error");
} finally {
savingBaseUrl.value = false;
}
}
async function handleRestoreFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
@@ -545,6 +576,29 @@ async function handleRestoreFile(event: Event) {
</div>
</section>
<section v-if="authStore.isAdmin" class="settings-section">
<h2>Application URL</h2>
<p class="section-desc">
The public URL used in email links (invitations, password resets). Example: https://notes.example.com
</p>
<div class="field">
<label for="base-url">Base URL</label>
<input
id="base-url"
v-model="baseUrl"
type="url"
placeholder="https://notes.example.com"
class="input"
/>
</div>
<div class="actions">
<button class="btn-save" @click="saveBaseUrl" :disabled="savingBaseUrl">
{{ savingBaseUrl ? "Saving..." : "Save" }}
</button>
<span v-if="baseUrlSaved" class="saved-msg">Saved!</span>
</div>
</section>
<section v-if="authStore.isAdmin" class="settings-section">
<h2>Email / SMTP</h2>
<p class="section-desc">
@@ -612,15 +666,35 @@ async function handleRestoreFile(event: Event) {
<section class="settings-section">
<h2>Model</h2>
<p class="section-desc">
Choose which LLM model to use for chat. Models need to be downloaded before use.
</p>
<div v-for="cat in categories" :key="cat" class="model-category">
<h3 class="category-label">{{ cat }}</h3>
<div class="model-list">
<div class="model-tabs">
<button
class="model-tab"
:class="{ active: modelTab === 'installed' }"
@click="modelTab = 'installed'"
>
Installed
</button>
<button
class="model-tab"
:class="{ active: modelTab === 'available' }"
@click="modelTab = 'available'"
>
Available
</button>
</div>
<!-- Installed tab -->
<template v-if="modelTab === 'installed'">
<p class="section-desc">
Models currently downloaded and ready to use.
</p>
<div v-if="installedModels.length === 0" class="empty-state">
No models installed. Browse the Available tab to download one.
</div>
<div v-else class="model-list">
<div
v-for="model in modelsInCategory(cat)"
v-for="model in installedModels"
:key="model.name"
class="model-card"
:class="{ active: model.active }"
@@ -630,56 +704,84 @@ async function handleRestoreFile(event: Event) {
<span class="model-name">{{ model.name }}</span>
<span class="model-size">{{ model.size }}</span>
</div>
<p class="model-desc">{{ model.description }}</p>
<p class="model-best-for">
<strong>Best for:</strong> {{ model.bestFor }}
</p>
</div>
<div class="model-actions">
<template v-if="model.installed">
<button
v-if="!model.active"
class="btn-select"
@click="selectModel(model.name)"
:disabled="saving"
>
Select
</button>
<span v-else class="active-badge">Active</span>
<template v-if="confirmDelete === model.name">
<button
v-if="!model.active"
class="btn-select"
@click="selectModel(model.name)"
:disabled="saving"
>
Select
</button>
<span v-else class="active-badge">Active</span>
<template v-if="confirmDelete === model.name">
<button
class="btn-confirm-delete"
@click="removeModel(model.name)"
:disabled="deleting !== null"
>
{{ deleting === model.name ? "Removing..." : "Confirm" }}
</button>
<button class="btn-cancel-delete" @click="cancelDelete">
Cancel
</button>
</template>
<button
v-else
class="btn-remove"
class="btn-confirm-delete"
@click="removeModel(model.name)"
:disabled="deleting !== null || model.active"
:title="model.active ? 'Cannot remove the active model' : 'Remove model'"
:disabled="deleting !== null"
>
Remove
{{ deleting === model.name ? "Removing..." : "Confirm" }}
</button>
<button class="btn-cancel-delete" @click="cancelDelete">
Cancel
</button>
</template>
<button
v-else
class="btn-pull"
@click="pullModel(model.name)"
:disabled="pullProgress !== null"
class="btn-remove"
@click="removeModel(model.name)"
:disabled="deleting !== null || model.active"
:title="model.active ? 'Cannot remove the active model' : 'Remove model'"
>
{{ pullProgress?.model === model.name ? `Downloading ${pullProgress.percent}%` : "Download" }}
Remove
</button>
</div>
</div>
</div>
</div>
</template>
<!-- Available tab -->
<template v-if="modelTab === 'available'">
<p class="section-desc">
Browse and download models. Models need to be downloaded before use.
</p>
<div v-for="cat in categories" :key="cat" class="model-category">
<h3 class="category-label">{{ cat }}</h3>
<div class="model-list">
<div
v-for="model in modelsInCategory(cat)"
:key="model.name"
class="model-card"
:class="{ active: model.active }"
>
<div class="model-info">
<div class="model-name-row">
<span class="model-name">{{ model.name }}</span>
<span class="model-size">{{ model.size }}</span>
</div>
<p class="model-desc">{{ model.description }}</p>
<p class="model-best-for">
<strong>Best for:</strong> {{ model.bestFor }}
</p>
</div>
<div class="model-actions">
<template v-if="model.installed">
<span class="installed-badge">Installed</span>
</template>
<button
v-else
class="btn-pull"
@click="pullModel(model.name)"
:disabled="pullProgress !== null"
>
{{ pullProgress?.model === model.name ? `Downloading ${pullProgress.percent}%` : "Download" }}
</button>
</div>
</div>
</div>
</div>
</template>
</section>
<section class="settings-section">
@@ -912,6 +1014,53 @@ async function handleRestoreFile(event: Event) {
font-weight: 600;
}
/* Model tabs */
.model-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.model-tab {
padding: 0.4rem 1rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
}
.model-tab.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.model-tab:hover:not(.active) {
border-color: var(--color-text-muted);
color: var(--color-text);
}
/* Installed badge on available tab */
.installed-badge {
padding: 0.25rem 0.75rem;
background: var(--color-success);
color: #fff;
border-radius: var(--radius-lg);
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
}
/* Empty state */
.empty-state {
padding: 2rem 1rem;
text-align: center;
color: var(--color-text-muted);
font-size: 0.9rem;
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
}
/* Category headers */
.model-category {
margin-bottom: 1.5rem;
+94 -70
View File
@@ -11,6 +11,7 @@ import type { TaskStatus } from "@/types/task";
import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import TagPill from "@/components/TagPill.vue";
import TableOfContents from "@/components/TableOfContents.vue";
const route = useRoute();
const router = useRouter();
@@ -118,84 +119,107 @@ function onTagClick(tag: string) {
</script>
<template>
<main class="viewer">
<p v-if="store.loading">Loading...</p>
<template v-else-if="store.currentTask">
<div class="toolbar">
<router-link to="/tasks" class="btn-back">Back</router-link>
<router-link
:to="`/tasks/${store.currentTask.id}/edit`"
class="btn-edit"
>
Edit
</router-link>
<button
class="btn-convert"
@click="convertToNote"
:disabled="converting"
>
{{ converting ? "Converting..." : "Convert to Note" }}
</button>
</div>
<h1>{{ store.currentTask.title || "Untitled" }}</h1>
<p class="meta">
Updated {{ relativeTime(store.currentTask.updated_at) }}
&middot;
Created {{ relativeTime(store.currentTask.created_at) }}
</p>
<div class="badges">
<StatusBadge
:status="store.currentTask.status!"
clickable
@click="cycleStatus"
/>
<PriorityBadge :priority="store.currentTask.priority!" />
<span
v-if="store.currentTask.due_date"
:class="['due-date', { overdue: isOverdue() }]"
>
Due: {{ store.currentTask.due_date }}
</span>
</div>
<div class="tags" v-if="store.currentTask.tags.length">
<TagPill
v-for="tag in store.currentTask.tags"
:key="tag"
:tag="tag"
@click="onTagClick"
/>
</div>
<div
class="body prose"
v-html="renderedBody"
@click="onBodyClick"
></div>
<div class="viewer-layout">
<main class="viewer">
<p v-if="store.loading">Loading...</p>
<template v-else-if="store.currentTask">
<div class="toolbar">
<router-link to="/tasks" class="btn-back">Back</router-link>
<router-link
:to="`/tasks/${store.currentTask.id}/edit`"
class="btn-edit"
>
Edit
</router-link>
<button
class="btn-convert"
@click="convertToNote"
:disabled="converting"
>
{{ converting ? "Converting..." : "Convert to Note" }}
</button>
</div>
<h1>{{ store.currentTask.title || "Untitled" }}</h1>
<p class="meta">
Updated {{ relativeTime(store.currentTask.updated_at) }}
&middot;
Created {{ relativeTime(store.currentTask.created_at) }}
</p>
<div class="badges">
<StatusBadge
:status="store.currentTask.status!"
clickable
@click="cycleStatus"
/>
<PriorityBadge :priority="store.currentTask.priority!" />
<span
v-if="store.currentTask.due_date"
:class="['due-date', { overdue: isOverdue() }]"
>
Due: {{ store.currentTask.due_date }}
</span>
</div>
<div class="tags" v-if="store.currentTask.tags.length">
<TagPill
v-for="tag in store.currentTask.tags"
:key="tag"
:tag="tag"
@click="onTagClick"
/>
</div>
<div
class="body prose"
v-html="renderedBody"
@click="onBodyClick"
></div>
<div v-if="backlinks.length" class="backlinks">
<h2>Backlinks</h2>
<ul class="backlinks-list">
<li v-for="link in backlinks" :key="`${link.type}-${link.id}`" class="backlink-item">
<span class="backlink-type">{{ link.type }}</span>
<router-link
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
class="backlink-link"
>
{{ link.title || "Untitled" }}
</router-link>
</li>
</ul>
</div>
</template>
<p v-else>Task not found.</p>
</main>
<div v-if="backlinks.length" class="backlinks">
<h2>Backlinks</h2>
<ul class="backlinks-list">
<li v-for="link in backlinks" :key="`${link.type}-${link.id}`" class="backlink-item">
<span class="backlink-type">{{ link.type }}</span>
<router-link
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
class="backlink-link"
>
{{ link.title || "Untitled" }}
</router-link>
</li>
</ul>
</div>
</template>
<p v-else>Task not found.</p>
</main>
<TableOfContents
v-if="store.currentTask?.body"
:body="store.currentTask.body"
class="toc-sidebar"
/>
</div>
</template>
<style scoped>
.viewer-layout {
display: flex;
max-width: 1200px;
margin: 0 auto;
gap: 2rem;
}
.viewer {
flex: 1;
min-width: 0;
max-width: 960px;
margin: 2rem auto;
margin: 2rem 0;
padding: 0 1rem;
}
.toc-sidebar {
margin-top: 2rem;
}
@media (max-width: 1200px) {
.toc-sidebar {
display: none;
}
}
.toolbar {
display: flex;
gap: 0.75rem;
+159 -2
View File
@@ -1,10 +1,17 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { apiGet, apiPut, apiDelete } from "@/api/client";
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import type { User } from "@/types/auth";
interface Invitation {
id: number;
email: string;
created_at: string;
expires_at: string;
}
const authStore = useAuthStore();
const toastStore = useToastStore();
@@ -15,8 +22,13 @@ const toggling = ref(false);
const confirmDeleteId = ref<number | null>(null);
const deleting = ref<number | null>(null);
const inviteEmail = ref("");
const sendingInvite = ref(false);
const invitations = ref<Invitation[]>([]);
const revokingId = ref<number | null>(null);
onMounted(async () => {
await Promise.all([fetchUsers(), fetchRegistration()]);
await Promise.all([fetchUsers(), fetchRegistration(), fetchInvitations()]);
loading.value = false;
});
@@ -38,6 +50,49 @@ async function fetchRegistration() {
}
}
async function fetchInvitations() {
try {
const data = await apiGet<{ invitations: Invitation[] }>("/api/admin/invitations");
invitations.value = data.invitations;
} catch {
// Ignore
}
}
async function sendInvite() {
const email = inviteEmail.value.trim().toLowerCase();
if (!email) return;
sendingInvite.value = true;
try {
await apiPost("/api/admin/invitations", { email });
toastStore.show(`Invitation sent to ${email}`);
inviteEmail.value = "";
await fetchInvitations();
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to send invitation", "error");
} else {
toastStore.show("Failed to send invitation", "error");
}
} finally {
sendingInvite.value = false;
}
}
async function revokeInvitation(id: number) {
revokingId.value = id;
try {
await apiDelete(`/api/admin/invitations/${id}`);
invitations.value = invitations.value.filter((inv) => inv.id !== id);
toastStore.show("Invitation revoked");
} catch {
toastStore.show("Failed to revoke invitation", "error");
} finally {
revokingId.value = null;
}
}
async function toggleRegistration() {
toggling.value = true;
try {
@@ -122,6 +177,58 @@ function formatDate(iso: string): string {
</div>
</section>
<section class="settings-section">
<h2>Invite User</h2>
<form class="invite-form" @submit.prevent="sendInvite">
<input
v-model="inviteEmail"
type="email"
placeholder="Email address"
class="input invite-input"
required
:disabled="sendingInvite"
/>
<button
type="submit"
class="btn-invite"
:disabled="sendingInvite || !inviteEmail.trim()"
>
{{ sendingInvite ? "Sending..." : "Send Invite" }}
</button>
</form>
<p class="field-hint">Send an invitation link to allow someone to register, even when public registration is closed.</p>
<div v-if="invitations.length > 0" class="invite-list">
<h3>Pending Invitations</h3>
<table class="users-table">
<thead>
<tr>
<th>Email</th>
<th class="hide-mobile">Sent</th>
<th class="hide-mobile">Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="inv in invitations" :key="inv.id">
<td class="cell-email">{{ inv.email }}</td>
<td class="hide-mobile cell-date">{{ formatDate(inv.created_at) }}</td>
<td class="hide-mobile cell-date">{{ formatDate(inv.expires_at) }}</td>
<td class="cell-actions">
<button
class="btn-delete"
@click="revokeInvitation(inv.id)"
:disabled="revokingId !== null"
>
{{ revokingId === inv.id ? "Revoking..." : "Revoke" }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="settings-section">
<h2>Users</h2>
@@ -201,6 +308,53 @@ function formatDate(iso: string): string {
font-size: 1.1rem;
}
/* Invite form */
.invite-form {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.invite-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.95rem;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.invite-input:focus {
outline: none;
border-color: var(--color-primary);
}
.btn-invite {
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
}
.btn-invite:disabled {
opacity: 0.6;
cursor: default;
}
.btn-invite:hover:not(:disabled) {
opacity: 0.9;
}
.invite-list {
margin-top: 1rem;
}
.invite-list h3 {
margin: 0 0 0.5rem;
font-size: 0.95rem;
color: var(--color-text-secondary);
}
/* Registration toggle */
.registration-row {
display: flex;
@@ -382,5 +536,8 @@ function formatDate(iso: string): string {
.btn-toggle {
width: 100%;
}
.invite-form {
flex-direction: column;
}
}
</style>
+1
View File
@@ -17,3 +17,4 @@ from fabledassistant.models.setting import Setting # noqa: E402, F401
from fabledassistant.models.user import User # noqa: E402, F401
from fabledassistant.models.app_log import AppLog # noqa: E402, F401
from fabledassistant.models.password_reset import PasswordResetToken # noqa: E402, F401
from fabledassistant.models.invitation import InvitationToken # noqa: E402, F401
+25
View File
@@ -0,0 +1,25 @@
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class InvitationToken(Base):
__tablename__ = "invitation_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(Text, nullable=False)
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
invited_by: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__table_args__ = (
Index("ix_invitation_tokens_token_hash", "token_hash"),
Index("ix_invitation_tokens_email", "email"),
)
+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,
+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
+14
View File
@@ -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()
+9
View File
@@ -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():
+69 -18
View File
@@ -12,7 +12,7 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
2026-02-13 — Phase 5.6: Assist Background-Task + Buffer Architecture
2026-02-13 — Phase 5.9: Invitation system, table of contents, actionable dashboard, settings improvements
## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with
@@ -190,6 +190,15 @@ for AI-assisted features.
- Indexes: `category`, `user_id`, `created_at`, composite `(category, created_at DESC)`
- Automatic retention cleanup via hourly asyncio task (configurable `LOG_RETENTION_DAYS`, default 90)
### Invitation Tokens
- `id` (int PK), `email` (text NOT NULL), `token_hash` (text NOT NULL UNIQUE),
`invited_by` (int FK users CASCADE), `expires_at` (timestamptz NOT NULL),
`used` (boolean NOT NULL DEFAULT FALSE), `created_at` (timestamptz)
- Admin creates invitation → raw token emailed → recipient registers via `/register-invite?token=...`
- Token stored as SHA256 hash (same pattern as password reset)
- 7-day expiration, one-time use, previous unused invitations for same email auto-revoked
- Indexes: `token_hash`, `email`
### Messages
- `id` (int PK), `conversation_id` (FK to conversations, CASCADE), `role` (str: system/user/assistant),
`content` (str), `status` (str, default `'complete'``complete`/`generating`/`error`),
@@ -219,7 +228,9 @@ fabledassistant/
│ ├── 0007_add_title_and_updated_at_indexes.py # B-tree indexes on notes.title and conversations.updated_at
│ ├── 0008_add_users_and_user_id.py # Users table, user_id FKs on notes/conversations/settings, composite PK for settings
│ ├── 0009_add_message_status.py # Add status column to messages table (default 'complete')
── 0010_add_app_logs_table.py # App logs table for audit, usage, and error logging
── 0010_add_app_logs_table.py # App logs table for audit, usage, and error logging
│ ├── 0011_add_password_reset_tokens.py # Password reset tokens table
│ └── 0012_add_invitation_tokens.py # Invitation tokens table
├── src/
│ └── fabledassistant/
│ ├── __init__.py
@@ -232,18 +243,19 @@ fabledassistant/
│ │ ├── note.py # Note model (unified: id, title, body, tags[], parent_id, user_id, status, priority, due_date, timestamps)
│ │ ├── conversation.py # Conversation + Message models with user_id
│ │ ├── setting.py # Setting model (composite PK: user_id + key, value TEXT)
│ │ ── app_log.py # AppLog model (id, category, user_id, username, action, endpoint, method, status_code, duration_ms, ip_address, details, created_at)
│ │ ── app_log.py # AppLog model (id, category, user_id, username, action, endpoint, method, status_code, duration_ms, ip_address, details, created_at)
│ │ └── invitation.py # InvitationToken model (id, email, token_hash, invited_by, expires_at, used, created_at)
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── api.py # /api blueprint with /health endpoint (public)
│ │ ├── auth.py # /api/auth blueprint: register, login, logout, me, status
│ │ ├── admin.py # /api/admin blueprint: backup, restore, user management, registration toggle (admin only)
│ │ ├── auth.py # /api/auth blueprint: register, login, logout, me, status, password reset, invitation registration
│ │ ├── admin.py # /api/admin blueprint: backup, restore, user management, registration toggle, invitations, base URL, SMTP (admin only)
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming (all @login_required)
│ │ ├── notes.py # /api/notes CRUD + wikilinks + backlinks (all @login_required)
│ │ ├── tasks.py # /api/tasks CRUD + PATCH status (all @login_required)
│ │ └── settings.py # /api/settings GET/PUT — per-user settings (@login_required)
│ ├── services/
│ │ ├── auth.py # Auth business logic: hash_password, verify_password, create_user, authenticate, get_user_by_id, is_registration_open, list_users, delete_user, set_registration_open
│ │ ├── auth.py # Auth business logic: hash_password, verify_password, create_user, authenticate, get_user_by_id, is_registration_open, list_users, delete_user, set_registration_open, password reset tokens, invitation tokens (create, validate, register_with_invitation, list_pending, revoke)
│ │ ├── backup.py # Backup/restore: export_full_backup, export_user_backup, restore_full_backup
│ │ ├── notes.py # CRUD with user_id isolation, is_task filter, convert, backlinks, search_notes_for_context
│ │ ├── llm.py # Ollama interaction: build_context with user_id, streaming, URL fetching
@@ -299,16 +311,17 @@ fabledassistant/
│ ├── views/
│ │ ├── LoginView.vue # Login form with error display, link to register
│ │ ├── RegisterView.vue # Register form with password confirmation; shows "closed" message when registration disabled
│ │ ├── UserManagementView.vue # Admin user management: registration toggle, user list with delete
│ │ ├── RegisterInviteView.vue # Invitation-based registration: validates token, creates account with pre-set email
│ │ ├── UserManagementView.vue # Admin user management: registration toggle, invitations (send/revoke), user list with delete
│ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, context pills
│ │ ├── HomeView.vue # Landing page: recent notes + tasks + recent chats
│ │ ├── HomeView.vue # Actionable dashboard: overdue tasks, due today, in progress, recent chats, recently edited notes
│ │ ├── SettingsView.vue # Settings page: assistant name, model catalog, data export/restore (admin)
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
│ │ ├── NoteEditorView.vue # Create/edit: Tiptap editor, sticky toolbar, AI Assist panel (bottom 1/3), Ctrl+S, unsaved guard
│ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task, backlinks
│ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task, backlinks, table of contents sidebar
│ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination
│ │ ├── TaskEditorView.vue # Create/edit task: Tiptap editor, sticky toolbar, AI Assist panel, Ctrl+S, dirty guard
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note, backlinks
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note, backlinks, table of contents sidebar
│ ├── components/
│ │ ├── LogsView.vue # Admin log viewer: stats summary, category/search/date filters, paginated table with expandable detail rows
│ │ ├── AppHeader.vue # Nav bar: brand, nav links (incl. admin Logs), status indicator, theme toggle, user info + logout, hamburger menu (mobile)
@@ -324,9 +337,10 @@ fabledassistant/
│ │ ├── SearchBar.vue # Debounced search input
│ │ ├── TagPill.vue # Clickable/dismissible tag pill
│ │ ├── PaginationBar.vue # Prev/next + page numbers
│ │ ├── TableOfContents.vue # Sticky sidebar TOC: parses markdown headings, smooth-scroll on click, hidden ≤1200px
│ │ └── ToastNotification.vue # Fixed-position toast container with close button, warning support
│ └── router/
│ └── index.ts # Routes: /, /login, /register, /notes/*, /tasks/*, /chat, /chat/:id, /settings, /admin/users, /admin/logs; beforeEach auth guard
│ └── index.ts # Routes: /, /login, /register, /register-invite, /notes/*, /tasks/*, /chat, /chat/:id, /settings, /admin/users, /admin/logs; beforeEach auth guard
└── public/
```
@@ -362,7 +376,14 @@ fabledassistant/
| GET | `/api/notes/:id/backlinks` | List notes/tasks that reference this note via wikilinks |
| POST | `/api/notes/assist` | Launch background assist generation, return 202 (body: `{body, target_section, instruction}`; 409 if already running) |
| GET | `/api/notes/assist/stream` | SSE endpoint tailing assist generation buffer; supports `Last-Event-ID` reconnection; emits `chunk`, `done`, `error` events with 15s keepalives |
| GET | `/api/tasks` | List tasks (params: `q`, `tag`, `status`, `priority`, `sort`, `order`, `limit`, `offset`) — queries notes where `status IS NOT NULL` |
| GET | `/api/auth/invitation/:token` | Validate invitation token, returns `{valid, email?}` (public) |
| POST | `/api/auth/register-with-invite` | Register with invitation token (body: `{token, username, password}`) (public) |
| GET | `/api/admin/base-url` | Get application base URL setting (admin only) |
| PUT | `/api/admin/base-url` | Set application base URL (admin only, body: `{base_url}`) |
| POST | `/api/admin/invitations` | Create invitation (admin only, body: `{email}`) — sends email with registration link |
| GET | `/api/admin/invitations` | List pending invitations (admin only) |
| DELETE | `/api/admin/invitations/:id` | Revoke invitation (admin only) |
| GET | `/api/tasks` | List tasks (params: `q`, `tag`, `status`, `priority`, `due_before`, `due_after`, `sort`, `order`, `limit`, `offset`) — queries notes where `status IS NOT NULL` |
| POST | `/api/tasks` | Create task (body: `{title, body, status?, priority?, due_date?}` — accepts `description` as fallback for `body`) |
| GET | `/api/tasks/:id` | Get single task |
| PUT | `/api/tasks/:id` | Update task (accepts `body` or `description`, prefers `body`) |
@@ -397,7 +418,7 @@ container startup.
### Migration Chain
```
0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py → 0004_merge_tasks_into_notes.py → 0005_add_chat_tables.py → 0006_add_settings_table.py → 0007_add_title_and_updated_at_indexes.py → 0008_add_users_and_user_id.py → 0009_add_message_status.py → 0010_add_app_logs_table.py
0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py → 0004_merge_tasks_into_notes.py → 0005_add_chat_tables.py → 0006_add_settings_table.py → 0007_add_title_and_updated_at_indexes.py → 0008_add_users_and_user_id.py → 0009_add_message_status.py → 0010_add_app_logs_table.py → 0011_add_password_reset_tokens.py → 0012_add_invitation_tokens.py
```
### How Migrations Run
@@ -665,6 +686,31 @@ When adding a new migration, follow these conventions:
- [x] **Frontend two-step pattern:** `useAssist.ts` calls `apiPost()` then `apiSSEStream()` with named event mapping (`chunk`/`done`/`error`); tracks stream handle for cleanup
- [x] **Fixes `NS_ERROR_NET_PARTIAL_TRANSFER`:** Keepalive pings prevent browser/Hypercorn from closing connection during gaps between LLM chunks
### Phase 5.7 — Invitation System ✓
- [x] **Invitation tokens table:** `invitation_tokens` (migration 0012) — SHA256-hashed tokens, 7-day expiry, one-time use
- [x] **Admin invitation management:** `POST/GET/DELETE /api/admin/invitations` for create/list/revoke
- [x] **Invitation email:** Branded HTML email with accept link, sent via fire-and-forget asyncio task
- [x] **Invitation registration flow:** `GET /api/auth/invitation/:token` validates, `POST /api/auth/register-with-invite` creates account
- [x] **Frontend:** `/register-invite` view with token validation, email pre-fill, username/password form
- [x] **Admin UI:** Invite User section in UserManagementView with email input, pending invitations table, revoke action
- [x] **Base URL admin setting:** Configurable via Settings UI; used in invitation and password reset email links (replaces `Config.BASE_URL` hardcoded fallback)
### Phase 5.8 — Table of Contents + Markdown Improvements ✓
- [x] **TableOfContents component:** Sticky sidebar on note/task viewer — parses markdown headings, slugified IDs, smooth-scroll click, hidden on screens ≤1200px
- [x] **Heading IDs in rendered markdown:** Custom `marked` renderer adds `id` attributes to headings for TOC anchor links
- [x] **First-line tag stripping:** `stripFirstLineTags()` removes leading `#tag` lines from markdown before rendering (prevents tags from rendering as headings)
- [x] **Viewer layout:** NoteViewerView and TaskViewerView use flex layout with main content + TOC sidebar (max-width 1200px)
- [x] **Model catalog refresh:** Updated to current models (llama3.2/3.3, gemma3, qwen3, phi4, deepseek-r1, qwen2.5-coder, dolphin3), added Reasoning category, removed discontinued models
- [x] **Settings model tabs:** Split model section into "Installed" and "Available" tabs
### Phase 5.9 — Actionable "Today" Dashboard ✓
- [x] **Due date filtering API:** Added `due_before` (exclusive `<`) and `due_after` (inclusive `>=`) params to `list_notes()` and `/api/tasks` endpoint
- [x] **Dashboard rewrite:** HomeView now shows Overdue (red left-border accent), Due Today, In Progress, Recent Chats, Recently Edited sections
- [x] **Parallel fetching:** 5 API calls via `Promise.allSettled` — notes, overdue tasks, due-today tasks, in-progress tasks, chats
- [x] **Client-side filtering:** Filters out `done` tasks from overdue/due-today, deduplicates in-progress against other lists
- [x] **Task sections hidden when empty:** Only shown if there are matching tasks
- [x] **Status toggle:** Marking a task `done` removes it from all dashboard lists
### Future / Stretch
- Tagging/labeling system with LLM-suggested tags
- Calendar/timeline view for tasks
@@ -680,7 +726,7 @@ When adding a new migration, follow these conventions:
- To reset database: `docker compose down -v && docker compose up --build`
## Current Status
**Phase:** Phase 5.6 complete. Assist Background-Task + Buffer Architecture.
**Phase:** Phase 5.9 complete. Invitation system, table of contents, actionable dashboard, settings improvements.
- Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags
- **Tiptap WYSIWYG editor** with inline formatting preview, markdown round-trip, paste handling
- **Tag/wikilink autocomplete** via `@tiptap/suggestion` with heading disambiguation
@@ -693,17 +739,22 @@ When adding a new migration, follow these conventions:
- Context pills with promote/exclude controls; note picker in chat input
- Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations as notes
- Dedicated `/chat` page with responsive sidebar (overlay on mobile)
- Settings page: assistant name, model catalog, password change, data export/restore (admin)
- **Registration control**: auto-closes after first user, admin toggle, password confirmation
- **Admin user management**: `/admin/users` with user list + delete
- Settings page: assistant name, model catalog (installed/available tabs), password change, data export/restore (admin)
- **Invitation system**: admin sends email invitations, recipients register via token-based `/register-invite` flow
- **Registration control**: auto-closes after first user, admin toggle, invitation-based registration
- **Admin user management**: `/admin/users` with user list, delete, invite, revoke
- **Session cookie hardening**: HttpOnly, SameSite=Lax, optional Secure flag
- **Actionable dashboard**: HomeView shows overdue/due-today/in-progress task sections (hidden when empty), recent chats, recently edited notes
- **Table of contents**: Sticky sidebar on note/task viewers, auto-generated from markdown headings
- **App-wide layout fix**: navbar always visible, all views fit within viewport
- **Responsive design**: hamburger menu, mobile touch targets, responsive breakpoints
- **Docker Swarm production stack** with secrets, network isolation, health checks, resource limits
- **Application logging**: audit (security events), usage (API requests), error (unhandled exceptions)
- **Admin log viewer**: `/admin/logs` with stats, filters, pagination, expandable detail rows
- **Automatic log retention**: configurable via `LOG_RETENTION_DAYS` (default 90 days)
- **SMTP email notifications**: security alerts (login/logout/failed login/password change), task due date reminders
- **SMTP email notifications**: security alerts (login/logout/failed login/password change), task due date reminders, invitation emails
- **Admin SMTP configuration**: Settings UI with test email, DB-stored config with env var fallbacks
- **Admin base URL setting**: Configurable public URL used in email links
- **Per-user notification preferences**: toggle task reminders and security alerts independently
- **Password reset flow**: Email-based with SHA256-hashed tokens, 1-hour expiry
- Dark/light theme with CSS custom properties and design tokens