Add registration control, admin user management, and security hardening
- Registration auto-closes after first user; admin can toggle from /admin/users - Admin user management view with user list and delete - Password confirmation on registration form - Password change in Settings (PUT /api/auth/password) - Session cookie hardening: HttpOnly, SameSite=Lax, optional Secure flag - Startup warning when SECRET_KEY is default - Production deployment docs: reverse proxy, rate limiting, CSP headers - Fix assist prompt to preserve markdown headings in target sections - Simplify prod compose networking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -67,10 +67,30 @@ Configuration is done via environment variables. See `docker-compose.yml` for de
|
||||
| `SECRET_KEY` | (required) | Session signing key |
|
||||
| `OLLAMA_BASE_URL` | `http://ollama:11434` | Ollama API endpoint |
|
||||
| `DEFAULT_MODEL` | `llama3.1` | Default LLM model to auto-pull on startup |
|
||||
| `SECURE_COOKIES` | `false` | Set to `true` when behind TLS to add `Secure` flag to session cookies |
|
||||
| `LOG_LEVEL` | `INFO` | Logging level |
|
||||
|
||||
For production deployments, `docker-compose.prod.yml` supports Docker secrets (`SECRET_KEY_FILE`, `DATABASE_URL_FILE`) and includes network isolation, health checks, and resource limits.
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Reverse Proxy (Required)
|
||||
|
||||
Fabled Assistant does **not** handle SSL/TLS. You must run it behind a reverse proxy for production use:
|
||||
|
||||
- **Nginx**, **Traefik**, or **Caddy** in front of the app container
|
||||
- Terminate TLS at the proxy and forward traffic to port 5000
|
||||
- **Do not expose port 5000 directly to the internet**
|
||||
- **Rate-limit auth endpoints** at the proxy layer — recommended: limit `/api/auth/login` and `/api/auth/register` to ~5 requests/minute per IP to prevent brute-force attacks
|
||||
- **Set Content Security Policy headers** at the proxy — recommended: `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'`
|
||||
|
||||
### Security Checklist
|
||||
|
||||
- **Set a strong `SECRET_KEY`** — used to sign session cookies. Generate one with `python -c "import secrets; print(secrets.token_hex(32))"` or use Docker secrets via `SECRET_KEY_FILE`.
|
||||
- **Registration auto-closes** — After the first user registers (who becomes admin), registration is closed by default. The admin can re-enable it from the user management page (`/admin/users`).
|
||||
- **Use Docker secrets in production** — `docker-compose.prod.yml` reads `SECRET_KEY_FILE` and `DATABASE_URL_FILE` instead of plain environment variables.
|
||||
- **Keep Ollama on an internal network** — The default compose files keep Ollama on a Docker-internal network, not exposed to the host.
|
||||
|
||||
## Technical Overview
|
||||
|
||||
### Stack
|
||||
|
||||
@@ -10,7 +10,6 @@ services:
|
||||
OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
||||
networks:
|
||||
- fabledassistant_frontend
|
||||
- fabledassistant_backend
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
|
||||
@@ -80,8 +79,5 @@ volumes:
|
||||
ollama_models:
|
||||
|
||||
networks:
|
||||
fabledassistant_frontend:
|
||||
driver: overlay
|
||||
fabledassistant_backend:
|
||||
driver: overlay
|
||||
internal: true
|
||||
|
||||
@@ -61,6 +61,7 @@ router.afterEach(() => {
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/chat" class="nav-link">Chat</router-link>
|
||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin/users" class="nav-link">Users</router-link>
|
||||
<span class="status-indicator" :class="statusClass" :title="statusLabel">
|
||||
<span class="status-dot"></span>
|
||||
</span>
|
||||
|
||||
@@ -77,6 +77,11 @@ const router = createRouter({
|
||||
name: "settings",
|
||||
component: () => import("@/views/SettingsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/admin/users",
|
||||
name: "admin-users",
|
||||
component: () => import("@/views/UserManagementView.vue"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
const user = ref<User | null>(null);
|
||||
const loading = ref(true);
|
||||
const hasUsers = ref(true);
|
||||
const registrationOpen = ref(false);
|
||||
|
||||
const isAuthenticated = computed(() => user.value !== null);
|
||||
const isAdmin = computed(() => user.value?.role === "admin");
|
||||
@@ -26,8 +27,10 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
try {
|
||||
const data = await apiGet<AuthStatus>("/api/auth/status");
|
||||
hasUsers.value = data.has_users;
|
||||
registrationOpen.value = data.registration_open;
|
||||
} catch {
|
||||
hasUsers.value = true;
|
||||
registrationOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +55,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
user,
|
||||
loading,
|
||||
hasUsers,
|
||||
registrationOpen,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
checkAuth,
|
||||
|
||||
@@ -8,4 +8,5 @@ export interface User {
|
||||
|
||||
export interface AuthStatus {
|
||||
has_users: boolean;
|
||||
registration_open: boolean;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
@@ -9,11 +9,30 @@ const authStore = useAuthStore();
|
||||
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
const confirmPassword = ref("");
|
||||
const email = ref("");
|
||||
const error = ref("");
|
||||
const submitting = ref(false);
|
||||
const checking = ref(true);
|
||||
|
||||
const passwordMismatch = computed(
|
||||
() => confirmPassword.value.length > 0 && password.value !== confirmPassword.value
|
||||
);
|
||||
|
||||
const canSubmit = computed(
|
||||
() => !submitting.value && !passwordMismatch.value && password.value.length >= 8
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await authStore.checkHasUsers();
|
||||
checking.value = false;
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
if (passwordMismatch.value) {
|
||||
error.value = "Passwords do not match";
|
||||
return;
|
||||
}
|
||||
error.value = "";
|
||||
submitting.value = true;
|
||||
try {
|
||||
@@ -36,50 +55,77 @@ async function handleSubmit() {
|
||||
<main class="auth-page">
|
||||
<div class="auth-card">
|
||||
<div class="auth-brand"><AppLogo :size="32" /><h1>Create Account</h1></div>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<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="email">Email (optional)</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
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>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<button type="submit" class="btn-submit" :disabled="submitting">
|
||||
{{ submitting ? "Creating account..." : "Create Account" }}
|
||||
</button>
|
||||
</form>
|
||||
<p class="auth-footer">
|
||||
Already have an account?
|
||||
<router-link to="/login">Sign in</router-link>
|
||||
</p>
|
||||
|
||||
<div v-if="checking" class="loading-msg">Checking registration status...</div>
|
||||
|
||||
<div v-else-if="!authStore.registrationOpen" class="closed-msg">
|
||||
<p>Registration is currently closed.</p>
|
||||
<p>Contact an administrator to get an account.</p>
|
||||
<p class="auth-footer">
|
||||
Already have an account?
|
||||
<router-link to="/login">Sign in</router-link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<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="email">Email (optional)</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
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>
|
||||
<p class="auth-footer">
|
||||
Already have an account?
|
||||
<router-link to="/login">Sign in</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -111,6 +157,21 @@ async function handleSubmit() {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.loading-msg {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.closed-msg {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.closed-msg p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -134,11 +195,22 @@ async function handleSubmit() {
|
||||
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;
|
||||
|
||||
@@ -3,12 +3,17 @@ import { ref, computed, onMounted } from "vue";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { apiPut } from "@/api/client";
|
||||
import type { ModelInfo } from "@/types/settings";
|
||||
|
||||
const store = useSettingsStore();
|
||||
const authStore = useAuthStore();
|
||||
const toastStore = useToastStore();
|
||||
const assistantName = ref("");
|
||||
const currentPassword = ref("");
|
||||
const newPassword = ref("");
|
||||
const confirmNewPassword = ref("");
|
||||
const changingPassword = ref(false);
|
||||
const saving = ref(false);
|
||||
const saved = ref(false);
|
||||
const pullProgress = ref<{ model: string; percent: number } | null>(null);
|
||||
@@ -190,6 +195,33 @@ onMounted(async () => {
|
||||
store.fetchInstalledModels();
|
||||
});
|
||||
|
||||
async function changePassword() {
|
||||
if (newPassword.value !== confirmNewPassword.value) {
|
||||
toastStore.show("New passwords do not match", "error");
|
||||
return;
|
||||
}
|
||||
changingPassword.value = true;
|
||||
try {
|
||||
await apiPut("/api/auth/password", {
|
||||
current_password: currentPassword.value,
|
||||
new_password: newPassword.value,
|
||||
});
|
||||
toastStore.show("Password changed successfully");
|
||||
currentPassword.value = "";
|
||||
newPassword.value = "";
|
||||
confirmNewPassword.value = "";
|
||||
} 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 change password", "error");
|
||||
} else {
|
||||
toastStore.show("Failed to change password", "error");
|
||||
}
|
||||
} finally {
|
||||
changingPassword.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAssistant() {
|
||||
saving.value = true;
|
||||
saved.value = false;
|
||||
@@ -345,6 +377,54 @@ async function handleRestoreFile(event: Event) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Change Password</h2>
|
||||
<div class="field">
|
||||
<label for="current-password">Current Password</label>
|
||||
<input
|
||||
id="current-password"
|
||||
v-model="currentPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-password">New Password</label>
|
||||
<input
|
||||
id="new-password"
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
/>
|
||||
<p class="field-hint">Must be at least 8 characters</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="confirm-new-password">Confirm New Password</label>
|
||||
<input
|
||||
id="confirm-new-password"
|
||||
v-model="confirmNewPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
:class="{ 'input-error': confirmNewPassword && newPassword !== confirmNewPassword }"
|
||||
/>
|
||||
<p v-if="confirmNewPassword && newPassword !== confirmNewPassword" class="error-hint">
|
||||
Passwords do not match
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn-save"
|
||||
@click="changePassword"
|
||||
:disabled="changingPassword || !currentPassword || newPassword.length < 8 || newPassword !== confirmNewPassword"
|
||||
>
|
||||
{{ changingPassword ? "Changing..." : "Change Password" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Model</h2>
|
||||
<p class="section-desc">
|
||||
@@ -538,6 +618,17 @@ async function handleRestoreFile(event: Event) {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.input-error {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.input-error:focus {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.error-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* Model list */
|
||||
.model-list {
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { apiGet, apiPut, apiDelete } from "@/api/client";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import type { User } from "@/types/auth";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const toastStore = useToastStore();
|
||||
|
||||
const users = ref<User[]>([]);
|
||||
const registrationOpen = ref(false);
|
||||
const loading = ref(true);
|
||||
const toggling = ref(false);
|
||||
const confirmDeleteId = ref<number | null>(null);
|
||||
const deleting = ref<number | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchUsers(), fetchRegistration()]);
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
async function fetchUsers() {
|
||||
try {
|
||||
const data = await apiGet<{ users: User[] }>("/api/admin/users");
|
||||
users.value = data.users;
|
||||
} catch {
|
||||
toastStore.show("Failed to load users", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRegistration() {
|
||||
try {
|
||||
const data = await apiGet<{ open: boolean }>("/api/admin/registration");
|
||||
registrationOpen.value = data.open;
|
||||
} catch {
|
||||
// Ignore — will default to false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRegistration() {
|
||||
toggling.value = true;
|
||||
try {
|
||||
const data = await apiPut<{ open: boolean }>("/api/admin/registration", {
|
||||
open: !registrationOpen.value,
|
||||
});
|
||||
registrationOpen.value = data.open;
|
||||
toastStore.show(data.open ? "Registration opened" : "Registration closed");
|
||||
} catch {
|
||||
toastStore.show("Failed to update registration setting", "error");
|
||||
} finally {
|
||||
toggling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(userId: number) {
|
||||
if (confirmDeleteId.value === userId) {
|
||||
deleteUser(userId);
|
||||
} else {
|
||||
confirmDeleteId.value = userId;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
confirmDeleteId.value = null;
|
||||
}
|
||||
|
||||
async function deleteUser(userId: number) {
|
||||
confirmDeleteId.value = null;
|
||||
deleting.value = userId;
|
||||
try {
|
||||
await apiDelete(`/api/admin/users/${userId}`);
|
||||
users.value = users.value.filter((u) => u.id !== userId);
|
||||
toastStore.show("User deleted");
|
||||
} 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 delete user", "error");
|
||||
} else {
|
||||
toastStore.show("Failed to delete user", "error");
|
||||
}
|
||||
} finally {
|
||||
deleting.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="users-page">
|
||||
<h1>User Management</h1>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Registration</h2>
|
||||
<div class="registration-row">
|
||||
<div class="registration-info">
|
||||
<p class="registration-status">
|
||||
Registration is currently
|
||||
<strong :class="registrationOpen ? 'text-success' : 'text-muted'">
|
||||
{{ registrationOpen ? "open" : "closed" }}
|
||||
</strong>
|
||||
</p>
|
||||
<p class="field-hint">
|
||||
When closed, new users can only be added by an administrator.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-toggle"
|
||||
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
|
||||
@click="toggleRegistration"
|
||||
:disabled="toggling"
|
||||
>
|
||||
{{ toggling ? "Updating..." : registrationOpen ? "Close Registration" : "Open Registration" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Users</h2>
|
||||
|
||||
<div v-if="loading" class="loading-msg">Loading users...</div>
|
||||
|
||||
<div v-else-if="users.length === 0" class="empty-msg">No users found.</div>
|
||||
|
||||
<table v-else class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th class="hide-mobile">Email</th>
|
||||
<th>Role</th>
|
||||
<th class="hide-mobile">Joined</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.id">
|
||||
<td class="cell-username">{{ u.username }}</td>
|
||||
<td class="hide-mobile cell-email">{{ u.email || "—" }}</td>
|
||||
<td>
|
||||
<span class="role-badge" :class="u.role === 'admin' ? 'role-admin' : 'role-user'">
|
||||
{{ u.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="hide-mobile cell-date">{{ formatDate(u.created_at) }}</td>
|
||||
<td class="cell-actions">
|
||||
<template v-if="u.id === authStore.user?.id">
|
||||
<span class="you-label">You</span>
|
||||
</template>
|
||||
<template v-else-if="confirmDeleteId === u.id">
|
||||
<button
|
||||
class="btn-confirm-delete"
|
||||
@click="confirmDelete(u.id)"
|
||||
:disabled="deleting !== null"
|
||||
>
|
||||
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
|
||||
</button>
|
||||
<button class="btn-cancel-delete" @click="cancelDelete">Cancel</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
class="btn-delete"
|
||||
@click="confirmDelete(u.id)"
|
||||
:disabled="deleting !== null"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.users-page {
|
||||
max-width: 960px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.users-page h1 {
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
.settings-section {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.settings-section h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Registration toggle */
|
||||
.registration-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.registration-info {
|
||||
flex: 1;
|
||||
}
|
||||
.registration-status {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.text-success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
.text-muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.field-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.btn-toggle {
|
||||
padding: 0.45rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-toggle:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-toggle-open {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-toggle-open:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.btn-toggle-close {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.btn-toggle-close:hover:not(:disabled) {
|
||||
border-color: var(--color-warning);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
/* Users table */
|
||||
.loading-msg,
|
||||
.empty-msg {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.users-table th {
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.users-table td {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.users-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.cell-username {
|
||||
font-weight: 600;
|
||||
}
|
||||
.cell-email {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.cell-date {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cell-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Role badges */
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.role-admin {
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
}
|
||||
.role-user {
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.you-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.btn-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-delete:hover:not(:disabled) {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.btn-delete:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-confirm-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.btn-confirm-delete:hover:not(:disabled) {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
.btn-confirm-delete:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-cancel-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-cancel-delete:hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.registration-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.btn-toggle {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -26,6 +26,15 @@ def create_app() -> Quart:
|
||||
|
||||
app = Quart(__name__, static_folder=None)
|
||||
app.secret_key = Config.SECRET_KEY
|
||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
app.config["SESSION_COOKIE_SECURE"] = Config.SECURE_COOKIES
|
||||
|
||||
if Config.SECRET_KEY == "dev-secret-change-me":
|
||||
logger.warning(
|
||||
"SECRET_KEY is set to the default value — session cookies are insecure. "
|
||||
"Set SECRET_KEY or SECRET_KEY_FILE for production use."
|
||||
)
|
||||
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(api)
|
||||
|
||||
@@ -25,4 +25,5 @@ class Config:
|
||||
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "llama3.1")
|
||||
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
|
||||
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
|
||||
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
|
||||
|
||||
@@ -3,6 +3,12 @@ import json
|
||||
from quart import Blueprint, Response, jsonify, request
|
||||
|
||||
from fabledassistant.auth import admin_required, login_required, get_current_user_id
|
||||
from fabledassistant.services.auth import (
|
||||
delete_user,
|
||||
is_registration_open,
|
||||
list_users,
|
||||
set_registration_open,
|
||||
)
|
||||
from fabledassistant.services.backup import (
|
||||
export_full_backup,
|
||||
export_user_backup,
|
||||
@@ -45,3 +51,43 @@ async def restore():
|
||||
|
||||
stats = await restore_full_backup(data)
|
||||
return jsonify({"status": "ok", "stats": stats})
|
||||
|
||||
|
||||
@admin_bp.route("/users", methods=["GET"])
|
||||
@admin_required
|
||||
async def get_users():
|
||||
users = await list_users()
|
||||
return jsonify({"users": [u.to_dict() for u in users]})
|
||||
|
||||
|
||||
@admin_bp.route("/users/<int:user_id>", methods=["DELETE"])
|
||||
@admin_required
|
||||
async def remove_user(user_id: int):
|
||||
current_uid = get_current_user_id()
|
||||
if user_id == current_uid:
|
||||
return jsonify({"error": "Cannot delete your own account"}), 400
|
||||
|
||||
deleted = await delete_user(user_id)
|
||||
if not deleted:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@admin_bp.route("/registration", methods=["GET"])
|
||||
@admin_required
|
||||
async def get_registration():
|
||||
open_status = await is_registration_open()
|
||||
return jsonify({"open": open_status})
|
||||
|
||||
|
||||
@admin_bp.route("/registration", methods=["PUT"])
|
||||
@admin_required
|
||||
async def toggle_registration():
|
||||
data = await request.get_json()
|
||||
open_val = data.get("open")
|
||||
if open_val is None:
|
||||
return jsonify({"error": "Missing 'open' field"}), 400
|
||||
|
||||
uid = get_current_user_id()
|
||||
await set_registration_open(uid, bool(open_val))
|
||||
return jsonify({"status": "ok", "open": bool(open_val)})
|
||||
|
||||
@@ -3,9 +3,11 @@ from quart import Blueprint, jsonify, request, session
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.services.auth import (
|
||||
authenticate,
|
||||
change_password,
|
||||
create_user,
|
||||
get_user_by_id,
|
||||
get_user_count,
|
||||
is_registration_open,
|
||||
)
|
||||
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||
@@ -13,6 +15,9 @@ auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||
|
||||
@auth_bp.route("/register", methods=["POST"])
|
||||
async def register():
|
||||
if not await is_registration_open():
|
||||
return jsonify({"error": "Registration is closed"}), 403
|
||||
|
||||
data = await request.get_json()
|
||||
username = (data.get("username") or "").strip()
|
||||
password = data.get("password") or ""
|
||||
@@ -64,7 +69,28 @@ async def me():
|
||||
return jsonify(user.to_dict())
|
||||
|
||||
|
||||
@auth_bp.route("/password", methods=["PUT"])
|
||||
@login_required
|
||||
async def update_password():
|
||||
data = await request.get_json()
|
||||
current = data.get("current_password") or ""
|
||||
new_pw = data.get("new_password") or ""
|
||||
|
||||
if not current:
|
||||
return jsonify({"error": "Current password is required"}), 400
|
||||
if len(new_pw) < 8:
|
||||
return jsonify({"error": "New password must be at least 8 characters"}), 400
|
||||
|
||||
uid = get_current_user_id()
|
||||
success = await change_password(uid, current, new_pw)
|
||||
if not success:
|
||||
return jsonify({"error": "Current password is incorrect"}), 403
|
||||
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@auth_bp.route("/status", methods=["GET"])
|
||||
async def status():
|
||||
count = await get_user_count()
|
||||
return jsonify({"has_users": count > 0})
|
||||
reg_open = await is_registration_open()
|
||||
return jsonify({"has_users": count > 0, "registration_open": reg_open})
|
||||
|
||||
@@ -20,6 +20,9 @@ def build_assist_messages(
|
||||
f"--- Full Document ---\n{truncated_body}\n--- End Document ---\n\n"
|
||||
"The user will give you a specific section of the document and an instruction. "
|
||||
"Output ONLY the replacement text for that section. "
|
||||
"If the target section starts with a markdown heading (e.g. ## Heading), "
|
||||
"your output MUST also start with a heading at the same level. "
|
||||
"You may revise the heading text but do not remove it. "
|
||||
"Do not include other sections, explanatory text, or markdown code fences around the output. "
|
||||
"Match the document's existing tone and style."
|
||||
)
|
||||
|
||||
@@ -63,6 +63,8 @@ async def create_user(
|
||||
.where(Setting.user_id == user.id)
|
||||
.values(user_id=user.id)
|
||||
)
|
||||
# Auto-close registration after first user setup
|
||||
session.add(Setting(user_id=user.id, key="registration_open", value="false"))
|
||||
await session.commit()
|
||||
logger.info("First user '%s' created as admin, claimed orphaned data", username)
|
||||
|
||||
@@ -83,3 +85,60 @@ async def authenticate(username: str, password: str) -> User | None:
|
||||
async def get_user_by_id(user_id: int) -> User | None:
|
||||
async with async_session() as session:
|
||||
return await session.get(User, user_id)
|
||||
|
||||
|
||||
async def change_password(user_id: int, current_password: str, new_password: str) -> bool:
|
||||
"""Change a user's password. Returns True on success, False if current password is wrong."""
|
||||
async with async_session() as session:
|
||||
user = await session.get(User, user_id)
|
||||
if not user:
|
||||
return False
|
||||
if not verify_password(current_password, user.password_hash):
|
||||
return False
|
||||
user.password_hash = hash_password(new_password)
|
||||
await session.commit()
|
||||
logger.info("Password changed for user %d (%s)", user_id, user.username)
|
||||
return True
|
||||
|
||||
|
||||
async def is_registration_open() -> bool:
|
||||
"""Check if new user registration is allowed.
|
||||
|
||||
Always open when no users exist (first-user setup).
|
||||
Otherwise reads the admin's 'registration_open' setting (default closed).
|
||||
"""
|
||||
user_count = await get_user_count()
|
||||
if user_count == 0:
|
||||
return True
|
||||
|
||||
async with async_session() as session:
|
||||
# Find the admin user's registration_open setting
|
||||
result = await session.execute(
|
||||
select(Setting)
|
||||
.join(User, Setting.user_id == User.id)
|
||||
.where(User.role == "admin", Setting.key == "registration_open")
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
return setting.value == "true" if setting else False
|
||||
|
||||
|
||||
async def list_users() -> list[User]:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(User).order_by(User.created_at))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def delete_user(user_id: int) -> bool:
|
||||
async with async_session() as session:
|
||||
user = await session.get(User, user_id)
|
||||
if not user:
|
||||
return False
|
||||
await session.delete(user)
|
||||
await session.commit()
|
||||
logger.info("Deleted user %d (%s)", user_id, user.username)
|
||||
return True
|
||||
|
||||
|
||||
async def set_registration_open(admin_user_id: int, open: bool) -> None:
|
||||
from fabledassistant.services.settings import set_setting
|
||||
await set_setting(admin_user_id, "registration_open", "true" if open else "false")
|
||||
|
||||
+39
-10
@@ -12,7 +12,7 @@
|
||||
> Include file-level details in the commit body when the change is non-trivial.
|
||||
|
||||
## Last Updated
|
||||
2026-02-11 — Phase 5.2: Tiptap Inline-Preview Editor + Layout Improvements
|
||||
2026-02-12 — Phase 5.3: Registration Control, User Management, Security Hardening
|
||||
|
||||
## Project Overview
|
||||
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
||||
@@ -76,6 +76,12 @@ for AI-assisted features.
|
||||
`build_context()` returns `(messages, context_meta)` tuple; metadata includes
|
||||
auto-found note IDs/titles sent to frontend via SSE `context` event before streaming.
|
||||
Multi-word search splits terms into per-word ILIKE with AND logic (not adjacent match).
|
||||
- **Reverse proxy required for production:** The app does not terminate TLS. A
|
||||
reverse proxy (Nginx/Traefik/Caddy) must sit in front of port 5000. Do not
|
||||
expose the app directly to the internet.
|
||||
- **Registration auto-closes:** After the first user registers (admin), registration
|
||||
is closed by default. The admin can toggle it from `/admin/users`. The setting is
|
||||
stored as the admin user's `registration_open` setting key.
|
||||
- **No blocking long-running operations:** Any potentially slow operation (model
|
||||
pulls, LLM calls, URL fetching, etc.) must never block app startup or freeze the
|
||||
UI. Backend uses SSE streaming for incremental responses (chat messages and model
|
||||
@@ -91,6 +97,10 @@ for AI-assisted features.
|
||||
`CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and `DO $$ BEGIN
|
||||
CREATE TYPE ... EXCEPTION WHEN duplicate_object` to allow safe re-runs and
|
||||
fresh database creation.
|
||||
- **Session cookie security:** `HttpOnly` and `SameSite=Lax` always set. `Secure`
|
||||
flag controlled by `SECURE_COOKIES` env var (enable when behind TLS).
|
||||
- **Default SECRET_KEY warning:** App logs a WARNING on startup if the default
|
||||
`dev-secret-change-me` key is in use.
|
||||
|
||||
### High-Level Component Diagram
|
||||
```
|
||||
@@ -199,7 +209,7 @@ fabledassistant/
|
||||
│ ├── __init__.py
|
||||
│ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API, request logging middleware
|
||||
│ ├── auth.py # Auth decorators: login_required, admin_required, get_current_user_id
|
||||
│ ├── config.py # Config from env vars + Docker secrets file support (_read_secret)
|
||||
│ ├── config.py # Config from env vars + Docker secrets file support (_read_secret) + SECURE_COOKIES flag
|
||||
│ ├── models/
|
||||
│ │ ├── __init__.py # async_session factory, Base, imports all models
|
||||
│ │ ├── user.py # User model (id, username, email, password_hash, role, created_at)
|
||||
@@ -210,13 +220,13 @@ fabledassistant/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── api.py # /api blueprint with /health endpoint (public)
|
||||
│ │ ├── auth.py # /api/auth blueprint: register, login, logout, me, status
|
||||
│ │ ├── admin.py # /api/admin blueprint: backup (user/full), restore (admin only)
|
||||
│ │ ├── admin.py # /api/admin blueprint: backup, restore, user management, registration toggle (admin only)
|
||||
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming (all @login_required)
|
||||
│ │ ├── notes.py # /api/notes CRUD + wikilinks + backlinks (all @login_required)
|
||||
│ │ ├── tasks.py # /api/tasks CRUD + PATCH status (all @login_required)
|
||||
│ │ └── settings.py # /api/settings GET/PUT — per-user settings (@login_required)
|
||||
│ ├── services/
|
||||
│ │ ├── auth.py # Auth business logic: hash_password, verify_password, create_user, authenticate, get_user_by_id
|
||||
│ │ ├── 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
|
||||
│ │ ├── backup.py # Backup/restore: export_full_backup, export_user_backup, restore_full_backup
|
||||
│ │ ├── notes.py # CRUD with user_id isolation, is_task filter, convert, backlinks, search_notes_for_context
|
||||
│ │ ├── llm.py # Ollama interaction: build_context with user_id, streaming, URL fetching
|
||||
@@ -268,7 +278,8 @@ fabledassistant/
|
||||
│ │ └── markdownSerializer.ts # Tiptap JSON → markdown serializer: handles all StarterKit nodes + marks
|
||||
│ ├── views/
|
||||
│ │ ├── LoginView.vue # Login form with error display, link to register
|
||||
│ │ ├── RegisterView.vue # Register form (username, password, email), first-user setup
|
||||
│ │ ├── RegisterView.vue # Register form with password confirmation; shows "closed" message when registration disabled
|
||||
│ │ ├── UserManagementView.vue # Admin user management: registration toggle, user list with delete
|
||||
│ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, context pills
|
||||
│ │ ├── HomeView.vue # Landing page: recent notes + tasks + recent chats
|
||||
│ │ ├── SettingsView.vue # Settings page: assistant name, model catalog, data export/restore (admin)
|
||||
@@ -294,7 +305,7 @@ fabledassistant/
|
||||
│ │ ├── PaginationBar.vue # Prev/next + page numbers
|
||||
│ │ └── ToastNotification.vue # Fixed-position toast container with close button, warning support
|
||||
│ └── router/
|
||||
│ └── index.ts # Routes: /, /login, /register, /notes/*, /tasks/*, /chat, /chat/:id, /settings; beforeEach auth guard
|
||||
│ └── index.ts # Routes: /, /login, /register, /notes/*, /tasks/*, /chat, /chat/:id, /settings, /admin/users; beforeEach auth guard
|
||||
└── public/
|
||||
```
|
||||
|
||||
@@ -303,13 +314,18 @@ fabledassistant/
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/health` | Health check (public) |
|
||||
| GET | `/api/auth/status` | Check if any users exist (public) |
|
||||
| POST | `/api/auth/register` | Register new user (first user becomes admin) |
|
||||
| GET | `/api/auth/status` | Check if any users exist + registration open status (public) |
|
||||
| POST | `/api/auth/register` | Register new user (first user becomes admin; returns 403 if registration closed) |
|
||||
| POST | `/api/auth/login` | Login with username/password |
|
||||
| POST | `/api/auth/logout` | Logout (clear session) |
|
||||
| GET | `/api/auth/me` | Get current user info |
|
||||
| PUT | `/api/auth/password` | Change password (body: `{current_password, new_password}`) |
|
||||
| GET | `/api/admin/backup` | Export backup (`?scope=user` for own data, full requires admin) |
|
||||
| POST | `/api/admin/restore` | Restore from JSON backup (admin only) |
|
||||
| GET | `/api/admin/users` | List all users (admin only) |
|
||||
| DELETE | `/api/admin/users/:id` | Delete a user (admin only, cannot delete self) |
|
||||
| GET | `/api/admin/registration` | Get registration open/closed status (admin only) |
|
||||
| PUT | `/api/admin/registration` | Toggle registration open/closed (admin only, body: `{open: bool}`) |
|
||||
| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`; defaults to `is_task=false` — plain notes only; `?is_task=true` for tasks, `?all=true` for everything) |
|
||||
| POST | `/api/notes` | Create note (body: `{title, body, status?, priority?, due_date?}` — tags auto-extracted) |
|
||||
| GET | `/api/notes/tags` | List all tags from notes table (param: `q` for filter) |
|
||||
@@ -583,6 +599,16 @@ When adding a new migration, follow these conventions:
|
||||
- [x] **Accept trailing newline:** Accepted AI suggestions always end with a newline for clean separation
|
||||
- [x] **Wider content area:** Max-width increased from 720px to 960px across all views
|
||||
|
||||
### Phase 5.3 — Registration Control, User Management, Security Hardening ✓
|
||||
- [x] **Registration auto-closes:** After the first user registers (admin), registration is closed by default
|
||||
- [x] **Registration toggle:** Admin can open/close registration from `/admin/users`
|
||||
- [x] **Password confirmation:** Register form requires password confirmation with inline validation
|
||||
- [x] **Admin user management:** `/admin/users` view with user list and delete (admin only)
|
||||
- [x] **Session cookie hardening:** `HttpOnly`, `SameSite=Lax` always on; `Secure` via `SECURE_COOKIES` env var
|
||||
- [x] **Default SECRET_KEY warning:** Logs WARNING on startup if default key is in use
|
||||
- [x] **Password change:** `PUT /api/auth/password` endpoint + Settings UI section
|
||||
- [x] **Production deployment docs:** README documents reverse proxy, rate limiting, CSP headers, SECRET_KEY, registration behavior
|
||||
|
||||
### Future / Stretch
|
||||
- Tagging/labeling system with LLM-suggested tags
|
||||
- Calendar/timeline view for tasks
|
||||
@@ -598,7 +624,7 @@ When adding a new migration, follow these conventions:
|
||||
- To reset database: `docker compose down -v && docker compose up --build`
|
||||
|
||||
## Current Status
|
||||
**Phase:** Phase 5.2 complete. Tiptap Inline-Preview Editor + Layout Improvements.
|
||||
**Phase:** Phase 5.3 complete. Registration Control, User Management, Security Hardening.
|
||||
- Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags
|
||||
- **Tiptap WYSIWYG editor** with inline formatting preview, markdown round-trip, paste handling
|
||||
- **Tag/wikilink autocomplete** via `@tiptap/suggestion` with heading disambiguation
|
||||
@@ -611,7 +637,10 @@ When adding a new migration, follow these conventions:
|
||||
- Context pills with promote/exclude controls; note picker in chat input
|
||||
- Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations as notes
|
||||
- Dedicated `/chat` page with responsive sidebar (overlay on mobile)
|
||||
- Settings page: assistant name, model catalog, data export/restore (admin)
|
||||
- Settings page: assistant name, model catalog, password change, data export/restore (admin)
|
||||
- **Registration control**: auto-closes after first user, admin toggle, password confirmation
|
||||
- **Admin user management**: `/admin/users` with user list + delete
|
||||
- **Session cookie hardening**: HttpOnly, SameSite=Lax, optional Secure flag
|
||||
- **App-wide layout fix**: navbar always visible, all views fit within viewport
|
||||
- **Responsive design**: hamburger menu, mobile touch targets, responsive breakpoints
|
||||
- **Docker Swarm production stack** with secrets, network isolation, health checks, resource limits
|
||||
|
||||
Reference in New Issue
Block a user