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:
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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) }}
|
||||
·
|
||||
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) }}
|
||||
·
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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) }}
|
||||
·
|
||||
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) }}
|
||||
·
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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"})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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():
|
||||
|
||||
+69
-18
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user