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"),
|
component: () => import("@/views/ResetPasswordView.vue"),
|
||||||
meta: { public: true },
|
meta: { public: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/register-invite",
|
||||||
|
name: "register-invite",
|
||||||
|
component: () => import("@/views/RegisterInviteView.vue"),
|
||||||
|
meta: { public: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/notes",
|
path: "/notes",
|
||||||
name: "notes",
|
name: "notes",
|
||||||
|
|||||||
@@ -8,8 +8,37 @@ function decodeEntities(text: string): string {
|
|||||||
return textarea.value;
|
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 {
|
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 html = marked(decoded) as string;
|
||||||
const withTags = linkifyTags(html);
|
const withTags = linkifyTags(html);
|
||||||
const withLinks = linkifyWikilinks(withTags);
|
const withLinks = linkifyWikilinks(withTags);
|
||||||
@@ -21,7 +50,8 @@ export function renderMarkdown(text: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderPreview(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 html = marked(decoded) as string;
|
||||||
const withTags = linkifyTags(html);
|
const withTags = linkifyTags(html);
|
||||||
const withLinks = linkifyWikilinks(withTags);
|
const withLinks = linkifyWikilinks(withTags);
|
||||||
|
|||||||
+122
-43
@@ -13,47 +13,96 @@ import { useChatStore } from "@/stores/chat";
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const recentNotes = ref<Note[]>([]);
|
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 recentChats = ref<Conversation[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const tasksStore = useTasksStore();
|
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 () => {
|
onMounted(async () => {
|
||||||
try {
|
const today = todayStr();
|
||||||
const notesData = await apiGet<NoteListResponse>(
|
const tomorrow = tomorrowStr();
|
||||||
"/api/notes?sort=updated_at&order=desc&limit=5"
|
|
||||||
);
|
const [notesRes, overdueRes, dueTodayRes, inProgressRes, chatsRes] =
|
||||||
recentNotes.value = notesData.notes;
|
await Promise.allSettled([
|
||||||
} catch (e) {
|
apiGet<NoteListResponse>("/api/notes?sort=updated_at&order=desc&limit=5"),
|
||||||
console.error("Failed to load recent notes:", e);
|
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 {
|
// Filter out done tasks from overdue and due-today
|
||||||
const tasksData = await apiGet<TaskListResponse>(
|
if (overdueRes.status === "fulfilled") {
|
||||||
"/api/tasks?sort=updated_at&order=desc&limit=5"
|
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 {
|
// Deduplicate in-progress against overdue and due-today
|
||||||
const chatData = await apiGet<{ conversations: Conversation[]; total: number }>(
|
if (inProgressRes.status === "fulfilled") {
|
||||||
"/api/chat/conversations?limit=3&offset=0"
|
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;
|
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) {
|
function onStatusToggle(id: number, status: TaskStatus) {
|
||||||
tasksStore.patchStatus(id, status).then((updated) => {
|
tasksStore.patchStatus(id, status).then((updated) => {
|
||||||
const idx = recentTasks.value.findIndex((t) => t.id === updated.id);
|
if (updated.status === "done") {
|
||||||
if (idx !== -1) {
|
removeFromAllLists(updated.id);
|
||||||
recentTasks.value[idx] = updated;
|
} 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>
|
<p v-if="loading" class="loading">Loading...</p>
|
||||||
|
|
||||||
<template v-else>
|
<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">
|
<section class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Recent Chats</h2>
|
<h2>Recent Chats</h2>
|
||||||
@@ -97,7 +191,7 @@ async function newChat() {
|
|||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Recent Notes</h2>
|
<h2>Recently Edited</h2>
|
||||||
<router-link to="/notes" class="see-all">See all</router-link>
|
<router-link to="/notes" class="see-all">See all</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="recentNotes.length" class="cards">
|
<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>
|
<router-link to="/notes/new" class="btn-cta">+ New Note</router-link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
@@ -150,6 +225,10 @@ async function newChat() {
|
|||||||
.section {
|
.section {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
.section-overdue {
|
||||||
|
border-left: 3px solid var(--color-overdue, #e74c3c);
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
}
|
||||||
.section-header {
|
.section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { relativeTime } from "@/composables/useRelativeTime";
|
|||||||
import { apiPost } from "@/api/client";
|
import { apiPost } from "@/api/client";
|
||||||
import type { Note } from "@/types/note";
|
import type { Note } from "@/types/note";
|
||||||
import TagPill from "@/components/TagPill.vue";
|
import TagPill from "@/components/TagPill.vue";
|
||||||
|
import TableOfContents from "@/components/TableOfContents.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -100,72 +101,95 @@ async function convertToTask() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="viewer">
|
<div class="viewer-layout">
|
||||||
<p v-if="store.loading">Loading...</p>
|
<main class="viewer">
|
||||||
<template v-else-if="store.currentNote">
|
<p v-if="store.loading">Loading...</p>
|
||||||
<div class="toolbar">
|
<template v-else-if="store.currentNote">
|
||||||
<router-link to="/notes" class="btn-back">Back</router-link>
|
<div class="toolbar">
|
||||||
<router-link
|
<router-link to="/notes" class="btn-back">Back</router-link>
|
||||||
:to="`/notes/${store.currentNote.id}/edit`"
|
<router-link
|
||||||
class="btn-edit"
|
:to="`/notes/${store.currentNote.id}/edit`"
|
||||||
>
|
class="btn-edit"
|
||||||
Edit
|
>
|
||||||
</router-link>
|
Edit
|
||||||
<button
|
</router-link>
|
||||||
v-if="!store.currentNote.is_task"
|
<button
|
||||||
class="btn-convert"
|
v-if="!store.currentNote.is_task"
|
||||||
@click="convertToTask"
|
class="btn-convert"
|
||||||
:disabled="converting"
|
@click="convertToTask"
|
||||||
>
|
:disabled="converting"
|
||||||
{{ converting ? "Converting..." : "Convert to Task" }}
|
>
|
||||||
</button>
|
{{ converting ? "Converting..." : "Convert to Task" }}
|
||||||
</div>
|
</button>
|
||||||
<h1>{{ store.currentNote.title || "Untitled" }}</h1>
|
</div>
|
||||||
<p class="meta">
|
<h1>{{ store.currentNote.title || "Untitled" }}</h1>
|
||||||
Updated {{ relativeTime(store.currentNote.updated_at) }}
|
<p class="meta">
|
||||||
·
|
Updated {{ relativeTime(store.currentNote.updated_at) }}
|
||||||
Created {{ relativeTime(store.currentNote.created_at) }}
|
·
|
||||||
</p>
|
Created {{ relativeTime(store.currentNote.created_at) }}
|
||||||
<div class="tags" v-if="store.currentNote.tags.length">
|
</p>
|
||||||
<TagPill
|
<div class="tags" v-if="store.currentNote.tags.length">
|
||||||
v-for="tag in store.currentNote.tags"
|
<TagPill
|
||||||
:key="tag"
|
v-for="tag in store.currentNote.tags"
|
||||||
:tag="tag"
|
:key="tag"
|
||||||
@click="onTagClick"
|
:tag="tag"
|
||||||
/>
|
@click="onTagClick"
|
||||||
</div>
|
/>
|
||||||
<div
|
</div>
|
||||||
ref="bodyEl"
|
<div
|
||||||
class="body prose"
|
ref="bodyEl"
|
||||||
v-html="renderedBody"
|
class="body prose"
|
||||||
@click="onBodyClick"
|
v-html="renderedBody"
|
||||||
></div>
|
@click="onBodyClick"
|
||||||
|
></div>
|
||||||
|
|
||||||
<div v-if="backlinks.length" class="backlinks">
|
<div v-if="backlinks.length" class="backlinks">
|
||||||
<h2>Backlinks</h2>
|
<h2>Backlinks</h2>
|
||||||
<ul class="backlinks-list">
|
<ul class="backlinks-list">
|
||||||
<li v-for="link in backlinks" :key="`${link.type}-${link.id}`" class="backlink-item">
|
<li v-for="link in backlinks" :key="`${link.type}-${link.id}`" class="backlink-item">
|
||||||
<span class="backlink-type">{{ link.type }}</span>
|
<span class="backlink-type">{{ link.type }}</span>
|
||||||
<router-link
|
<router-link
|
||||||
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
|
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
|
||||||
class="backlink-link"
|
class="backlink-link"
|
||||||
>
|
>
|
||||||
{{ link.title || "Untitled" }}
|
{{ link.title || "Untitled" }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p v-else>Note not found.</p>
|
<p v-else>Note not found.</p>
|
||||||
</main>
|
</main>
|
||||||
|
<TableOfContents
|
||||||
|
v-if="store.currentNote?.body"
|
||||||
|
:body="store.currentNote.body"
|
||||||
|
class="toc-sidebar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.viewer-layout {
|
||||||
|
display: flex;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
.viewer {
|
.viewer {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
max-width: 960px;
|
max-width: 960px;
|
||||||
margin: 2rem auto;
|
margin: 2rem 0;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
.toc-sidebar {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.toc-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
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[] = [
|
const MODEL_CATALOG: ModelInfo[] = [
|
||||||
// — General Purpose —
|
// — 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",
|
name: "llama3.1",
|
||||||
description: "Meta's Llama 3.1 8B — strong general-purpose model with good instruction following.",
|
description: "Meta's Llama 3.1 8B — strong general-purpose model with good instruction following. 128K context.",
|
||||||
size: "4.7 GB",
|
size: "4.9 GB",
|
||||||
bestFor: "General chat, writing, Q&A",
|
bestFor: "General chat, writing, Q&A",
|
||||||
category: "General Purpose",
|
category: "General Purpose",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "llama3.1:70b",
|
name: "llama3.3",
|
||||||
description: "Meta's Llama 3.1 70B — significantly more capable, better reasoning and nuance.",
|
description: "Meta's Llama 3.3 70B — 405B-level performance in a 70B model. 128K context. Top tier open model.",
|
||||||
size: "40 GB",
|
size: "43 GB",
|
||||||
bestFor: "Complex reasoning, detailed analysis",
|
bestFor: "Complex reasoning, detailed analysis",
|
||||||
category: "General Purpose",
|
category: "General Purpose",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mistral",
|
name: "gemma3",
|
||||||
description: "Mistral 7B — fast and efficient with strong performance for its size.",
|
description: "Google's Gemma 3 — multimodal (vision + text), 1B to 27B. 128K context, 140+ languages.",
|
||||||
size: "4.1 GB",
|
size: "5.2 GB",
|
||||||
bestFor: "Fast responses, general tasks",
|
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",
|
category: "General Purpose",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "qwen2.5",
|
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",
|
size: "4.7 GB",
|
||||||
bestFor: "Multilingual, code, math",
|
bestFor: "Multilingual, code, math",
|
||||||
category: "General Purpose",
|
category: "General Purpose",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "phi3",
|
name: "mistral",
|
||||||
description: "Microsoft Phi-3 Mini — compact model with surprising capability for its size.",
|
description: "Mistral 7B v0.3 — fast and efficient with function calling support. 32K context.",
|
||||||
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.",
|
|
||||||
size: "4.1 GB",
|
size: "4.1 GB",
|
||||||
bestFor: "Natural conversation, general tasks",
|
bestFor: "Fast responses, general tasks",
|
||||||
category: "General Purpose",
|
category: "General Purpose",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "yi",
|
name: "phi4",
|
||||||
description: "01.AI's Yi 6B — Chinese-developed model, more permissive on creative content.",
|
description: "Microsoft Phi-4 14B — strong reasoning and math for its size. Successor to Phi-3.",
|
||||||
size: "3.5 GB",
|
size: "9.1 GB",
|
||||||
bestFor: "Creative content, multilingual",
|
bestFor: "Reasoning, math, structured tasks",
|
||||||
category: "General Purpose",
|
category: "General Purpose",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "command-r",
|
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",
|
size: "20 GB",
|
||||||
bestFor: "RAG, conversation, creative tasks",
|
bestFor: "RAG, conversation, creative tasks",
|
||||||
category: "General Purpose",
|
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 —
|
// — Coding —
|
||||||
{
|
{
|
||||||
name: "codellama",
|
name: "qwen2.5-coder",
|
||||||
description: "Meta's Code Llama — specialized for code generation and understanding.",
|
description: "Alibaba's Qwen 2.5 Coder — 0.5B to 32B. 32B version rivals GPT-4o on coding benchmarks.",
|
||||||
size: "3.8 GB",
|
size: "4.7 GB",
|
||||||
bestFor: "Code generation, debugging, technical docs",
|
bestFor: "Code generation, refactoring, debugging",
|
||||||
category: "Coding",
|
category: "Coding",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "deepseek-coder-v2",
|
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",
|
size: "8.9 GB",
|
||||||
bestFor: "Code, math, technical problem solving",
|
bestFor: "Code, math, technical problem solving",
|
||||||
category: "Coding",
|
category: "Coding",
|
||||||
},
|
},
|
||||||
// — Uncensored / Creative Writing —
|
// — 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",
|
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",
|
size: "4.1 GB",
|
||||||
bestFor: "Uncensored general chat, creative writing",
|
bestFor: "Uncensored general chat, creative writing",
|
||||||
category: "Uncensored / Creative Writing",
|
category: "Uncensored / Creative Writing",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "dolphin-llama3",
|
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",
|
size: "4.7 GB",
|
||||||
bestFor: "Uncensored chat, strong reasoning",
|
bestFor: "Uncensored chat, strong reasoning",
|
||||||
category: "Uncensored / Creative Writing",
|
category: "Uncensored / Creative Writing",
|
||||||
@@ -153,30 +175,15 @@ const MODEL_CATALOG: ModelInfo[] = [
|
|||||||
bestFor: "Helpful assistant, minimal filtering",
|
bestFor: "Helpful assistant, minimal filtering",
|
||||||
category: "Uncensored / Creative Writing",
|
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 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 modelStatuses = computed(() => {
|
||||||
const installed = new Set(
|
const installed = new Set(
|
||||||
@@ -189,6 +196,10 @@ const modelStatuses = computed(() => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const installedModels = computed(() =>
|
||||||
|
modelStatuses.value.filter((m) => m.installed)
|
||||||
|
);
|
||||||
|
|
||||||
const categories = computed(() => {
|
const categories = computed(() => {
|
||||||
const cats: string[] = [];
|
const cats: string[] = [];
|
||||||
for (const m of modelStatuses.value) {
|
for (const m of modelStatuses.value) {
|
||||||
@@ -224,7 +235,7 @@ onMounted(async () => {
|
|||||||
notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false";
|
notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load SMTP config if admin
|
// Load admin settings
|
||||||
if (authStore.isAdmin) {
|
if (authStore.isAdmin) {
|
||||||
try {
|
try {
|
||||||
const smtpConfig = await apiGet<Record<string, string>>("/api/admin/smtp");
|
const smtpConfig = await apiGet<Record<string, string>>("/api/admin/smtp");
|
||||||
@@ -232,6 +243,12 @@ onMounted(async () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// SMTP not configured yet
|
// 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) {
|
async function handleRestoreFile(event: Event) {
|
||||||
const file = (event.target as HTMLInputElement).files?.[0];
|
const file = (event.target as HTMLInputElement).files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -545,6 +576,29 @@ async function handleRestoreFile(event: Event) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section v-if="authStore.isAdmin" class="settings-section">
|
||||||
<h2>Email / SMTP</h2>
|
<h2>Email / SMTP</h2>
|
||||||
<p class="section-desc">
|
<p class="section-desc">
|
||||||
@@ -612,15 +666,35 @@ async function handleRestoreFile(event: Event) {
|
|||||||
|
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2>Model</h2>
|
<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">
|
<div class="model-tabs">
|
||||||
<h3 class="category-label">{{ cat }}</h3>
|
<button
|
||||||
<div class="model-list">
|
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
|
<div
|
||||||
v-for="model in modelsInCategory(cat)"
|
v-for="model in installedModels"
|
||||||
:key="model.name"
|
:key="model.name"
|
||||||
class="model-card"
|
class="model-card"
|
||||||
:class="{ active: model.active }"
|
:class="{ active: model.active }"
|
||||||
@@ -630,56 +704,84 @@ async function handleRestoreFile(event: Event) {
|
|||||||
<span class="model-name">{{ model.name }}</span>
|
<span class="model-name">{{ model.name }}</span>
|
||||||
<span class="model-size">{{ model.size }}</span>
|
<span class="model-size">{{ model.size }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="model-desc">{{ model.description }}</p>
|
|
||||||
<p class="model-best-for">
|
|
||||||
<strong>Best for:</strong> {{ model.bestFor }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="model-actions">
|
<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
|
<button
|
||||||
v-if="!model.active"
|
class="btn-confirm-delete"
|
||||||
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"
|
|
||||||
@click="removeModel(model.name)"
|
@click="removeModel(model.name)"
|
||||||
:disabled="deleting !== null || model.active"
|
:disabled="deleting !== null"
|
||||||
:title="model.active ? 'Cannot remove the active model' : 'Remove model'"
|
|
||||||
>
|
>
|
||||||
Remove
|
{{ deleting === model.name ? "Removing..." : "Confirm" }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-cancel-delete" @click="cancelDelete">
|
||||||
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
class="btn-pull"
|
class="btn-remove"
|
||||||
@click="pullModel(model.name)"
|
@click="removeModel(model.name)"
|
||||||
:disabled="pullProgress !== null"
|
: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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
@@ -912,6 +1014,53 @@ async function handleRestoreFile(event: Event) {
|
|||||||
font-weight: 600;
|
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 */
|
/* Category headers */
|
||||||
.model-category {
|
.model-category {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { TaskStatus } from "@/types/task";
|
|||||||
import StatusBadge from "@/components/StatusBadge.vue";
|
import StatusBadge from "@/components/StatusBadge.vue";
|
||||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||||
import TagPill from "@/components/TagPill.vue";
|
import TagPill from "@/components/TagPill.vue";
|
||||||
|
import TableOfContents from "@/components/TableOfContents.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -118,84 +119,107 @@ function onTagClick(tag: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="viewer">
|
<div class="viewer-layout">
|
||||||
<p v-if="store.loading">Loading...</p>
|
<main class="viewer">
|
||||||
<template v-else-if="store.currentTask">
|
<p v-if="store.loading">Loading...</p>
|
||||||
<div class="toolbar">
|
<template v-else-if="store.currentTask">
|
||||||
<router-link to="/tasks" class="btn-back">Back</router-link>
|
<div class="toolbar">
|
||||||
<router-link
|
<router-link to="/tasks" class="btn-back">Back</router-link>
|
||||||
:to="`/tasks/${store.currentTask.id}/edit`"
|
<router-link
|
||||||
class="btn-edit"
|
:to="`/tasks/${store.currentTask.id}/edit`"
|
||||||
>
|
class="btn-edit"
|
||||||
Edit
|
>
|
||||||
</router-link>
|
Edit
|
||||||
<button
|
</router-link>
|
||||||
class="btn-convert"
|
<button
|
||||||
@click="convertToNote"
|
class="btn-convert"
|
||||||
:disabled="converting"
|
@click="convertToNote"
|
||||||
>
|
:disabled="converting"
|
||||||
{{ converting ? "Converting..." : "Convert to Note" }}
|
>
|
||||||
</button>
|
{{ converting ? "Converting..." : "Convert to Note" }}
|
||||||
</div>
|
</button>
|
||||||
<h1>{{ store.currentTask.title || "Untitled" }}</h1>
|
</div>
|
||||||
<p class="meta">
|
<h1>{{ store.currentTask.title || "Untitled" }}</h1>
|
||||||
Updated {{ relativeTime(store.currentTask.updated_at) }}
|
<p class="meta">
|
||||||
·
|
Updated {{ relativeTime(store.currentTask.updated_at) }}
|
||||||
Created {{ relativeTime(store.currentTask.created_at) }}
|
·
|
||||||
</p>
|
Created {{ relativeTime(store.currentTask.created_at) }}
|
||||||
<div class="badges">
|
</p>
|
||||||
<StatusBadge
|
<div class="badges">
|
||||||
:status="store.currentTask.status!"
|
<StatusBadge
|
||||||
clickable
|
:status="store.currentTask.status!"
|
||||||
@click="cycleStatus"
|
clickable
|
||||||
/>
|
@click="cycleStatus"
|
||||||
<PriorityBadge :priority="store.currentTask.priority!" />
|
/>
|
||||||
<span
|
<PriorityBadge :priority="store.currentTask.priority!" />
|
||||||
v-if="store.currentTask.due_date"
|
<span
|
||||||
:class="['due-date', { overdue: isOverdue() }]"
|
v-if="store.currentTask.due_date"
|
||||||
>
|
:class="['due-date', { overdue: isOverdue() }]"
|
||||||
Due: {{ store.currentTask.due_date }}
|
>
|
||||||
</span>
|
Due: {{ store.currentTask.due_date }}
|
||||||
</div>
|
</span>
|
||||||
<div class="tags" v-if="store.currentTask.tags.length">
|
</div>
|
||||||
<TagPill
|
<div class="tags" v-if="store.currentTask.tags.length">
|
||||||
v-for="tag in store.currentTask.tags"
|
<TagPill
|
||||||
:key="tag"
|
v-for="tag in store.currentTask.tags"
|
||||||
:tag="tag"
|
:key="tag"
|
||||||
@click="onTagClick"
|
:tag="tag"
|
||||||
/>
|
@click="onTagClick"
|
||||||
</div>
|
/>
|
||||||
<div
|
</div>
|
||||||
class="body prose"
|
<div
|
||||||
v-html="renderedBody"
|
class="body prose"
|
||||||
@click="onBodyClick"
|
v-html="renderedBody"
|
||||||
></div>
|
@click="onBodyClick"
|
||||||
|
></div>
|
||||||
|
|
||||||
<div v-if="backlinks.length" class="backlinks">
|
<div v-if="backlinks.length" class="backlinks">
|
||||||
<h2>Backlinks</h2>
|
<h2>Backlinks</h2>
|
||||||
<ul class="backlinks-list">
|
<ul class="backlinks-list">
|
||||||
<li v-for="link in backlinks" :key="`${link.type}-${link.id}`" class="backlink-item">
|
<li v-for="link in backlinks" :key="`${link.type}-${link.id}`" class="backlink-item">
|
||||||
<span class="backlink-type">{{ link.type }}</span>
|
<span class="backlink-type">{{ link.type }}</span>
|
||||||
<router-link
|
<router-link
|
||||||
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
|
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
|
||||||
class="backlink-link"
|
class="backlink-link"
|
||||||
>
|
>
|
||||||
{{ link.title || "Untitled" }}
|
{{ link.title || "Untitled" }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p v-else>Task not found.</p>
|
<p v-else>Task not found.</p>
|
||||||
</main>
|
</main>
|
||||||
|
<TableOfContents
|
||||||
|
v-if="store.currentTask?.body"
|
||||||
|
:body="store.currentTask.body"
|
||||||
|
class="toc-sidebar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.viewer-layout {
|
||||||
|
display: flex;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
.viewer {
|
.viewer {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
max-width: 960px;
|
max-width: 960px;
|
||||||
margin: 2rem auto;
|
margin: 2rem 0;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
.toc-sidebar {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.toc-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
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 { useAuthStore } from "@/stores/auth";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import type { User } from "@/types/auth";
|
import type { User } from "@/types/auth";
|
||||||
|
|
||||||
|
interface Invitation {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const toastStore = useToastStore();
|
const toastStore = useToastStore();
|
||||||
|
|
||||||
@@ -15,8 +22,13 @@ const toggling = ref(false);
|
|||||||
const confirmDeleteId = ref<number | null>(null);
|
const confirmDeleteId = ref<number | null>(null);
|
||||||
const deleting = 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 () => {
|
onMounted(async () => {
|
||||||
await Promise.all([fetchUsers(), fetchRegistration()]);
|
await Promise.all([fetchUsers(), fetchRegistration(), fetchInvitations()]);
|
||||||
loading.value = false;
|
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() {
|
async function toggleRegistration() {
|
||||||
toggling.value = true;
|
toggling.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -122,6 +177,58 @@ function formatDate(iso: string): string {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="settings-section">
|
||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
|
|
||||||
@@ -201,6 +308,53 @@ function formatDate(iso: string): string {
|
|||||||
font-size: 1.1rem;
|
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 toggle */
|
||||||
.registration-row {
|
.registration-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -382,5 +536,8 @@ function formatDate(iso: string): string {
|
|||||||
.btn-toggle {
|
.btn-toggle {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.invite-form {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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.user import User # noqa: E402, F401
|
||||||
from fabledassistant.models.app_log import AppLog # 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.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
|
import json
|
||||||
|
|
||||||
from quart import Blueprint, Response, g, jsonify, request
|
from quart import Blueprint, Response, g, jsonify, request
|
||||||
|
|
||||||
from fabledassistant.auth import admin_required, login_required, get_current_user_id
|
from fabledassistant.auth import admin_required, login_required, get_current_user_id
|
||||||
from fabledassistant.services.auth import (
|
from fabledassistant.services.auth import (
|
||||||
|
create_invitation,
|
||||||
delete_user,
|
delete_user,
|
||||||
is_registration_open,
|
is_registration_open,
|
||||||
|
list_pending_invitations,
|
||||||
list_users,
|
list_users,
|
||||||
|
revoke_invitation,
|
||||||
set_registration_open,
|
set_registration_open,
|
||||||
)
|
)
|
||||||
from fabledassistant.services.backup import (
|
from fabledassistant.services.backup import (
|
||||||
@@ -14,9 +18,10 @@ from fabledassistant.services.backup import (
|
|||||||
export_user_backup,
|
export_user_backup,
|
||||||
restore_full_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.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")
|
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
|
||||||
|
|
||||||
@@ -175,3 +180,81 @@ async def list_logs():
|
|||||||
async def log_stats():
|
async def log_stats():
|
||||||
stats = await get_log_stats()
|
stats = await get_log_stats()
|
||||||
return jsonify(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 quart import Blueprint, g, jsonify, request, session
|
||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
from fabledassistant.auth import login_required, get_current_user_id
|
||||||
from fabledassistant.config import Config
|
|
||||||
from fabledassistant.services.auth import (
|
from fabledassistant.services.auth import (
|
||||||
authenticate,
|
authenticate,
|
||||||
change_password,
|
change_password,
|
||||||
@@ -14,7 +13,9 @@ from fabledassistant.services.auth import (
|
|||||||
get_user_by_username,
|
get_user_by_username,
|
||||||
get_user_count,
|
get_user_count,
|
||||||
is_registration_open,
|
is_registration_open,
|
||||||
|
register_with_invitation,
|
||||||
reset_password_with_token,
|
reset_password_with_token,
|
||||||
|
validate_invitation_token,
|
||||||
)
|
)
|
||||||
from fabledassistant.services.logging import log_audit
|
from fabledassistant.services.logging import log_audit
|
||||||
from fabledassistant.services.notifications import (
|
from fabledassistant.services.notifications import (
|
||||||
@@ -22,7 +23,7 @@ from fabledassistant.services.notifications import (
|
|||||||
send_password_reset_email,
|
send_password_reset_email,
|
||||||
send_password_reset_success_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")
|
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||||
|
|
||||||
@@ -142,7 +143,8 @@ async def forgot_password():
|
|||||||
user = await get_user_by_email(email)
|
user = await get_user_by_email(email)
|
||||||
if user and await is_smtp_configured():
|
if user and await is_smtp_configured():
|
||||||
raw_token = await create_password_reset_token(user.id)
|
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))
|
asyncio.create_task(send_password_reset_email(user.email, reset_url))
|
||||||
await log_audit(
|
await log_audit(
|
||||||
"password_reset_requested",
|
"password_reset_requested",
|
||||||
@@ -196,6 +198,46 @@ async def reset_password():
|
|||||||
return jsonify({"status": "ok"})
|
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"])
|
@auth_bp.route("/status", methods=["GET"])
|
||||||
async def status():
|
async def status():
|
||||||
count = await get_user_count()
|
count = await get_user_count()
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ async def list_tasks_route():
|
|||||||
limit = request.args.get("limit", 50, type=int)
|
limit = request.args.get("limit", 50, type=int)
|
||||||
offset = request.args.get("offset", 0, 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(
|
tasks, total = await list_notes(
|
||||||
uid,
|
uid,
|
||||||
q=q,
|
q=q,
|
||||||
@@ -36,6 +41,8 @@ async def list_tasks_route():
|
|||||||
is_task=True,
|
is_task=True,
|
||||||
status=status,
|
status=status,
|
||||||
priority=priority,
|
priority=priority,
|
||||||
|
due_before=due_before,
|
||||||
|
due_after=due_after,
|
||||||
sort=sort,
|
sort=sort,
|
||||||
order=order,
|
order=order,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy import func, select, update
|
|||||||
from fabledassistant.models import async_session
|
from fabledassistant.models import async_session
|
||||||
from fabledassistant.models.conversation import Conversation
|
from fabledassistant.models.conversation import Conversation
|
||||||
from fabledassistant.models.note import Note
|
from fabledassistant.models.note import Note
|
||||||
|
from fabledassistant.models.invitation import InvitationToken
|
||||||
from fabledassistant.models.password_reset import PasswordResetToken
|
from fabledassistant.models.password_reset import PasswordResetToken
|
||||||
from fabledassistant.models.setting import Setting
|
from fabledassistant.models.setting import Setting
|
||||||
from fabledassistant.models.user import User
|
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)
|
logger.info("Password reset via token for user %d (%s)", user.id, user.username)
|
||||||
return True
|
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"))
|
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:
|
async def send_email(to: str, subject: str, html_body: str) -> None:
|
||||||
"""Send an email via SMTP."""
|
"""Send an email via SMTP."""
|
||||||
config = await get_smtp_config()
|
config = await get_smtp_config()
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ async def list_notes(
|
|||||||
is_task: bool | None = None,
|
is_task: bool | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
priority: str | None = None,
|
priority: str | None = None,
|
||||||
|
due_before: date | None = None,
|
||||||
|
due_after: date | None = None,
|
||||||
sort: str = "updated_at",
|
sort: str = "updated_at",
|
||||||
order: str = "desc",
|
order: str = "desc",
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
@@ -96,6 +98,13 @@ async def list_notes(
|
|||||||
query = query.where(Note.priority == priority)
|
query = query.where(Note.priority == priority)
|
||||||
count_query = count_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)
|
sort_col = getattr(Note, sort, Note.updated_at)
|
||||||
if order == "asc":
|
if order == "asc":
|
||||||
query = query.order_by(sort_col.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)
|
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:
|
async def check_due_tasks() -> None:
|
||||||
"""Check for tasks due today and send reminder emails."""
|
"""Check for tasks due today and send reminder emails."""
|
||||||
if not await is_smtp_configured():
|
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.
|
> Include file-level details in the commit body when the change is non-trivial.
|
||||||
|
|
||||||
## Last Updated
|
## 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
|
## Project Overview
|
||||||
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
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)`
|
- Indexes: `category`, `user_id`, `created_at`, composite `(category, created_at DESC)`
|
||||||
- Automatic retention cleanup via hourly asyncio task (configurable `LOG_RETENTION_DAYS`, default 90)
|
- 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
|
### Messages
|
||||||
- `id` (int PK), `conversation_id` (FK to conversations, CASCADE), `role` (str: system/user/assistant),
|
- `id` (int PK), `conversation_id` (FK to conversations, CASCADE), `role` (str: system/user/assistant),
|
||||||
`content` (str), `status` (str, default `'complete'` — `complete`/`generating`/`error`),
|
`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
|
│ ├── 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
|
│ ├── 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')
|
│ ├── 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/
|
├── src/
|
||||||
│ └── fabledassistant/
|
│ └── fabledassistant/
|
||||||
│ ├── __init__.py
|
│ ├── __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)
|
│ │ ├── 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
|
│ │ ├── conversation.py # Conversation + Message models with user_id
|
||||||
│ │ ├── setting.py # Setting model (composite PK: user_id + key, value TEXT)
|
│ │ ├── 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/
|
│ ├── routes/
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
│ │ ├── api.py # /api blueprint with /health endpoint (public)
|
│ │ ├── api.py # /api blueprint with /health endpoint (public)
|
||||||
│ │ ├── auth.py # /api/auth blueprint: register, login, logout, me, status
|
│ │ ├── 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 (admin only)
|
│ │ ├── 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)
|
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming (all @login_required)
|
||||||
│ │ ├── notes.py # /api/notes CRUD + wikilinks + backlinks (all @login_required)
|
│ │ ├── notes.py # /api/notes CRUD + wikilinks + backlinks (all @login_required)
|
||||||
│ │ ├── tasks.py # /api/tasks CRUD + PATCH status (all @login_required)
|
│ │ ├── tasks.py # /api/tasks CRUD + PATCH status (all @login_required)
|
||||||
│ │ └── settings.py # /api/settings GET/PUT — per-user settings (@login_required)
|
│ │ └── settings.py # /api/settings GET/PUT — per-user settings (@login_required)
|
||||||
│ ├── services/
|
│ ├── 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
|
│ │ ├── 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
|
│ │ ├── 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
|
│ │ ├── llm.py # Ollama interaction: build_context with user_id, streaming, URL fetching
|
||||||
@@ -299,16 +311,17 @@ fabledassistant/
|
|||||||
│ ├── views/
|
│ ├── views/
|
||||||
│ │ ├── LoginView.vue # Login form with error display, link to register
|
│ │ ├── LoginView.vue # Login form with error display, link to register
|
||||||
│ │ ├── RegisterView.vue # Register form with password confirmation; shows "closed" message when registration disabled
|
│ │ ├── 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
|
│ │ ├── 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)
|
│ │ ├── SettingsView.vue # Settings page: assistant name, model catalog, data export/restore (admin)
|
||||||
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
|
│ │ ├── 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
|
│ │ ├── 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
|
│ │ ├── 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
|
│ │ ├── 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/
|
│ ├── components/
|
||||||
│ │ ├── LogsView.vue # Admin log viewer: stats summary, category/search/date filters, paginated table with expandable detail rows
|
│ │ ├── 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)
|
│ │ ├── 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
|
│ │ ├── SearchBar.vue # Debounced search input
|
||||||
│ │ ├── TagPill.vue # Clickable/dismissible tag pill
|
│ │ ├── TagPill.vue # Clickable/dismissible tag pill
|
||||||
│ │ ├── PaginationBar.vue # Prev/next + page numbers
|
│ │ ├── 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
|
│ │ └── ToastNotification.vue # Fixed-position toast container with close button, warning support
|
||||||
│ └── router/
|
│ └── 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/
|
└── public/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -362,7 +376,14 @@ fabledassistant/
|
|||||||
| GET | `/api/notes/:id/backlinks` | List notes/tasks that reference this note via wikilinks |
|
| 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) |
|
| 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/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`) |
|
| 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 |
|
| GET | `/api/tasks/:id` | Get single task |
|
||||||
| PUT | `/api/tasks/:id` | Update task (accepts `body` or `description`, prefers `body`) |
|
| PUT | `/api/tasks/:id` | Update task (accepts `body` or `description`, prefers `body`) |
|
||||||
@@ -397,7 +418,7 @@ container startup.
|
|||||||
|
|
||||||
### Migration Chain
|
### 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
|
### 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] **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
|
- [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
|
### Future / Stretch
|
||||||
- Tagging/labeling system with LLM-suggested tags
|
- Tagging/labeling system with LLM-suggested tags
|
||||||
- Calendar/timeline view for tasks
|
- 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`
|
- To reset database: `docker compose down -v && docker compose up --build`
|
||||||
|
|
||||||
## Current Status
|
## 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
|
- Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags
|
||||||
- **Tiptap WYSIWYG editor** with inline formatting preview, markdown round-trip, paste handling
|
- **Tiptap WYSIWYG editor** with inline formatting preview, markdown round-trip, paste handling
|
||||||
- **Tag/wikilink autocomplete** via `@tiptap/suggestion` with heading disambiguation
|
- **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
|
- Context pills with promote/exclude controls; note picker in chat input
|
||||||
- Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations as notes
|
- Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations as notes
|
||||||
- Dedicated `/chat` page with responsive sidebar (overlay on mobile)
|
- Dedicated `/chat` page with responsive sidebar (overlay on mobile)
|
||||||
- Settings page: assistant name, model catalog, password change, data export/restore (admin)
|
- Settings page: assistant name, model catalog (installed/available tabs), password change, data export/restore (admin)
|
||||||
- **Registration control**: auto-closes after first user, admin toggle, password confirmation
|
- **Invitation system**: admin sends email invitations, recipients register via token-based `/register-invite` flow
|
||||||
- **Admin user management**: `/admin/users` with user list + delete
|
- **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
|
- **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
|
- **App-wide layout fix**: navbar always visible, all views fit within viewport
|
||||||
- **Responsive design**: hamburger menu, mobile touch targets, responsive breakpoints
|
- **Responsive design**: hamburger menu, mobile touch targets, responsive breakpoints
|
||||||
- **Docker Swarm production stack** with secrets, network isolation, health checks, resource limits
|
- **Docker Swarm production stack** with secrets, network isolation, health checks, resource limits
|
||||||
- **Application logging**: audit (security events), usage (API requests), error (unhandled exceptions)
|
- **Application logging**: audit (security events), usage (API requests), error (unhandled exceptions)
|
||||||
- **Admin log viewer**: `/admin/logs` with stats, filters, pagination, expandable detail rows
|
- **Admin log viewer**: `/admin/logs` with stats, filters, pagination, expandable detail rows
|
||||||
- **Automatic log retention**: configurable via `LOG_RETENTION_DAYS` (default 90 days)
|
- **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 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
|
- **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
|
- Dark/light theme with CSS custom properties and design tokens
|
||||||
|
|||||||
Reference in New Issue
Block a user