first commit

This commit is contained in:
domrichardson
2026-03-24 16:03:04 +00:00
commit df40cc57e1
80 changed files with 16766 additions and 0 deletions

1081
frontend/src/App.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
:root {
--primary-color: #667eea;
--secondary-color: #764ba2;
--text-color: #333;
--bg-color: #f8f9fa;
--border-color: #dee2e6;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
color: var(--text-color);
background-color: var(--bg-color);
}
html,
body,
#app {
height: 100%;
width: 100%;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}

View File

@@ -0,0 +1,254 @@
<template>
<teleport to="body">
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-xl modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit Space</h5>
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
</div>
<div class="modal-body">
<div class="row g-3 mb-4">
<div class="col-md-5">
<label class="form-label">Name</label>
<input v-model="form.name" type="text" class="form-control" />
</div>
<div class="col-md-5">
<label class="form-label">Description</label>
<input v-model="form.description" type="text" class="form-control" />
</div>
<div class="col-md-2">
<label class="form-label">Icon</label>
<input v-model="form.icon" type="text" class="form-control" maxlength="20" />
</div>
<div class="col-12 d-flex justify-content-between align-items-center">
<div class="form-check form-switch">
<input id="admin-space-public" v-model="form.is_public" class="form-check-input" type="checkbox" />
<label for="admin-space-public" class="form-check-label">Public space</label>
</div>
<button class="btn btn-primary" :disabled="savingSpace" @click="saveSpace">
{{ savingSpace ? "Saving..." : "Save Space" }}
</button>
</div>
</div>
<hr />
<div class="d-flex justify-content-between align-items-center mt-3 mb-2">
<h6 class="mb-0">Members</h6>
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingMembers" @click="loadMembers">Refresh</button>
</div>
<form class="row g-2 align-items-end mb-3" @submit.prevent="addMember">
<div class="col-md-10">
<label class="form-label form-label-sm mb-1">Username</label>
<select v-model="newMember.user_id" class="form-select form-select-sm" required>
<option disabled value="">Select user</option>
<option v-for="u in selectableUsers" :key="u.id" :value="u.id">{{ u.username }}</option>
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary btn-sm w-100" :disabled="addingMember">
{{ addingMember ? "..." : "Add" }}
</button>
</div>
</form>
<div v-if="loadingMembers" class="text-muted small">Loading members...</div>
<div v-else-if="members.length === 0" class="text-muted small">No members found.</div>
<div v-else class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Username</th>
<th>Joined</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="m in members" :key="m.user_id">
<td>{{ m.username || m.user_id }}</td>
<td class="small text-muted">{{ formatDate(m.joined_at) }}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" :disabled="removingMemberId === m.user_id" @click="removeMember(m)">
{{ removingMemberId === m.user_id ? "Removing..." : "Remove" }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
<div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div>
<hr />
<div class="border border-danger rounded p-3 mt-3">
<h6 class="text-danger mb-1">Danger Zone</h6>
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p>
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace">
{{ deleting ? "Deleting..." : "Delete Space" }}
</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
</teleport>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import apiClient from "../services/apiClient";
const props = defineProps({
space: {
type: Object,
required: true,
},
users: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["close", "saved", "deleted"]);
const form = ref({
name: "",
description: "",
icon: "",
is_public: false,
});
const members = ref([]);
const loadingMembers = ref(false);
const savingSpace = ref(false);
const addingMember = ref(false);
const removingMemberId = ref("");
const error = ref("");
const success = ref("");
const newMember = ref({ user_id: "" });
const deleting = ref(false);
const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-");
const clearMessages = () => {
error.value = "";
success.value = "";
};
const resetFormFromSpace = () => {
form.value = {
name: props.space?.name || "",
description: props.space?.description || "",
icon: props.space?.icon || "",
is_public: !!props.space?.is_public,
};
};
const selectableUsers = computed(() => {
const memberIds = new Set(members.value.map((m) => m.user_id));
return (props.users || []).filter((u) => !memberIds.has(u.id));
});
const loadMembers = async () => {
loadingMembers.value = true;
clearMessages();
try {
const response = await apiClient.get(`/api/v1/admin/spaces/${props.space.id}/members`);
members.value = response.data.members || [];
} catch (e) {
error.value = e.response?.data || "Failed to load members.";
} finally {
loadingMembers.value = false;
}
};
const saveSpace = async () => {
savingSpace.value = true;
clearMessages();
try {
const response = await apiClient.put(`/api/v1/admin/spaces/${props.space.id}`, {
name: form.value.name,
description: form.value.description,
icon: form.value.icon,
is_public: form.value.is_public,
});
success.value = "Space updated.";
emit("saved", response.data);
} catch (e) {
error.value = e.response?.data || "Failed to update space.";
} finally {
savingSpace.value = false;
}
};
const addMember = async () => {
if (!newMember.value.user_id) {
return;
}
addingMember.value = true;
clearMessages();
try {
await apiClient.post(`/api/v1/admin/spaces/${props.space.id}/members`, {
user_id: newMember.value.user_id,
});
success.value = "Member added.";
newMember.value = { user_id: "" };
await loadMembers();
} catch (e) {
error.value = e.response?.data || "Failed to add member.";
} finally {
addingMember.value = false;
}
};
const removeMember = async (member) => {
const memberName = member?.username || member?.user_id;
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) {
return;
}
removingMemberId.value = member.user_id;
clearMessages();
try {
await apiClient.delete(`/api/v1/admin/spaces/${props.space.id}/members/${member.user_id}`);
success.value = "Member removed.";
await loadMembers();
} catch (e) {
error.value = e.response?.data || "Failed to remove member.";
} finally {
removingMemberId.value = "";
}
};
watch(
() => props.space,
async () => {
resetFormFromSpace();
await loadMembers();
},
{ immediate: true },
);
const deleteSpace = async () => {
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) {
return;
}
deleting.value = true;
clearMessages();
try {
await apiClient.delete(`/api/v1/admin/spaces/${props.space.id}`);
emit("deleted", props.space);
} catch (e) {
error.value = e.response?.data || "Failed to delete space.";
} finally {
deleting.value = false;
}
};
</script>

View File

@@ -0,0 +1,278 @@
<template>
<div class="category-tree">
<div v-for="category in categories" :key="category.id" class="category-item">
<div class="category-header" @click="handleCategoryClick(category)">
<span class="expand-icon" v-if="category.subcategories?.length || category.notes?.length">
<i :class="expandedCategories[category.id] ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'" aria-hidden="true"></i>
</span>
<span v-else class="expand-icon"> </span>
<span class="category-name">{{ category.name }}</span>
<div v-if="canAnyCategoryAction" class="category-actions">
<button class="menu-button" type="button" @click.stop="toggleMenu(category.id)">
<i class="mdi mdi-dots-horizontal" aria-hidden="true"></i>
</button>
<div v-if="openMenuId === category.id" class="menu-dropdown">
<button v-if="canCreateCategories" type="button" class="menu-item" @click.stop="handleAddSubcategory(category)">Add subcategory</button>
<button v-if="canEditCategories" type="button" class="menu-item" @click.stop="handleEditCategory(category)">Edit</button>
<button v-if="canDeleteCategories" type="button" class="menu-item danger" @click.stop="handleDeleteCategory(category)">Delete</button>
</div>
</div>
</div>
<div v-if="expandedCategories[category.id]" class="category-content">
<div
v-for="note in sortedNotes(category.notes)"
:key="note.id"
class="note-item"
:class="{ 'is-featured': note.is_favorite || note.is_featured, 'is-pinned': note.is_pinned }"
@click.stop="onSelectNote(note)"
>
<i class="mdi mdi-file-document-outline me-1" aria-hidden="true"></i>
<span>{{ note.title }}</span>
<i v-if="note.is_pinned" class="mdi mdi-pin pin-icon me-1" aria-hidden="true"></i>
<i v-else-if="note.is_favorite || note.is_featured" class="mdi mdi-star featured-icon me-1" aria-hidden="true"></i>
</div>
<CategoryTree
v-if="category.subcategories?.length"
:categories="category.subcategories"
:on-select-note="onSelectNote"
:on-select-category="onSelectCategory"
:on-add-subcategory="onAddSubcategory"
:on-edit-category="onEditCategory"
:on-delete-category="onDeleteCategory"
:can-create-categories="canCreateCategories"
:can-edit-categories="canEditCategories"
:can-delete-categories="canDeleteCategories"
class="subcategories"
/>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from "vue";
import { sortNotesByPriority } from "../utils/noteSort";
const props = defineProps({
categories: {
type: Array,
default: () => [],
},
onSelectNote: {
type: Function,
required: true,
},
onSelectCategory: {
type: Function,
required: true,
},
onAddSubcategory: {
type: Function,
required: true,
},
onEditCategory: {
type: Function,
required: true,
},
onDeleteCategory: {
type: Function,
required: true,
},
canCreateCategories: {
type: Boolean,
default: false,
},
canEditCategories: {
type: Boolean,
default: false,
},
canDeleteCategories: {
type: Boolean,
default: false,
},
});
const canAnyCategoryAction = computed(() => props.canCreateCategories || props.canEditCategories || props.canDeleteCategories);
const expandedCategories = ref({});
const openMenuId = ref(null);
const sortedNotes = (notes) => sortNotesByPriority(notes || []);
const toggleCategory = (categoryId) => {
expandedCategories.value[categoryId] = !expandedCategories.value[categoryId];
};
const toggleMenu = (categoryId) => {
openMenuId.value = openMenuId.value === categoryId ? null : categoryId;
};
const handleCategoryClick = (category) => {
props.onSelectCategory(category);
toggleCategory(category.id);
openMenuId.value = null;
};
const handleAddSubcategory = (category) => {
if (!props.canCreateCategories) {
return;
}
openMenuId.value = null;
expandedCategories.value[category.id] = true;
props.onAddSubcategory(category);
};
const handleEditCategory = (category) => {
if (!props.canEditCategories) {
return;
}
openMenuId.value = null;
props.onEditCategory(category);
};
const handleDeleteCategory = (category) => {
if (!props.canDeleteCategories) {
return;
}
openMenuId.value = null;
props.onDeleteCategory(category);
};
</script>
<style scoped>
.category-item {
margin-bottom: 0.25rem;
}
.category-header {
display: flex;
align-items: center;
padding: 0.5rem;
cursor: pointer;
user-select: none;
border-radius: 4px;
}
.category-header:hover {
background-color: #e9ecef;
}
.expand-icon {
width: 20px;
text-align: center;
font-size: 0.875rem;
}
.category-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-actions {
position: relative;
}
.menu-button {
border: 0;
background: transparent;
width: 28px;
height: 28px;
border-radius: 6px;
color: #495057;
}
.menu-button:hover {
background-color: rgba(0, 0, 0, 0.08);
}
.menu-dropdown {
position: absolute;
top: 30px;
right: 0;
min-width: 150px;
padding: 0.35rem;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.15);
z-index: 5;
}
.menu-item {
display: block;
width: 100%;
border: 0;
background: transparent;
text-align: left;
padding: 0.45rem 0.6rem;
border-radius: 0.35rem;
}
.menu-item:hover {
background-color: #f1f3f5;
}
.menu-item.danger {
color: #c92a2a;
}
.category-content {
padding-left: 1rem;
}
.note-item {
display: flex;
align-items: center;
padding: 0.5rem;
cursor: pointer;
border-radius: 4px;
font-size: 16px;
margin-bottom: 0.25rem;
}
.note-item:hover {
background-color: #e9ecef;
}
.note-item span {
flex-grow: 1;
}
.pin-icon {
color: #408aca;
font-size: 0.9em;
flex-shrink: 0;
}
.featured-icon {
color: #f08c00;
font-size: 0.9em;
flex-shrink: 0;
}
.note-item.is-pinned {
background: #dbf5ff;
border: 1px solid #a8d1ff;
}
.note-item.is-pinned:hover {
background: #c5e9ff;
}
.note-item.is-featured {
background: #fff9db;
border: 1px solid #ffd8a8;
}
.note-item.is-featured:hover {
background: #fff6c5;
}
.subcategories {
margin-top: 0.25rem;
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<teleport to="body">
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeModal">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ isEditing ? "Edit Category" : "Create New Category" }}</h5>
<button type="button" class="btn-close" aria-label="Close" @click="closeModal"></button>
</div>
<div class="modal-body">
<form @submit.prevent="handleSubmit">
<div class="mb-3">
<label for="catName" class="form-label">Category Name</label>
<input id="catName" v-model="form.name" type="text" class="form-control" required />
</div>
<div class="mb-3">
<label for="catDesc" class="form-label">Description</label>
<textarea id="catDesc" v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="mb-3">
<label for="catParent" class="form-label">Parent Category</label>
<select id="catParent" v-model="form.parent_id" class="form-select">
<option :value="null">No parent</option>
<option v-for="category in parentOptions" :key="category.id" :value="category.id">
{{ category.name }}
</option>
</select>
</div>
<button type="submit" class="btn btn-primary">{{ isEditing ? "Save" : "Create" }}</button>
</form>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
</teleport>
</template>
<script setup>
import { computed, ref, watch } from "vue";
const props = defineProps({
category: {
type: Object,
default: null,
},
parentOptions: {
type: Array,
default: () => [],
},
parentId: {
type: String,
default: null,
},
});
const emit = defineEmits(["close", "submit"]);
const form = ref({
name: "",
description: "",
parent_id: null,
});
const isEditing = computed(() => !!props.category);
watch(
() => [props.category, props.parentId],
() => {
form.value = {
name: props.category?.name || "",
description: props.category?.description || "",
parent_id: props.category?.parent_id ?? props.parentId ?? null,
};
},
{ immediate: true },
);
const closeModal = () => {
emit("close");
};
const handleSubmit = () => {
if (form.value.name.trim()) {
emit("submit", {
...form.value,
parent_id: form.value.parent_id || null,
});
}
};
</script>
<style scoped></style>

View File

@@ -0,0 +1,156 @@
<template>
<teleport to="body">
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeModal">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Create New Note</h5>
<button type="button" class="btn-close" aria-label="Close" @click="closeModal"></button>
</div>
<div class="modal-body">
<form @submit.prevent="handleCreate">
<div class="mb-3">
<label for="noteTitle" class="form-label">Note Title</label>
<input id="noteTitle" v-model="form.title" type="text" class="form-control" required />
</div>
<div class="mb-3">
<label for="noteContent" class="form-label">Content</label>
<textarea id="noteContent" v-model="form.content" class="form-control" rows="5"></textarea>
</div>
<div class="mb-3">
<label for="noteDescription" class="form-label">Description</label>
<textarea id="noteDescription" v-model="form.description" class="form-control" rows="2" maxlength="500" placeholder="Short summary shown in note lists"></textarea>
</div>
<div class="mb-3">
<label for="noteCategory" class="form-label">Category</label>
<select id="noteCategory" v-model="form.category_id" class="form-select">
<option :value="null">Uncategorized</option>
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">
{{ category.label }}
</option>
</select>
</div>
<div class="note-flags mb-3">
<label class="form-check flag-check">
<input v-model="form.is_pinned" class="form-check-input" type="checkbox" />
<span class="form-check-label">Pinned</span>
</label>
<label class="form-check flag-check">
<input v-model="form.is_favorite" class="form-check-input" type="checkbox" />
<span class="form-check-label">Featured</span>
</label>
<label v-if="publicSharingEnabled" class="form-check flag-check">
<input v-model="form.is_public" class="form-check-input" type="checkbox" />
<span class="form-check-label">Public</span>
</label>
<label class="form-check flag-check">
<input v-model="form.is_password_protected" class="form-check-input" type="checkbox" />
<span class="form-check-label">Password Protected</span>
</label>
</div>
<div v-if="form.is_password_protected" class="mb-3">
<label for="notePassword" class="form-label">Note Password</label>
<input id="notePassword" v-model="form.note_password" type="password" class="form-control" minlength="4" maxlength="128" required />
</div>
<button type="submit" class="btn btn-primary">Create</button>
</form>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
</teleport>
</template>
<script setup>
import { onMounted, ref, watch } from "vue";
import { useSettingsStore } from "../stores/settingsStore";
const props = defineProps({
categoryOptions: {
type: Array,
default: () => [],
},
defaultCategoryId: {
type: String,
default: null,
},
});
const emit = defineEmits(["close", "create"]);
const settingsStore = useSettingsStore();
const publicSharingEnabled = ref(true);
const form = ref({
title: "",
description: "",
content: "",
category_id: null,
is_pinned: false,
is_favorite: false,
is_public: false,
is_password_protected: false,
note_password: "",
});
watch(
() => props.defaultCategoryId,
(defaultCategoryId) => {
form.value.category_id = defaultCategoryId || null;
},
{ immediate: true },
);
onMounted(async () => {
await settingsStore.loadFeatureFlags();
publicSharingEnabled.value = settingsStore.publicSharingEnabled;
if (!publicSharingEnabled.value) {
form.value.is_public = false;
}
});
const closeModal = () => {
emit("close");
};
const handleCreate = () => {
if (form.value.title.trim()) {
const { is_password_protected, ...payload } = form.value;
emit("create", {
...payload,
category_id: payload.category_id || null,
note_password: is_password_protected ? payload.note_password || "" : "",
});
form.value = {
title: "",
description: "",
content: "",
category_id: props.defaultCategoryId || null,
is_pinned: false,
is_favorite: false,
is_public: false,
is_password_protected: false,
note_password: "",
};
}
};
</script>
<style scoped>
.note-flags {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.flag-check {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<teleport to="body">
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeModal">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Create New Space</h5>
<button type="button" class="btn-close" aria-label="Close" @click="closeModal"></button>
</div>
<div class="modal-body">
<form @submit.prevent="handleCreate">
<div class="mb-3">
<label for="spaceName" class="form-label">Space Name</label>
<input id="spaceName" v-model="form.name" type="text" class="form-control" required />
</div>
<div class="mb-3">
<label for="spaceDesc" class="form-label">Description</label>
<textarea id="spaceDesc" v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<button type="submit" class="btn btn-primary">Create</button>
</form>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
</teleport>
</template>
<script setup>
import { ref } from "vue";
const emit = defineEmits(["close", "create"]);
const form = ref({
name: "",
description: "",
});
const closeModal = () => {
emit("close");
};
const handleCreate = () => {
if (form.value.name.trim()) {
emit("create", form.value);
form.value = { name: "", description: "" };
}
};
</script>
<style scoped></style>

View File

@@ -0,0 +1,265 @@
<template>
<teleport to="body">
<div class="modal-backdrop-custom" @click.self="$emit('close')">
<div class="modal-panel">
<div class="provider-modal-header">
<div>
<h5 class="provider-modal-title mb-1">Identity Providers</h5>
<p class="text-muted mb-0">Configure OAuth2 and OIDC buttons for the login page.</p>
</div>
<button type="button" class="btn-close provider-modal-close" @click="$emit('close')"></button>
</div>
<div class="provider-modal-body">
<div v-if="error" class="alert alert-danger">{{ error }}</div>
<div v-if="successMessage" class="alert alert-success">{{ successMessage }}</div>
<section class="provider-section">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Configured Providers</h6>
<button class="btn btn-sm btn-outline-secondary" :disabled="loading" @click="loadProviders">Refresh</button>
</div>
<div v-if="loading" class="text-muted small">Loading providers...</div>
<div v-else-if="providers.length === 0" class="border rounded p-3 text-muted">No providers configured yet.</div>
<div v-else class="list-group">
<div v-for="provider in providers" :key="provider.id" class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{ provider.name }}</div>
<div class="small text-muted">{{ provider.type.toUpperCase() }} · {{ provider.scopes.join(", ") }}</div>
<div class="small text-muted">Callback: {{ buildCallbackUrl(provider.id) }}</div>
</div>
<span class="badge" :class="provider.is_active ? 'text-bg-success' : 'text-bg-secondary'">
{{ provider.is_active ? "Active" : "Disabled" }}
</span>
</div>
</div>
</section>
<section class="provider-section">
<h6 class="mb-3">Add Provider</h6>
<form class="row g-3" @submit.prevent="createProvider">
<div class="col-md-6">
<label class="form-label">Display Name</label>
<input v-model="form.name" type="text" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">Provider Type</label>
<select v-model="form.type" class="form-select">
<option value="oidc">OIDC</option>
<option value="oauth2">OAuth2</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Client ID</label>
<input v-model="form.client_id" type="text" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">Client Secret</label>
<input v-model="form.client_secret" type="password" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">Authorization URL</label>
<input v-model="form.authorization_url" type="url" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">Token URL</label>
<input v-model="form.token_url" type="url" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">UserInfo URL</label>
<input v-model="form.userinfo_url" type="url" class="form-control" placeholder="Optional when id_token contains profile claims" />
</div>
<div class="col-md-6">
<label class="form-label">ID Token Field</label>
<input v-model="form.id_token_claim" type="text" class="form-control" placeholder="id_token" />
</div>
<div class="col-12">
<label class="form-label">Scopes</label>
<input v-model="form.scopes" type="text" class="form-control" placeholder="openid, profile, email" />
</div>
<div class="col-12 form-check ms-2">
<input id="provider-active" v-model="form.is_active" type="checkbox" class="form-check-input" />
<label for="provider-active" class="form-check-label">Provider is active</label>
</div>
<div class="col-12 d-flex justify-content-end gap-2">
<button type="button" class="btn btn-outline-secondary" @click="$emit('close')">Close</button>
<button type="submit" class="btn btn-primary" :disabled="submitting">
{{ submitting ? "Saving..." : "Add Provider" }}
</button>
</div>
</form>
</section>
</div>
</div>
</div>
</teleport>
</template>
<script setup>
import { onMounted, ref } from "vue";
import apiClient from "../services/apiClient";
defineEmits(["close"]);
const loading = ref(false);
const submitting = ref(false);
const error = ref("");
const successMessage = ref("");
const providers = ref([]);
const form = ref({
name: "",
type: "oidc",
client_id: "",
client_secret: "",
authorization_url: "",
token_url: "",
userinfo_url: "",
id_token_claim: "id_token",
scopes: "openid, profile, email",
is_active: true,
});
const loadProviders = async () => {
loading.value = true;
error.value = "";
try {
const response = await apiClient.get("/api/v1/auth/providers");
providers.value = response.data.providers || [];
} catch (err) {
error.value = err.response?.data || err.message;
} finally {
loading.value = false;
}
};
const resetForm = () => {
form.value = {
name: "",
type: "oidc",
client_id: "",
client_secret: "",
authorization_url: "",
token_url: "",
userinfo_url: "",
id_token_claim: "id_token",
scopes: "openid, profile, email",
is_active: true,
};
};
const buildCallbackUrl = (providerId) => `${apiClient.defaults.baseURL}/api/v1/auth/providers/${providerId}/callback`;
const createProvider = async () => {
submitting.value = true;
error.value = "";
successMessage.value = "";
try {
await apiClient.post("/api/v1/auth/providers", {
...form.value,
scopes: form.value.scopes
.split(",")
.map((scope) => scope.trim())
.filter(Boolean),
});
successMessage.value = "Provider added.";
resetForm();
await loadProviders();
} catch (err) {
error.value = err.response?.data || err.message;
} finally {
submitting.value = false;
}
};
onMounted(loadProviders);
</script>
<style scoped>
.modal-backdrop-custom {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1.5rem;
}
.modal-panel {
width: min(920px, 100%);
max-height: min(92vh, 980px);
background: #fff;
border: 1px solid #dbe3ee;
border-radius: 14px;
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.2);
overflow: hidden;
display: flex;
flex-direction: column;
}
.provider-modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid #e5e7eb;
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
}
.provider-modal-title {
margin: 0;
font-weight: 600;
}
.provider-modal-close {
flex-shrink: 0;
}
.provider-modal-body {
padding: 1rem 1.25rem 1.25rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.provider-section {
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #f8fafc;
padding: 0.9rem;
}
.provider-list {
max-height: 220px;
overflow: auto;
}
@media (max-width: 768px) {
.modal-backdrop-custom {
padding: 0.75rem;
}
.provider-modal-header,
.provider-modal-body {
padding-left: 0.85rem;
padding-right: 0.85rem;
}
}
</style>

View File

@@ -0,0 +1,287 @@
<template>
<div class="note-editor">
<div class="editor-toolbar mb-3">
<button class="btn btn-sm btn-primary" @click="saveNote">Save</button>
<button v-if="canDelete" class="btn btn-sm btn-danger ms-2" @click="confirmDelete">Delete</button>
<button class="btn btn-sm btn-outline-secondary ms-2" @click="emit('cancel')">Cancel</button>
<button class="btn btn-sm btn-secondary ms-2" @click="togglePreview">
{{ showPreview ? "Edit" : "Preview" }}
</button>
<span class="save-status ms-auto" :class="saveState">{{ saveStatusLabel }}</span>
</div>
<div class="editor-container">
<input v-model="editingNote.title" type="text" class="form-control form-control-lg mb-3" placeholder="Note title..." @input="autoSave" />
<div class="mb-3">
<label class="form-label">Description</label>
<textarea v-model="editingNote.description" class="form-control" rows="2" maxlength="500" placeholder="Short summary shown in note lists..." @input="autoSave"></textarea>
</div>
<div class="row">
<div :class="{ 'col-md-6': showPreview, 'col-12': !showPreview }">
<textarea v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
</div>
<div v-if="showPreview" class="col-md-6">
<div class="preview-pane border rounded p-3">
<div v-html="renderedMarkdown"></div>
</div>
</div>
</div>
<div class="mt-3">
<label class="form-label">Tags</label>
<input v-model="tagsInput" type="text" class="form-control" placeholder="Add tags separated by commas" />
</div>
<div class="mt-3">
<label class="form-label">Category</label>
<select v-model="editingNote.category_id" class="form-select" @change="autoSave">
<option :value="null">Uncategorized</option>
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">
{{ category.label }}
</option>
</select>
</div>
<div class="note-flags mt-3">
<label class="flag-check">
<input v-model="editingNote.is_pinned" class="flag-check-input" type="checkbox" @change="autoSave" />
<span class="flag-check-label">Pinned</span>
</label>
<label class="flag-check">
<input v-model="editingNote.is_favorite" class="flag-check-input" type="checkbox" @change="autoSave" />
<span class="flag-check-label">Featured</span>
</label>
<label v-if="publicSharingEnabled" class="flag-check">
<input v-model="editingNote.is_public" class="flag-check-input" type="checkbox" @change="autoSave" />
<span class="flag-check-label">Public</span>
</label>
</div>
<div class="mt-3">
<label class="form-label">Password Protection</label>
<select v-model="passwordAction" class="form-select">
<option value="keep">Keep current setting</option>
<option value="set">Set or change password</option>
<option value="remove">Remove password protection</option>
</select>
<input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onBeforeUnmount, onMounted } from "vue";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { useSettingsStore } from "../stores/settingsStore";
const props = defineProps({
note: {
type: Object,
required: true,
},
categoryOptions: {
type: Array,
default: () => [],
},
canDelete: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(["save", "delete", "cancel"]);
const settingsStore = useSettingsStore();
const publicSharingEnabled = ref(true);
const editingNote = ref({ ...props.note });
const showPreview = ref(false);
const tagsInput = ref(props.note.tags?.join(", ") || "");
const passwordAction = ref("keep");
const notePassword = ref("");
const saveTimeout = ref(null);
const saveState = ref("saved");
const saveStateTimeout = ref(null);
const renderedMarkdown = computed(() => {
const html = marked.parse(editingNote.value.content || "");
return DOMPurify.sanitize(html);
});
const saveStatusLabel = computed(() => {
switch (saveState.value) {
case "dirty":
return "Unsaved changes";
case "saving":
return "Saving...";
case "saved":
default:
return "Saved";
}
});
watch(
() => props.note,
(newNote) => {
editingNote.value = { ...newNote };
tagsInput.value = newNote.tags?.join(", ") || "";
passwordAction.value = "keep";
notePassword.value = "";
saveState.value = "saved";
},
);
watch(tagsInput, () => {
if (editingNote.value.id) {
autoSave();
}
});
const markSavedSoon = () => {
clearTimeout(saveStateTimeout.value);
saveStateTimeout.value = setTimeout(() => {
saveState.value = "saved";
}, 250);
};
const saveNote = () => {
if (passwordAction.value === "set") {
if (!notePassword.value.trim()) {
alert("Please enter a note password.");
return;
}
if (notePassword.value.trim().length < 4) {
alert("Note password must be at least 4 characters.");
return;
}
}
saveState.value = "saving";
const note = {
...editingNote.value,
category_id: editingNote.value.category_id || null,
tags: tagsInput.value
.split(",")
.map((t) => t.trim())
.filter((t) => t),
};
if (passwordAction.value === "set") {
note.note_password = notePassword.value;
} else if (passwordAction.value === "remove") {
note.note_password = "";
}
emit("save", note);
if (passwordAction.value !== "keep") {
passwordAction.value = "keep";
notePassword.value = "";
}
markSavedSoon();
};
const autoSave = () => {
saveState.value = "dirty";
clearTimeout(saveTimeout.value);
saveTimeout.value = setTimeout(saveNote, 3000);
};
const confirmDelete = () => {
if (!props.canDelete) {
return;
}
if (confirm("Are you sure you want to delete this note?")) {
emit("delete", editingNote.value.id);
}
};
const togglePreview = () => {
showPreview.value = !showPreview.value;
};
onBeforeUnmount(() => {
clearTimeout(saveTimeout.value);
clearTimeout(saveStateTimeout.value);
});
onMounted(async () => {
await settingsStore.loadFeatureFlags();
publicSharingEnabled.value = settingsStore.publicSharingEnabled;
});
</script>
<style scoped>
.editor-toolbar {
display: flex;
gap: 0.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #dee2e6;
}
.save-status {
display: inline-flex;
align-items: center;
font-size: 0.85rem;
color: #6c757d;
}
.save-status.dirty {
color: #b26a00;
}
.save-status.saving {
color: #0d6efd;
}
.save-status.saved {
color: #2b8a3e;
}
.editor-textarea {
font-family: "Courier New", monospace;
min-height: 400px;
resize: vertical;
}
.note-flags {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.flag-check {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.75rem;
border: 1px solid #dee2e6;
border-radius: 999px;
background: #f8f9fa;
margin: 0;
cursor: pointer;
}
.flag-check-input {
margin: 0;
width: 1.1rem;
height: 1.1rem;
cursor: pointer;
}
.flag-check-label {
line-height: 1;
user-select: none;
}
.preview-pane {
background-color: #f8f9fa;
overflow-y: auto;
max-height: 600px;
}
</style>

View File

@@ -0,0 +1,221 @@
<template>
<div class="note-list" :class="{ 'note-list--list': viewMode === 'list' }">
<div v-if="notes.length === 0" class="empty-notes-state" role="status" aria-live="polite">
<i class="mdi mdi-file-document-outline empty-notes-icon" aria-hidden="true"></i>
<h3 class="empty-notes-title">No Notes Yet</h3>
<p class="empty-notes-message">This space is empty for now. Create your first note to get started.</p>
</div>
<div v-for="note in notes" :key="note.id" class="note-card" :class="{ 'is-pinned': note.is_pinned, 'is-featured': note.is_favorite || note.is_featured }" @click="selectNote(note)">
<h5 class="note-title">
<i v-if="note.is_pinned" class="mdi mdi-pin pin-icon" aria-hidden="true"></i>
<i v-else-if="note.is_favorite || note.is_featured" class="mdi mdi-star featured-icon" aria-hidden="true"></i>
{{ note.title }}
</h5>
<p class="note-preview">{{ getDescription(note) }}</p>
<small class="text-muted">Updated: {{ formatDate(note.updated_at) }}</small>
</div>
<div v-if="canLoadMore" class="list-footer">
<button class="btn btn-outline-secondary" :disabled="isLoadingMore" @click="emit('loadMore')">
{{ isLoadingMore ? "Loading..." : "Load more" }}
</button>
</div>
</div>
</template>
<script setup>
defineProps({
notes: {
type: Array,
default: () => [],
},
canLoadMore: {
type: Boolean,
default: false,
},
isLoadingMore: {
type: Boolean,
default: false,
},
viewMode: {
type: String,
default: "grid",
},
});
const emit = defineEmits(["selectNote", "loadMore"]);
const selectNote = (note) => {
emit("selectNote", note);
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString();
};
const getDescription = (note) => {
const description = (note?.description || "").trim();
if (!description) {
return "No description";
}
return description;
};
</script>
<style scoped>
.note-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.empty-notes-state {
grid-column: 1 / -1;
min-height: 48vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
border: 1px dashed #cfd6e4;
border-radius: 14px;
background: linear-gradient(180deg, #f8f9fc 0%, #eef3fb 100%);
padding: 2rem 1.5rem;
}
.empty-notes-icon {
font-size: 5.25rem;
line-height: 1;
color: #60789a;
margin-bottom: 0.85rem;
}
.empty-notes-title {
margin: 0;
color: #23364f;
font-size: 1.8rem;
font-weight: 700;
}
.empty-notes-message {
margin: 0.75rem 0 0;
max-width: 460px;
color: #4f637d;
font-size: 1.05rem;
}
.note-card {
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1rem;
cursor: pointer;
transition: all 0.3s ease;
}
.note-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.note-title {
margin-bottom: 0.5rem;
color: #333;
display: flex;
align-items: center;
gap: 0.3rem;
}
.pin-icon {
color: #408aca;
font-size: 0.9em;
flex-shrink: 0;
}
.featured-icon {
color: #f08c00;
font-size: 0.95em;
flex-shrink: 0;
}
.note-card.is-pinned {
background: #dbf5ff;
border-color: #a8d1ff;
}
.note-card.is-featured {
border-color: #ffd8a8;
background: #fff9db;
}
.note-preview {
color: #666;
margin-bottom: 0.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-footer {
grid-column: 1 / -1;
display: flex;
justify-content: center;
margin-top: 0.5rem;
}
/* List view overrides */
.note-list--list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.note-list--list .note-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
border-radius: 6px;
}
.note-list--list .note-card:hover {
transform: none;
}
.note-list--list .note-title {
flex: 0 0 220px;
margin-bottom: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.note-list--list .note-preview {
flex: 1;
margin-bottom: 0;
}
.note-list--list .note-card > small {
flex: 0 0 auto;
white-space: nowrap;
}
.note-list--list .list-footer {
grid-column: unset;
}
@media (max-width: 768px) {
.empty-notes-state {
min-height: 40vh;
padding: 1.5rem 1rem;
}
.empty-notes-icon {
font-size: 4.3rem;
}
.empty-notes-title {
font-size: 1.45rem;
}
}
</style>

View File

@@ -0,0 +1,175 @@
<template>
<article class="note-viewer">
<header class="note-meta mb-4">
<div class="d-flex flex-wrap gap-2 mb-3" v-if="note.tags?.length">
<span v-for="tag in note.tags" :key="tag" class="tag-chip"><i class="mdi mdi-tag me-1" aria-hidden="true"></i>{{ tag }}</span>
</div>
<div class="d-flex flex-wrap gap-2 mb-3" v-if="note.is_pinned || note.is_favorite || note.is_featured || typeof note.is_public === 'boolean'">
<div class="d-flex flex-wrap gap-2 mb-3" v-if="note.is_pinned || note.is_favorite || note.is_featured || typeof note.is_public === 'boolean' || note.is_password_protected">
<span v-if="note.is_pinned" class="state-chip pinned-chip">Pinned</span>
<span v-if="note.is_favorite || note.is_featured" class="state-chip featured-chip">Featured</span>
<span :class="['state-chip', note.is_public ? 'public-chip' : 'private-chip']">
<i :class="note.is_public ? 'mdi mdi-eye me-1' : 'mdi mdi-eye-off me-1'" aria-hidden="true"></i>
{{ note.is_public ? "Public" : "Private" }}
</span>
<span v-if="note.is_password_protected" class="state-chip protected-chip">
<i class="mdi mdi-lock-outline me-1" aria-hidden="true"></i>
Password Protected
</span>
</div>
</div>
<div class="meta-grid text-muted small">
<span>Updated {{ formatDateTime(note.updated_at) }}</span>
<span v-if="categoryLabel">Category: {{ categoryLabel }}</span>
</div>
</header>
<div class="markdown-body" v-html="renderedMarkdown"></div>
</article>
</template>
<script setup>
import { computed } from "vue";
import { marked } from "marked";
import DOMPurify from "dompurify";
const props = defineProps({
note: {
type: Object,
required: true,
},
categoryOptions: {
type: Array,
default: () => [],
},
});
const renderedMarkdown = computed(() => {
const html = marked.parse(props.note.content || "");
return DOMPurify.sanitize(html);
});
const categoryLabel = computed(() => {
const categoryId = props.note.category_id;
if (!categoryId) {
return "Uncategorized";
}
const matchedLabel = props.categoryOptions.find((category) => category.id === categoryId)?.label?.trim();
if (matchedLabel) {
return matchedLabel;
}
const directLabel = props.note.category_name?.trim();
if (directLabel) {
return directLabel;
}
// If we cannot resolve the category name (e.g., public view without category options),
// avoid showing an incorrect "Uncategorized" state.
if (!props.categoryOptions.length) {
return null;
}
return "Uncategorized";
});
const formatDateTime = (dateString) => new Date(dateString).toLocaleString();
</script>
<style scoped>
.note-viewer {
max-width: 900px;
}
.note-meta {
padding-bottom: 1rem;
border-bottom: 1px solid #e9ecef;
}
.meta-grid {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.tag-chip {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: #eef2ff;
color: #364fc7;
font-size: 0.8rem;
}
.state-chip {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.6rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
}
.pinned-chip {
color: #005f8f;
background: #dbf5ff;
border: 1px solid #a8d1ff;
}
.featured-chip {
color: #8d7619;
border: 1px solid #ffd8a8;
background: #fff9db;
}
.public-chip {
color: #0c5460;
border: 1px solid #a5d8ff;
background: #e7f5ff;
}
.private-chip {
color: #5f3dc4;
border: 1px solid #d0bfff;
background: #f3f0ff;
}
.protected-chip {
color: #7f5539;
border: 1px solid #e0c3a6;
background: #fff4e6;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.markdown-body :deep(p),
.markdown-body :deep(li) {
line-height: 1.7;
}
.markdown-body :deep(pre) {
padding: 1rem;
border-radius: 0.75rem;
background: #111827;
color: #f9fafb;
overflow-x: auto;
}
.markdown-body :deep(code) {
font-family: "Courier New", monospace;
}
.markdown-body :deep(blockquote) {
margin: 1rem 0;
padding: 0.75rem 1rem;
border-left: 4px solid #748ffc;
background: #f8f9ff;
}
</style>

View File

@@ -0,0 +1,269 @@
<template>
<teleport to="body">
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Space Settings</h5>
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Space name</label>
<input v-model="form.name" type="text" class="form-control" />
</div>
<div class="form-check form-switch mb-4">
<input id="spacePublicToggle" v-model="form.is_public" class="form-check-input" type="checkbox" />
<label class="form-check-label" for="spacePublicToggle">Public space</label>
</div>
<div class="d-flex justify-content-end mb-4">
<button class="btn btn-primary" :disabled="saving" @click="saveSettings">
{{ saving ? "Saving..." : "Save Settings" }}
</button>
</div>
<template v-if="canViewMembers">
<hr />
<div class="d-flex justify-content-between align-items-center mb-2 mt-3">
<h6 class="mb-0">Members</h6>
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingMembers" @click="loadMembers">Refresh</button>
</div>
<form class="row g-2 align-items-end mb-3" @submit.prevent="addMember">
<div class="col-md-10">
<label class="form-label form-label-sm mb-1">Username</label>
<select v-model="memberForm.user_id" class="form-select form-select-sm" required :disabled="!canManageMembers">
<option disabled value="">Select user</option>
<option v-for="user in userOptions" :key="user.id" :value="user.id">{{ user.username }}</option>
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary btn-sm w-100" :disabled="addingMember || !canManageMembers">
{{ addingMember ? "..." : "Add" }}
</button>
</div>
</form>
<div v-if="loadingMembers" class="text-muted small">Loading members...</div>
<div v-else-if="members.length === 0" class="text-muted small">No members found.</div>
<div v-else class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Username</th>
<th>Joined</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="member in members" :key="member.user_id">
<td class="small text-muted">{{ member.username || member.user_id }}</td>
<td class="small text-muted">{{ formatDate(member.joined_at) }}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" :disabled="!canManageMembers || removingMemberId === member.user_id" @click="removeMember(member)">
{{ removingMemberId === member.user_id ? "Removing..." : "Remove" }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<template v-if="canDeleteSpace">
<hr />
<div class="border border-danger rounded p-3 mt-3">
<h6 class="text-danger mb-1">Danger Zone</h6>
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p>
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace">
{{ deleting ? "Deleting..." : "Delete Space" }}
</button>
</div>
</template>
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
<div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
</teleport>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import apiClient from "../services/apiClient";
import { useAuthStore } from "../stores/authStore";
const props = defineProps({
space: {
type: Object,
required: true,
},
});
const emit = defineEmits(["close", "saved", "deleted"]);
const authStore = useAuthStore();
const deleting = ref(false);
const canDeleteSpace = computed(() => authStore.hasSpacePermission(props.space, "settings.delete"));
const form = ref({
name: props.space.name || "",
description: props.space.description || "",
icon: props.space.icon || "",
is_public: !!props.space.is_public,
});
const members = ref([]);
const userOptions = ref([]);
const loadingMembers = ref(false);
const saving = ref(false);
const addingMember = ref(false);
const removingMemberId = ref("");
const error = ref("");
const success = ref("");
const memberForm = ref({ user_id: "" });
const canViewMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.view"));
const canManageMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.manage"));
watch(
() => props.space,
(space) => {
form.value = {
name: space.name || "",
description: space.description || "",
icon: space.icon || "",
is_public: !!space.is_public,
};
},
{ immediate: true },
);
const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-");
const clearMessages = () => {
error.value = "";
success.value = "";
};
const loadMembers = async () => {
if (!canViewMembers.value) {
members.value = [];
return;
}
loadingMembers.value = true;
clearMessages();
try {
const response = await apiClient.get(`/api/v1/spaces/${props.space.id}/members`);
members.value = response.data.members || [];
} catch (e) {
error.value = e.response?.data || "Failed to load members.";
} finally {
loadingMembers.value = false;
}
};
const loadUserOptions = async () => {
if (!canManageMembers.value) {
userOptions.value = [];
return;
}
try {
const response = await apiClient.get(`/api/v1/spaces/${props.space.id}/available-users`);
userOptions.value = response.data.users || [];
} catch (e) {
error.value = e.response?.data || "Failed to load users.";
}
};
const saveSettings = async () => {
saving.value = true;
clearMessages();
try {
const response = await apiClient.put(`/api/v1/spaces/${props.space.id}`, {
name: form.value.name,
description: form.value.description,
icon: form.value.icon,
is_public: form.value.is_public,
});
success.value = "Space settings saved.";
emit("saved", response.data);
} catch (e) {
error.value = e.response?.data || "Failed to save settings.";
} finally {
saving.value = false;
}
};
const addMember = async () => {
if (!canManageMembers.value) {
return;
}
if (!memberForm.value.user_id) {
return;
}
addingMember.value = true;
clearMessages();
try {
await apiClient.post(`/api/v1/spaces/${props.space.id}/members`, {
user_id: memberForm.value.user_id,
});
success.value = "Member added.";
memberForm.value = { user_id: "" };
await Promise.all([loadMembers(), loadUserOptions()]);
} catch (e) {
error.value = e.response?.data || "Failed to add member.";
} finally {
addingMember.value = false;
}
};
const removeMember = async (member) => {
if (!canManageMembers.value) {
return;
}
const memberName = member?.username || member?.user_id;
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) {
return;
}
removingMemberId.value = member.user_id;
clearMessages();
try {
await apiClient.delete(`/api/v1/spaces/${props.space.id}/members/${member.user_id}`);
success.value = "Member removed.";
await Promise.all([loadMembers(), loadUserOptions()]);
} catch (e) {
error.value = e.response?.data || "Failed to remove member.";
} finally {
removingMemberId.value = "";
}
};
if (canViewMembers.value) {
Promise.all([loadMembers(), loadUserOptions()]);
}
const deleteSpace = async () => {
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) {
return;
}
deleting.value = true;
clearMessages();
try {
await apiClient.delete(`/api/v1/spaces/${props.space.id}`);
emit("deleted", props.space);
} catch (e) {
error.value = e.response?.data || "Failed to delete space.";
} finally {
deleting.value = false;
}
};
</script>

13
frontend/src/main.js Normal file
View File

@@ -0,0 +1,13 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import router from "./router";
import App from "./App.vue";
import "bootstrap/dist/css/bootstrap.min.css";
import "@mdi/font/css/materialdesignicons.min.css";
import "./assets/styles/main.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");

View File

@@ -0,0 +1,631 @@
<template>
<div class="admin-page">
<div class="d-flex justify-content-between align-items-start mb-3 flex-wrap gap-2">
<div>
<h2 class="mb-1">Admin Panel</h2>
<p class="text-muted mb-0">Manage users, groups, spaces, and identity providers.</p>
</div>
<button class="btn btn-outline-secondary" @click="router.push('/')">Back to Notes</button>
</div>
<div v-if="error" class="alert alert-danger">{{ error }}</div>
<div v-if="successMessage" class="alert alert-success">{{ successMessage }}</div>
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<button class="nav-link" :class="{ active: activeTab === 'users' }" @click="activeTab = 'users'">Users</button>
</li>
<li class="nav-item">
<button class="nav-link" :class="{ active: activeTab === 'groups' }" @click="activeTab = 'groups'">Groups</button>
</li>
<li class="nav-item">
<button class="nav-link" :class="{ active: activeTab === 'spaces' }" @click="activeTab = 'spaces'">Spaces</button>
</li>
<li class="nav-item">
<button class="nav-link" :class="{ active: activeTab === 'providers' }" @click="activeTab = 'providers'">Identity Providers</button>
</li>
<li class="nav-item">
<button class="nav-link" :class="{ active: activeTab === 'featureFlags' }" @click="activeTab = 'featureFlags'">Feature Flags</button>
</li>
</ul>
<section v-if="activeTab === 'users'" class="admin-section card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">All Users</h5>
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingUsers" @click="loadUsers">Refresh</button>
</div>
<div v-if="loadingUsers" class="text-muted small">Loading users...</div>
<div v-else-if="users.length === 0" class="border rounded p-3 text-muted">No users found.</div>
<div v-else class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Username</th>
<th>Email</th>
<th>Groups</th>
<th>Status</th>
<th>Joined</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td>{{ u.username }}</td>
<td class="text-muted small">{{ u.email }}</td>
<td style="min-width: 260px">
<select
class="form-select form-select-sm"
multiple
:value="u.group_ids || []"
@change="
updateUserGroups(
u.id,
Array.from($event.target.selectedOptions).map((option) => option.value),
)
"
>
<option v-for="group in groups" :key="group.id" :value="group.id">
{{ group.name }}
</option>
</select>
<div class="small text-muted mt-1">Ctrl/Cmd+Click for multiple groups</div>
</td>
<td>
<span class="badge" :class="u.is_active ? 'text-bg-success' : 'text-bg-secondary'">
{{ u.is_active ? "Active" : "Inactive" }}
</span>
</td>
<td class="text-muted small">{{ formatDate(u.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section v-if="activeTab === 'groups'" class="admin-section card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Permission Groups</h5>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingGroups" @click="loadGroups">Refresh</button>
<button class="btn btn-sm btn-primary" @click="openCreateGroupModal">Create Group</button>
</div>
</div>
<div v-if="loadingGroups" class="text-muted small">Loading groups...</div>
<div v-else-if="groups.length === 0" class="border rounded p-3 text-muted">No groups created yet.</div>
<div v-else class="list-group">
<div v-for="group in groups" :key="group.id" class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold d-flex align-items-center gap-2">
<span>{{ group.name }}</span>
<span v-if="group.is_system" class="badge text-bg-dark">System</span>
</div>
<div class="small text-muted">{{ group.description || "No description" }}</div>
<div class="small text-muted">{{ (group.permissions || []).length }} permission{{ (group.permissions || []).length === 1 ? "" : "s" }}</div>
</div>
<button class="btn btn-sm btn-outline-primary" @click="openEditGroupModal(group)">Edit</button>
</div>
</div>
</div>
</section>
<section v-if="activeTab === 'spaces'" class="admin-section card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">All Spaces</h5>
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingSpaces" @click="loadSpaces">Refresh</button>
</div>
<div v-if="loadingSpaces" class="text-muted small">Loading spaces...</div>
<div v-else-if="spaces.length === 0" class="border rounded p-3 text-muted">No spaces found.</div>
<div v-else class="list-group mb-3">
<div v-for="space in spaces" :key="space.id" class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{ space.name }}</div>
<div class="small text-muted">{{ space.description || "No description" }}</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge" :class="space.is_public ? 'text-bg-success' : 'text-bg-secondary'">
{{ space.is_public ? "Public" : "Private" }}
</span>
<button class="btn btn-sm btn-outline-primary" @click="openSpaceModal(space)">Edit Space</button>
</div>
</div>
</div>
</div>
</section>
<section v-if="activeTab === 'providers'" class="admin-section card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Configured Providers</h5>
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingProviders" @click="loadProviders">Refresh</button>
</div>
<div v-if="loadingProviders" class="text-muted small">Loading providers...</div>
<div v-else-if="providers.length === 0" class="border rounded p-3 text-muted">No providers configured yet.</div>
<div v-else class="list-group mb-3">
<div v-for="provider in providers" :key="provider.id" class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{ provider.name }}</div>
<div class="small text-muted">{{ provider.type.toUpperCase() }} · {{ provider.scopes.join(", ") }}</div>
<div class="small text-muted">Callback: {{ buildCallbackUrl(provider.id) }}</div>
</div>
<span class="badge" :class="provider.is_active ? 'text-bg-success' : 'text-bg-secondary'">
{{ provider.is_active ? "Active" : "Disabled" }}
</span>
</div>
</div>
<h6 class="mb-2">Add Provider</h6>
<form class="row g-3" @submit.prevent="createProvider">
<div class="col-md-6">
<label class="form-label">Display Name</label>
<input v-model="providerForm.name" type="text" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">Provider Type</label>
<select v-model="providerForm.type" class="form-select">
<option value="oidc">OIDC</option>
<option value="oauth2">OAuth2</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Client ID</label>
<input v-model="providerForm.client_id" type="text" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">Client Secret</label>
<input v-model="providerForm.client_secret" type="password" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">Authorization URL</label>
<input v-model="providerForm.authorization_url" type="url" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">Token URL</label>
<input v-model="providerForm.token_url" type="url" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">UserInfo URL</label>
<input v-model="providerForm.userinfo_url" type="url" class="form-control" placeholder="Optional" />
</div>
<div class="col-md-6">
<label class="form-label">ID Token Field</label>
<input v-model="providerForm.id_token_claim" type="text" class="form-control" placeholder="id_token" />
</div>
<div class="col-12">
<label class="form-label">Scopes</label>
<input v-model="providerForm.scopes" type="text" class="form-control" placeholder="openid, profile, email" />
</div>
<div class="col-12 form-check ms-2">
<input id="provider-active" v-model="providerForm.is_active" type="checkbox" class="form-check-input" />
<label for="provider-active" class="form-check-label">Provider is active</label>
</div>
<div class="col-12 d-flex justify-content-end">
<button type="submit" class="btn btn-primary" :disabled="submittingProvider">
{{ submittingProvider ? "Saving..." : "Add Provider" }}
</button>
</div>
</form>
</div>
</section>
<section v-if="activeTab === 'featureFlags'" class="admin-section card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Application Feature Flags</h5>
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingFeatureFlags" @click="loadFeatureFlags">Refresh</button>
</div>
<div v-if="loadingFeatureFlags" class="text-muted small">Loading feature flags...</div>
<div v-else class="d-grid gap-3">
<div class="feature-flag-item border rounded p-3 d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">Enable User Registration</div>
<div class="small text-muted">Controls whether new users can sign up from the register page.</div>
</div>
<div class="form-check form-switch m-0">
<input id="flag-registration" v-model="featureFlagsForm.registration_enabled" class="form-check-input" type="checkbox" />
</div>
</div>
<div class="feature-flag-item border rounded p-3 d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">Enable Provider Login</div>
<div class="small text-muted">Controls OAuth/OIDC sign-in buttons and provider login endpoints.</div>
</div>
<div class="form-check form-switch m-0">
<input id="flag-provider-login" v-model="featureFlagsForm.provider_login_enabled" class="form-check-input" type="checkbox" />
</div>
</div>
<div class="feature-flag-item border rounded p-3 d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">Enable Public Sharing</div>
<div class="small text-muted">Reserved for public content controls and future sharing gates.</div>
</div>
<div class="form-check form-switch m-0">
<input id="flag-public-sharing" v-model="featureFlagsForm.public_sharing_enabled" class="form-check-input" type="checkbox" />
</div>
</div>
<div class="d-flex justify-content-end">
<button class="btn btn-primary" :disabled="savingFeatureFlags" @click="saveFeatureFlags">
{{ savingFeatureFlags ? "Saving..." : "Save Feature Flags" }}
</button>
</div>
</div>
</div>
</section>
</div>
<AdminSpaceModal v-if="showSpaceModal && selectedSpace" :space="selectedSpace" :users="users" @close="showSpaceModal = false" @saved="onSpaceSaved" @deleted="onSpaceDeleted" />
<teleport to="body">
<div v-if="showGroupModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeGroupModal">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ groupModalMode === "create" ? "Create Group" : "Edit Group" }}</h5>
<button type="button" class="btn-close" aria-label="Close" @click="closeGroupModal"></button>
</div>
<form @submit.prevent="submitGroupModal">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Group name</label>
<input v-model="groupModalForm.name" class="form-control" type="text" required :disabled="isEditingSystemGroup" />
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input v-model="groupModalForm.description" class="form-control" type="text" :disabled="isEditingSystemGroup" />
</div>
<div>
<label class="form-label">Permissions (one per line)</label>
<textarea
v-model="groupModalForm.permissionsText"
class="form-control permissions-textarea"
rows="10"
placeholder="space.create&#10;space.project_docs.category.create&#10;space.project_docs.*"
:disabled="isEditingSystemGroup"
></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="closeGroupModal">Cancel</button>
<button v-if="!isEditingSystemGroup" type="submit" class="btn btn-primary" :disabled="submittingGroupModal">
{{ submittingGroupModal ? "Saving..." : groupModalMode === "create" ? "Create Group" : "Save Changes" }}
</button>
</div>
</form>
</div>
</div>
</div>
<div v-if="showGroupModal" class="modal-backdrop fade show"></div>
</teleport>
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import apiClient from "../services/apiClient";
import AdminSpaceModal from "../components/AdminSpaceModal.vue";
const router = useRouter();
const activeTab = ref("users");
const error = ref("");
const successMessage = ref("");
const users = ref([]);
const loadingUsers = ref(false);
const groups = ref([]);
const loadingGroups = ref(false);
const showGroupModal = ref(false);
const groupModalMode = ref("create");
const editingGroupId = ref("");
const submittingGroupModal = ref(false);
const groupModalForm = ref({
name: "",
description: "",
permissionsText: "",
});
const spaces = ref([]);
const loadingSpaces = ref(false);
const showSpaceModal = ref(false);
const selectedSpace = ref(null);
const providers = ref([]);
const loadingProviders = ref(false);
const submittingProvider = ref(false);
const providerForm = ref({
name: "",
type: "oidc",
client_id: "",
client_secret: "",
authorization_url: "",
token_url: "",
userinfo_url: "",
id_token_claim: "id_token",
scopes: "openid, profile, email",
is_active: true,
});
const loadingFeatureFlags = ref(false);
const savingFeatureFlags = ref(false);
const featureFlagsForm = ref({
registration_enabled: true,
provider_login_enabled: true,
public_sharing_enabled: true,
});
const clearMessages = () => {
error.value = "";
successMessage.value = "";
};
const formatDate = (iso) => {
if (!iso) return "";
return new Date(iso).toLocaleDateString();
};
const loadUsers = async () => {
loadingUsers.value = true;
clearMessages();
try {
const res = await apiClient.get("/api/v1/admin/users");
users.value = res.data.users || [];
} catch (e) {
error.value = e.response?.data || "Failed to load users.";
} finally {
loadingUsers.value = false;
}
};
const updateUserGroups = async (userId, groupIds) => {
clearMessages();
try {
const response = await apiClient.put(`/api/v1/admin/users/${userId}/groups`, { group_ids: groupIds });
const updatedUser = response.data;
const userIndex = users.value.findIndex((user) => user.id === userId);
if (userIndex !== -1) {
users.value[userIndex] = { ...users.value[userIndex], ...updatedUser };
}
successMessage.value = "User groups updated.";
} catch (e) {
error.value = e.response?.data || "Failed to update user groups.";
}
};
const resetGroupModalForm = () => {
groupModalForm.value = {
name: "",
description: "",
permissionsText: "",
};
};
const isEditingSystemGroup = computed(() => {
if (groupModalMode.value !== "edit") {
return false;
}
const group = groups.value.find((item) => item.id === editingGroupId.value);
return !!group?.is_system;
});
const splitPermissionsByNewline = (raw) =>
(raw || "")
.split(/\r?\n/)
.map((permission) => permission.trim())
.filter(Boolean);
const openCreateGroupModal = () => {
groupModalMode.value = "create";
editingGroupId.value = "";
resetGroupModalForm();
showGroupModal.value = true;
};
const openEditGroupModal = (group) => {
groupModalMode.value = "edit";
editingGroupId.value = group.id;
groupModalForm.value = {
name: group.name || "",
description: group.description || "",
permissionsText: (group.permissions || []).join("\n"),
};
showGroupModal.value = true;
};
const closeGroupModal = () => {
showGroupModal.value = false;
submittingGroupModal.value = false;
};
const loadGroups = async () => {
loadingGroups.value = true;
clearMessages();
try {
const res = await apiClient.get("/api/v1/admin/groups");
groups.value = res.data.groups || [];
} catch (e) {
error.value = e.response?.data || "Failed to load groups.";
} finally {
loadingGroups.value = false;
}
};
const submitGroupModal = async () => {
submittingGroupModal.value = true;
clearMessages();
try {
const payload = {
name: groupModalForm.value.name,
description: groupModalForm.value.description,
permissions: splitPermissionsByNewline(groupModalForm.value.permissionsText),
};
if (groupModalMode.value === "create") {
await apiClient.post("/api/v1/admin/groups", payload);
successMessage.value = "Group created.";
} else {
await apiClient.put(`/api/v1/admin/groups/${editingGroupId.value}`, payload);
successMessage.value = "Group updated.";
}
closeGroupModal();
resetGroupModalForm();
await Promise.all([loadGroups(), loadUsers()]);
} catch (e) {
error.value = e.response?.data || `Failed to ${groupModalMode.value === "create" ? "create" : "update"} group.`;
} finally {
submittingGroupModal.value = false;
}
};
const loadSpaces = async () => {
loadingSpaces.value = true;
clearMessages();
try {
const res = await apiClient.get("/api/v1/admin/spaces");
spaces.value = res.data.spaces || [];
} catch (e) {
error.value = e.response?.data || "Failed to load spaces.";
} finally {
loadingSpaces.value = false;
}
};
const openSpaceModal = (space) => {
selectedSpace.value = { ...space };
showSpaceModal.value = true;
};
const onSpaceSaved = (updatedSpace) => {
const index = spaces.value.findIndex((space) => space.id === updatedSpace.id);
if (index !== -1) {
spaces.value[index] = { ...spaces.value[index], ...updatedSpace };
}
selectedSpace.value = { ...updatedSpace };
successMessage.value = "Space updated.";
};
const onSpaceDeleted = (deletedSpace) => {
spaces.value = spaces.value.filter((space) => space.id !== deletedSpace.id);
showSpaceModal.value = false;
selectedSpace.value = null;
successMessage.value = `Space "${deletedSpace.name}" deleted.`;
};
const buildCallbackUrl = (providerId) => `${apiClient.defaults.baseURL}/api/v1/auth/providers/${providerId}/callback`;
const resetProviderForm = () => {
providerForm.value = {
name: "",
type: "oidc",
client_id: "",
client_secret: "",
authorization_url: "",
token_url: "",
userinfo_url: "",
id_token_claim: "id_token",
scopes: "openid, profile, email",
is_active: true,
};
};
const loadProviders = async () => {
loadingProviders.value = true;
clearMessages();
try {
const res = await apiClient.get("/api/v1/auth/providers");
providers.value = res.data.providers || [];
} catch (e) {
error.value = e.response?.data || "Failed to load providers.";
} finally {
loadingProviders.value = false;
}
};
const createProvider = async () => {
submittingProvider.value = true;
clearMessages();
try {
await apiClient.post("/api/v1/admin/auth/providers", {
...providerForm.value,
scopes: providerForm.value.scopes
.split(",")
.map((scope) => scope.trim())
.filter(Boolean),
});
successMessage.value = "Provider added.";
resetProviderForm();
await loadProviders();
} catch (e) {
error.value = e.response?.data || "Failed to create provider.";
} finally {
submittingProvider.value = false;
}
};
const loadFeatureFlags = async () => {
loadingFeatureFlags.value = true;
clearMessages();
try {
const res = await apiClient.get("/api/v1/admin/feature-flags");
featureFlagsForm.value = {
registration_enabled: !!res.data.registration_enabled,
provider_login_enabled: !!res.data.provider_login_enabled,
public_sharing_enabled: !!res.data.public_sharing_enabled,
};
} catch (e) {
error.value = e.response?.data || "Failed to load feature flags.";
} finally {
loadingFeatureFlags.value = false;
}
};
const saveFeatureFlags = async () => {
savingFeatureFlags.value = true;
clearMessages();
try {
const res = await apiClient.put("/api/v1/admin/feature-flags", featureFlagsForm.value);
featureFlagsForm.value = {
registration_enabled: !!res.data.registration_enabled,
provider_login_enabled: !!res.data.provider_login_enabled,
public_sharing_enabled: !!res.data.public_sharing_enabled,
};
successMessage.value = "Feature flags updated.";
} catch (e) {
error.value = e.response?.data || "Failed to update feature flags.";
} finally {
savingFeatureFlags.value = false;
}
};
onMounted(async () => {
await Promise.all([loadUsers(), loadGroups(), loadSpaces(), loadProviders(), loadFeatureFlags()]);
});
</script>
<style scoped>
.admin-page {
max-width: 1100px;
margin: 0 auto;
}
.admin-section {
border-radius: 12px;
}
.permissions-textarea {
font-family: "Courier New", monospace;
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<div class="home-page">
<router-view />
</div>
</template>
<script setup></script>

View File

@@ -0,0 +1,298 @@
<template>
<div class="login-page">
<div class="auth-container">
<div class="login-card">
<div class="brand-block">
<div class="brand-mark">
<i class="mdi mdi-note-text-outline" aria-hidden="true"></i>
</div>
<h1 class="brand-title">Notely</h1>
</div>
<h2 class="auth-title">Login</h2>
<form @submit.prevent="handleLogin">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input id="email" v-model="form.email" type="email" class="form-control" required />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input id="password" v-model="form.password" type="password" class="form-control" required />
</div>
<div v-if="error" class="alert alert-danger">{{ error }}</div>
<button type="submit" class="btn btn-primary w-100 auth-submit">Login</button>
</form>
<div v-if="providerLoginEnabled && providers.length" class="mt-4">
<div class="oauth-divider"><span>or continue with</span></div>
<div class="d-grid gap-2 mt-3">
<button v-for="provider in providers" :key="provider.id" type="button" class="btn btn-outline-dark auth-provider-btn" @click="startProviderLogin(provider.id)">
Sign in with {{ provider.name }}
</button>
</div>
</div>
<p v-if="registrationEnabled" class="text-center mt-4 mb-0 auth-switch-link">
Don't have an account?
<router-link to="/register">Register here</router-link>
</p>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../stores/authStore";
import { useSettingsStore } from "../stores/settingsStore";
import apiClient from "../services/apiClient";
const router = useRouter();
const authStore = useAuthStore();
const settingsStore = useSettingsStore();
const form = ref({
email: "",
password: "",
});
const error = ref("");
const providers = ref([]);
const registrationEnabled = ref(true);
const providerLoginEnabled = ref(true);
const handleLogin = async () => {
error.value = "";
try {
await authStore.login(form.value.email, form.value.password);
router.push("/");
} catch (err) {
error.value = err;
}
};
const loadProviders = async () => {
try {
const response = await apiClient.get("/api/v1/auth/providers");
providers.value = response.data.providers || [];
} catch {
providers.value = [];
}
};
const startProviderLogin = (providerId) => {
window.location.href = `${apiClient.defaults.baseURL}/api/v1/auth/providers/${providerId}/start`;
};
const decodeBase64Url = (value) => {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padding = normalized.length % 4;
const padded = padding === 0 ? normalized : `${normalized}${"=".repeat(4 - padding)}`;
return atob(padded);
};
const decodeBase64UrlUTF8 = (value) => {
const binary = decodeBase64Url(value);
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
return new TextDecoder().decode(bytes);
};
const readUserFromQuery = (params) => {
const plainUserJSON = params.get("user_json");
if (plainUserJSON) {
return JSON.parse(plainUserJSON);
}
const encodedUser = params.get("user");
if (encodedUser) {
return JSON.parse(decodeBase64UrlUTF8(encodedUser));
}
return null;
};
const completeOAuthRedirect = async () => {
const params = new URLSearchParams(window.location.search);
const status = params.get("status");
const accessToken = params.get("access_token") || params.get("accessToken") || params.get("token");
if (status === "oauth_error") {
error.value = params.get("message") || "Provider sign-in failed.";
return true;
}
// Accept callback payloads even when `status` is missing.
if (status !== "oauth_success" && !accessToken) {
if (status === "oauth_error") {
error.value = params.get("message") || "Provider sign-in failed.";
}
return false;
}
if (!accessToken) {
error.value = "Provider sign-in returned an incomplete session.";
return true;
}
try {
const user = readUserFromQuery(params);
if (!user) {
error.value = "Provider sign-in returned an incomplete session.";
return true;
}
authStore.setSession({ access_token: accessToken, user });
await router.replace("/");
} catch {
error.value = "Unable to restore the provider session.";
}
if (authStore.isAuthenticated) {
window.location.replace("/");
}
return true;
};
onMounted(async () => {
const flags = await settingsStore.loadFeatureFlags();
registrationEnabled.value = !!flags.registration_enabled;
providerLoginEnabled.value = !!flags.provider_login_enabled;
if (authStore.isAuthenticated) {
await router.replace("/");
return;
}
const handledOAuthCallback = await completeOAuthRedirect();
if (!handledOAuthCallback && providerLoginEnabled.value) {
await loadProviders();
}
const queryMessage = typeof router.currentRoute.value.query.message === "string" ? router.currentRoute.value.query.message : "";
if (!error.value && queryMessage) {
error.value = queryMessage;
}
});
</script>
<style scoped>
.login-page {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
min-height: 100vh;
padding: 1.25rem;
background: radial-gradient(circle at 10% 10%, rgba(255, 255, 255, 0.2), transparent 45%), linear-gradient(135deg, #3554d1 0%, #4f46a5 100%);
}
.auth-container {
width: 100%;
max-width: 460px;
}
.login-card {
background: #fff;
padding: 2rem;
border-radius: 18px;
box-shadow: 0 22px 48px rgba(16, 24, 40, 0.22);
width: 100%;
}
.brand-block {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.brand-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 12px;
background: rgba(53, 84, 209, 0.12);
color: #2f4ac1;
font-size: 1.35rem;
}
.brand-title {
margin: 0;
font-size: 2.05rem;
font-weight: 700;
letter-spacing: 0.01em;
color: #2f3237;
}
.auth-title {
text-align: center;
font-size: 2.1rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: #2f3237;
}
.form-control {
border-radius: 0.65rem;
min-height: 48px;
border-color: #d6dbe4;
}
.auth-submit {
min-height: 48px;
font-weight: 600;
}
.auth-provider-btn {
min-height: 48px;
border-radius: 0.65rem;
}
.oauth-divider {
display: flex;
align-items: center;
color: #6c757d;
font-size: 0.9rem;
}
.oauth-divider::before,
.oauth-divider::after {
content: "";
flex: 1;
border-bottom: 1px solid #dee2e6;
}
.oauth-divider span {
padding: 0 0.75rem;
}
.auth-switch-link {
color: #4b5565;
}
@media (max-width: 576px) {
.login-page {
padding: 0.85rem;
}
.login-card {
border-radius: 14px;
padding: 1.35rem;
}
.brand-title {
font-size: 1.8rem;
}
.auth-title {
font-size: 1.85rem;
}
}
</style>

View File

@@ -0,0 +1,395 @@
<template>
<div class="public-layout">
<!-- Minimal navbar -->
<nav ref="navbarRef" class="navbar navbar-dark bg-dark sticky-top">
<div class="container-fluid">
<div class="d-flex align-items-center gap-2">
<button v-if="!loading && !errorType" class="btn btn-outline-light d-md-none" type="button" aria-label="Toggle notes list" @click="showSidebar = !showSidebar">
<i class="mdi mdi-menu" aria-hidden="true"></i>
</button>
<router-link to="/" class="navbar-brand mb-0 h1 d-flex align-items-center gap-2">
<i class="mdi mdi-notebook-outline" aria-hidden="true"></i>
<span>Notely</span>
</router-link>
</div>
<div class="d-flex gap-2">
<router-link v-if="!isAuthenticated" to="/login" class="btn btn-primary btn-sm">Login</router-link>
<router-link v-else to="/" class="btn btn-outline-light btn-sm">My Spaces</router-link>
</div>
</div>
</nav>
<!-- Loading -->
<div v-if="loading" class="d-flex justify-content-center align-items-center" style="min-height: 80vh">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<!-- Error -->
<div v-else-if="errorType" class="d-flex flex-column justify-content-center align-items-center text-center" style="min-height: 80vh">
<h2 class="mb-3">{{ errorTitle }}</h2>
<p class="text-muted mb-4">{{ errorDescription }}</p>
<router-link to="/login" class="btn btn-primary">Sign in to access private spaces</router-link>
</div>
<!-- Content -->
<div v-else class="d-flex public-body" :style="publicBodyStyle">
<div v-if="showSidebar" class="public-sidebar-backdrop" :style="offcanvasOffsetStyle" @click="showSidebar = false"></div>
<!-- Sidebar: note list -->
<aside class="public-sidebar bg-light border-end p-3" :class="{ open: showSidebar }" :style="offcanvasOffsetStyle">
<div class="mb-3">
<h5 class="mb-1 d-flex align-items-center gap-2">
<i class="mdi mdi-folder-outline" aria-hidden="true"></i>
<span>{{ space.name }}</span>
</h5>
<p class="text-muted small mb-0">{{ space.description }}</p>
</div>
<hr />
<div v-if="sortedPublicNotes.length === 0" class="text-muted small">No notes in this space.</div>
<ul class="list-unstyled mb-0">
<li v-for="note in sortedPublicNotes" :key="note.id" class="mb-1">
<button
class="btn btn-sm w-100 text-start note-item"
:class="{ active: selectedNote?.id === note.id, 'is-featured': note.is_favorite || note.is_featured }"
@click="selectAndRouteNote(note)"
>
<span class="d-block fw-semibold text-truncate">{{ note.title }}</span>
<span class="d-block text-muted small">{{ formatDate(note.updated_at) }}</span>
</button>
</li>
</ul>
<!-- Load more -->
<button v-if="hasMore" class="btn btn-sm btn-outline-secondary w-100 mt-2" @click="loadMore">Load more</button>
</aside>
<!-- Main: note content -->
<main class="flex-grow-1 p-4 overflow-auto">
<div v-if="selectedNote">
<h2 class="mb-3">{{ selectedNote.title }}</h2>
<NoteViewer :note="selectedNote" />
</div>
<div v-else class="text-center text-muted mt-5">
<p>Select a note from the list to read it.</p>
</div>
</main>
<teleport to="body">
<div v-if="showUnlockModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeUnlockModal">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title d-flex align-items-center gap-2 mb-0">
<i class="mdi mdi-lock-outline" aria-hidden="true"></i>
<span>Unlock Note</span>
</h5>
<button type="button" class="btn-close" aria-label="Close" @click="closeUnlockModal"></button>
</div>
<div class="modal-body">
<p class="text-muted mb-3">
Enter the password to view <strong>{{ unlockTargetNote?.title }}</strong
>.
</p>
<label class="form-label" for="unlockPublicNotePassword">Password</label>
<input id="unlockPublicNotePassword" v-model="unlockPassword" type="password" class="form-control" maxlength="128" @keyup.enter="unlockProtectedNote" />
<div v-if="unlockError" class="text-danger small mt-2">{{ unlockError }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="closeUnlockModal">Cancel</button>
<button type="button" class="btn btn-primary" :disabled="unlockingNote" @click="unlockProtectedNote">
{{ unlockingNote ? "Unlocking..." : "Unlock" }}
</button>
</div>
</div>
</div>
</div>
<div v-if="showUnlockModal" class="modal-backdrop fade show"></div>
</teleport>
</div>
</div>
</template>
<script setup>
import { ref, computed, nextTick, onBeforeUnmount, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../stores/authStore";
import apiClient from "../services/apiClient";
import NoteViewer from "../components/NoteViewer.vue";
import { sortNotesByPriority } from "../utils/noteSort";
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const isAuthenticated = ref(authStore.isAuthenticated);
const loading = ref(true);
const errorType = ref(null);
const space = ref(null);
const notes = ref([]);
const selectedNote = ref(null);
const showSidebar = ref(false);
const navbarRef = ref(null);
const navbarHeight = ref(56);
const skip = ref(0);
const limit = 50;
const hasMore = ref(false);
const showUnlockModal = ref(false);
const unlockTargetNote = ref(null);
const unlockPassword = ref("");
const unlockError = ref("");
const unlockingNote = ref(false);
const sortedPublicNotes = computed(() => sortNotesByPriority(notes.value));
const offcanvasOffsetStyle = computed(() => ({
top: `${navbarHeight.value}px`,
}));
const publicBodyStyle = computed(() => ({
height: `calc(100vh - ${navbarHeight.value}px)`,
}));
const errorTitle = computed(() => {
if (errorType.value === "note-not-found") {
return "Note not found";
}
if (errorType.value === "space-not-found") {
return "Space not found";
}
return "This space is not publicly accessible";
});
const errorDescription = computed(() => {
if (errorType.value === "note-not-found") {
return "The note you are looking for does not exist or is not public.";
}
if (errorType.value === "space-not-found") {
return "The space you are looking for does not exist.";
}
return "The space you are looking for is private.";
});
const formatDate = (dateString) => new Date(dateString).toLocaleDateString();
const upsertNoteInList = (note) => {
const index = notes.value.findIndex((n) => n.id === note.id);
if (index === -1) {
notes.value.unshift(note);
return;
}
notes.value[index] = note;
};
const loadNotes = async () => {
const response = await apiClient.get(`/api/v1/public/spaces/${route.params.spaceId}/notes`, {
params: { skip: skip.value, limit },
});
const fetched = response.data.notes || [];
fetched.forEach(upsertNoteInList);
hasMore.value = fetched.length === limit;
skip.value += fetched.length;
};
const loadMore = async () => {
await loadNotes();
};
const selectAndRouteNote = (note) => {
if (note?.is_password_protected) {
unlockTargetNote.value = note;
unlockPassword.value = "";
unlockError.value = "";
showUnlockModal.value = true;
selectedNote.value = null;
showSidebar.value = false;
router.replace(`/s/${route.params.spaceId}/n/${note.id}`);
return;
}
selectedNote.value = note;
showSidebar.value = false;
router.replace(`/s/${route.params.spaceId}/n/${note.id}`);
};
const loadPublicNote = async (noteId) => {
const response = await apiClient.get(`/api/v1/public/spaces/${route.params.spaceId}/notes/${noteId}`);
const note = response.data;
upsertNoteInList(note);
if (note?.is_password_protected) {
unlockTargetNote.value = note;
unlockPassword.value = "";
unlockError.value = "";
showUnlockModal.value = true;
selectedNote.value = null;
return;
}
selectedNote.value = note;
};
const closeUnlockModal = () => {
showUnlockModal.value = false;
unlockTargetNote.value = null;
unlockPassword.value = "";
unlockError.value = "";
unlockingNote.value = false;
};
const unlockProtectedNote = async () => {
if (!unlockTargetNote.value?.id) {
closeUnlockModal();
return;
}
if (!unlockPassword.value.trim()) {
unlockError.value = "Password is required.";
return;
}
unlockingNote.value = true;
unlockError.value = "";
try {
const response = await apiClient.post(`/api/v1/public/spaces/${route.params.spaceId}/notes/${unlockTargetNote.value.id}/unlock`, {
password: unlockPassword.value,
});
selectedNote.value = response.data;
upsertNoteInList(response.data);
closeUnlockModal();
} catch (error) {
unlockError.value = error?.response?.data || "Invalid password.";
} finally {
unlockingNote.value = false;
}
};
const updateNavbarHeight = () => {
navbarHeight.value = navbarRef.value?.offsetHeight || 56;
};
const handleEscapeKey = (event) => {
if (event.key === "Escape") {
showSidebar.value = false;
}
};
onMounted(async () => {
document.addEventListener("keydown", handleEscapeKey);
window.addEventListener("resize", updateNavbarHeight);
await nextTick();
updateNavbarHeight();
try {
const [spaceRes] = await Promise.all([apiClient.get(`/api/v1/public/spaces/${route.params.spaceId}`)]);
space.value = spaceRes.data;
if (route.params.noteId) {
try {
await loadPublicNote(route.params.noteId);
await loadNotes();
} catch {
errorType.value = "note-not-found";
}
} else {
await loadNotes();
if (sortedPublicNotes.value.length > 0) {
selectAndRouteNote(sortedPublicNotes.value[0]);
}
}
} catch (err) {
errorType.value = err.response?.status === 404 ? "space-not-found" : "space-not-public";
} finally {
loading.value = false;
await nextTick();
updateNavbarHeight();
}
});
onBeforeUnmount(() => {
document.removeEventListener("keydown", handleEscapeKey);
window.removeEventListener("resize", updateNavbarHeight);
});
watch(
() => route.params.noteId,
async (noteId) => {
if (!noteId || loading.value) return;
try {
await loadPublicNote(noteId);
errorType.value = null;
} catch {
errorType.value = "note-not-found";
}
},
);
</script>
<style scoped>
.public-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.public-body {
flex: 1;
overflow: hidden;
position: relative;
}
.public-sidebar {
width: 280px;
overflow-y: auto;
flex-shrink: 0;
}
.note-item {
border-radius: 6px;
padding: 0.5rem 0.75rem;
background: transparent;
border: 1px solid transparent;
transition: background 0.15s;
}
.note-item:hover {
background: #e9ecef;
}
.note-item.active {
background: #dbe4ff;
border-color: #748ffc;
color: #364fc7;
}
.note-item.is-featured {
background: #fff4e6;
border-color: #ffd8a8;
}
.note-item.is-featured:hover {
background: #ffe8cc;
}
@media (max-width: 768px) {
.public-sidebar-backdrop {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1090;
}
.public-sidebar {
position: fixed;
left: 0;
bottom: 0;
z-index: 1095;
transform: translateX(-100%);
transition: transform 0.3s ease-in-out;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
}
.public-sidebar.open {
transform: translateX(0);
}
}
</style>

View File

@@ -0,0 +1,205 @@
<template>
<div class="register-page">
<div class="auth-container">
<div class="register-card">
<div class="brand-block">
<div class="brand-mark">
<i class="mdi mdi-note-text-outline" aria-hidden="true"></i>
</div>
<h1 class="brand-title">Notely</h1>
</div>
<h2 class="auth-title">Register</h2>
<div v-if="!registrationEnabled" class="alert alert-warning">
Registration is currently disabled by an administrator.
<router-link to="/login" class="alert-link ms-1">Go to login</router-link>
</div>
<form @submit.prevent="handleRegister" :class="{ 'opacity-50': !registrationEnabled }">
<div class="row mb-3">
<div class="col-12 col-md-6 mb-3 mb-md-0">
<label for="firstName" class="form-label">First Name</label>
<input id="firstName" v-model="form.firstName" type="text" class="form-control" :disabled="!registrationEnabled" />
</div>
<div class="col-12 col-md-6">
<label for="lastName" class="form-label">Last Name</label>
<input id="lastName" v-model="form.lastName" type="text" class="form-control" :disabled="!registrationEnabled" />
</div>
</div>
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input id="username" v-model="form.username" type="text" class="form-control" required :disabled="!registrationEnabled" />
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input id="email" v-model="form.email" type="email" class="form-control" required :disabled="!registrationEnabled" />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input id="password" v-model="form.password" type="password" class="form-control" required :disabled="!registrationEnabled" />
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label">Confirm Password</label>
<input id="confirmPassword" v-model="form.confirmPassword" type="password" class="form-control" required :disabled="!registrationEnabled" />
</div>
<div v-if="error" class="alert alert-danger">{{ error }}</div>
<button type="submit" class="btn btn-primary w-100 auth-submit" :disabled="!registrationEnabled">Register</button>
</form>
<p class="text-center mt-4 mb-0 auth-switch-link">
Already have an account?
<router-link to="/login">Login here</router-link>
</p>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../stores/authStore";
import { useSettingsStore } from "../stores/settingsStore";
const router = useRouter();
const authStore = useAuthStore();
const settingsStore = useSettingsStore();
const form = ref({
email: "",
username: "",
password: "",
confirmPassword: "",
firstName: "",
lastName: "",
});
const error = ref("");
const registrationEnabled = ref(true);
const handleRegister = async () => {
error.value = "";
if (!registrationEnabled.value) {
error.value = "Registration is currently disabled.";
return;
}
if (form.value.password !== form.value.confirmPassword) {
error.value = "Passwords do not match";
return;
}
try {
await authStore.register(form.value.email, form.value.username, form.value.password, form.value.firstName, form.value.lastName);
router.push("/");
} catch (err) {
error.value = err;
}
};
onMounted(async () => {
const flags = await settingsStore.loadFeatureFlags();
registrationEnabled.value = !!flags.registration_enabled;
});
</script>
<style scoped>
.register-page {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
min-height: 100vh;
padding: 1.25rem;
background: radial-gradient(circle at 10% 10%, rgba(255, 255, 255, 0.2), transparent 45%), linear-gradient(135deg, #3554d1 0%, #4f46a5 100%);
}
.auth-container {
width: 100%;
max-width: 560px;
}
.register-card {
background: #fff;
padding: 2rem;
border-radius: 18px;
box-shadow: 0 22px 48px rgba(16, 24, 40, 0.22);
width: 100%;
}
.brand-block {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.brand-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 12px;
background: rgba(53, 84, 209, 0.12);
color: #2f4ac1;
font-size: 1.35rem;
}
.brand-title {
margin: 0;
font-size: 2.05rem;
font-weight: 700;
letter-spacing: 0.01em;
color: #2f3237;
}
.auth-title {
text-align: center;
font-size: 2.1rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: #2f3237;
}
.form-control {
border-radius: 0.65rem;
min-height: 48px;
border-color: #d6dbe4;
}
.auth-submit {
min-height: 48px;
font-weight: 600;
}
.auth-switch-link {
color: #4b5565;
}
@media (max-width: 576px) {
.register-page {
padding: 0.85rem;
}
.register-card {
border-radius: 14px;
padding: 1.35rem;
}
.brand-title {
font-size: 1.8rem;
}
.auth-title {
font-size: 1.85rem;
}
}
</style>

View File

@@ -0,0 +1,123 @@
import { createRouter, createWebHistory } from "vue-router";
import { useAuthStore } from "../stores/authStore";
import { useSettingsStore } from "../stores/settingsStore";
import LoginPage from "../pages/Login.vue";
import RegisterPage from "../pages/Register.vue";
const decodeBase64UrlUTF8 = (value) => {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padding = normalized.length % 4;
const padded = padding === 0 ? normalized : `${normalized}${"=".repeat(4 - padding)}`;
const binary = atob(padded);
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
return new TextDecoder().decode(bytes);
};
const restoreOAuthSessionFromQuery = (query, authStore) => {
// Merge router query with URLSearchParams for full coverage
const params = new URLSearchParams(window.location.search);
const accessToken = query.access_token || query.accessToken || query.token || params.get("access_token") || params.get("accessToken") || params.get("token");
if (!accessToken) {
return false;
}
try {
const plainUserJSON = query.user_json || params.get("user_json");
const encodedUser = query.user || params.get("user");
const user = plainUserJSON ? JSON.parse(plainUserJSON) : encodedUser ? JSON.parse(decodeBase64UrlUTF8(encodedUser)) : null;
if (!user) {
return false;
}
authStore.setSession({ access_token: accessToken, user });
return true;
} catch {
return false;
}
};
const routes = [
{
path: "/login",
name: "Login",
component: LoginPage,
},
{
path: "/register",
name: "Register",
component: RegisterPage,
},
{
path: "/",
name: "Home",
component: () => import("../pages/Home.vue"),
meta: { requiresAuth: true },
},
{
path: "/admin",
name: "Admin",
component: () => import("../pages/Admin.vue"),
meta: { requiresAuth: true, requiresAdminPermission: true },
},
{
path: "/s/:spaceId",
name: "PublicSpace",
component: () => import("../pages/PublicSpace.vue"),
},
{
path: "/s/:spaceId/n/:noteId",
name: "PublicNote",
component: () => import("../pages/PublicSpace.vue"),
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
const settingsStore = useSettingsStore();
// Only attempt OAuth callback restoration if actual OAuth query params are present
const params = new URLSearchParams(window.location.search);
const hasOAuthParams = to.query.access_token || to.query.accessToken || to.query.token || params.get("access_token") || params.get("accessToken") || params.get("token");
if (to.path === "/login") {
if (hasOAuthParams) {
const restored = restoreOAuthSessionFromQuery(to.query, authStore);
if (restored) {
next({ path: "/", replace: true });
return;
}
}
// Allow login page to be viewed regardless of auth state if no OAuth callback
if (!hasOAuthParams) {
next();
return;
}
}
if (to.path === "/register") {
await settingsStore.loadFeatureFlags();
if (!settingsStore.registrationEnabled) {
next({ path: "/login", query: { message: "Registration is currently disabled." } });
return;
}
}
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next("/login");
} else if (to.meta.requiresAdminPermission && !authStore.isAdmin) {
next("/");
} else if ((to.path === "/login" || to.path === "/register") && authStore.isAuthenticated) {
next("/");
} else {
next();
}
});
export default router;

View File

@@ -0,0 +1,27 @@
import axios from "axios";
import { useAuthStore } from "../stores/authStore";
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || "http://localhost:8080",
});
apiClient.interceptors.request.use((config) => {
const authStore = useAuthStore();
if (authStore.accessToken) {
config.headers.Authorization = `Bearer ${authStore.accessToken}`;
}
return config;
});
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
const authStore = useAuthStore();
authStore.logout();
}
return Promise.reject(error);
},
);
export default apiClient;

View File

@@ -0,0 +1,108 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import apiClient from "../services/apiClient";
export const useAuthStore = defineStore("auth", () => {
const storedUser = localStorage.getItem("user");
const user = ref(storedUser ? JSON.parse(storedUser) : null);
const accessToken = ref(localStorage.getItem("accessToken"));
const isAuthenticated = computed(() => !!accessToken.value && !!user.value);
const isAdmin = computed(() => hasPermission("*") || hasPermission("admin.access"));
const normalizePermission = (permission) => (permission || "").trim().toLowerCase();
const permissionMatches = (pattern, permission) => {
const normalizedPattern = normalizePermission(pattern);
const normalizedPermission = normalizePermission(permission);
if (!normalizedPattern || !normalizedPermission) {
return false;
}
if (normalizedPattern === "*" || normalizedPattern === normalizedPermission) {
return true;
}
if (!normalizedPattern.includes("*")) {
return false;
}
const escaped = normalizedPattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
const regex = new RegExp(`^${escaped}$`);
return regex.test(normalizedPermission);
};
const hasPermission = (permission) => {
const userPermissions = user.value?.permissions || [];
return userPermissions.some((pattern) => permissionMatches(pattern, permission));
};
const getSpacePermissionToken = (space) => space?.permission_key || "";
const hasSpacePermission = (space, action) => {
const token = getSpacePermissionToken(space);
if (!token) {
return false;
}
return hasPermission(`space.${token}.${action}`);
};
const setSession = (responseData) => {
accessToken.value = responseData.access_token;
user.value = responseData.user;
localStorage.setItem("accessToken", accessToken.value);
localStorage.setItem("user", JSON.stringify(user.value));
};
const register = async (email, username, password, firstName = "", lastName = "") => {
try {
const response = await apiClient.post("/api/v1/auth/register", {
email,
username,
password,
password_confirm: password,
first_name: firstName,
last_name: lastName,
});
setSession(response.data);
return response.data;
} catch (error) {
throw error.response?.data?.message || error.message;
}
};
const login = async (email, password) => {
try {
const response = await apiClient.post("/api/v1/auth/login", {
email: email?.trim(),
password,
});
setSession(response.data);
return response.data;
} catch (error) {
throw error.response?.data?.message || error.message;
}
};
const logout = () => {
accessToken.value = null;
user.value = null;
localStorage.removeItem("accessToken");
localStorage.removeItem("user");
};
return {
user,
accessToken,
isAuthenticated,
isAdmin,
hasPermission,
hasSpacePermission,
setSession,
register,
login,
logout,
};
});

View File

@@ -0,0 +1,47 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import apiClient from "../services/apiClient";
const DEFAULT_FLAGS = {
registration_enabled: true,
provider_login_enabled: true,
public_sharing_enabled: true,
};
export const useSettingsStore = defineStore("settings", () => {
const featureFlags = ref({ ...DEFAULT_FLAGS });
const flagsLoaded = ref(false);
const registrationEnabled = computed(() => !!featureFlags.value.registration_enabled);
const providerLoginEnabled = computed(() => !!featureFlags.value.provider_login_enabled);
const publicSharingEnabled = computed(() => !!featureFlags.value.public_sharing_enabled);
const loadFeatureFlags = async (force = false) => {
if (flagsLoaded.value && !force) {
return featureFlags.value;
}
try {
const response = await apiClient.get("/api/v1/settings/feature-flags");
featureFlags.value = {
...DEFAULT_FLAGS,
...response.data,
};
flagsLoaded.value = true;
} catch {
featureFlags.value = { ...DEFAULT_FLAGS };
flagsLoaded.value = true;
}
return featureFlags.value;
};
return {
featureFlags,
flagsLoaded,
registrationEnabled,
providerLoginEnabled,
publicSharingEnabled,
loadFeatureFlags,
};
});

View File

@@ -0,0 +1,224 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import apiClient from "../services/apiClient";
export const useSpaceStore = defineStore("space", () => {
const spaces = ref([]);
const currentSpace = ref(null);
const notes = ref([]);
const notesSkip = ref(0);
const notesLimit = ref(20);
const notesHasMore = ref(true);
const notesLoading = ref(false);
const categories = ref([]);
const categoryTree = ref([]);
const refreshSpaceData = async (spaceId) => {
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId)]);
};
const fetchSpaces = async () => {
try {
const response = await apiClient.get("/api/v1/spaces");
spaces.value = response.data || [];
} catch (error) {
console.error("Error fetching spaces:", error);
}
};
const selectSpace = async (spaceId) => {
try {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}`);
currentSpace.value = response.data;
await refreshSpaceData(spaceId);
} catch (error) {
console.error("Error selecting space:", error);
}
};
const fetchCategories = async (spaceId) => {
try {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/categories`);
categoryTree.value = response.data || [];
categories.value = categoryTree.value;
} catch (error) {
console.error("Error fetching categories:", error);
categoryTree.value = [];
categories.value = [];
}
};
const fetchNotes = async (spaceId, options = {}) => {
const { reset = true, limit = notesLimit.value } = options;
if (!spaceId) {
return;
}
if (notesLoading.value) {
return;
}
if (!reset && !notesHasMore.value) {
return;
}
if (reset) {
notesSkip.value = 0;
notesHasMore.value = true;
notesLimit.value = limit;
}
try {
notesLoading.value = true;
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/notes`, {
params: {
skip: notesSkip.value,
limit,
},
});
const fetchedNotes = response.data || [];
if (reset) {
notes.value = fetchedNotes;
} else {
notes.value = [...notes.value, ...fetchedNotes];
}
notesSkip.value += fetchedNotes.length;
notesHasMore.value = fetchedNotes.length === limit;
} catch (error) {
console.error("Error fetching notes:", error);
} finally {
notesLoading.value = false;
}
};
const loadMoreNotes = async (spaceId) => {
await fetchNotes(spaceId, { reset: false, limit: notesLimit.value });
};
const createSpace = async (spaceData) => {
try {
const response = await apiClient.post("/api/v1/spaces", spaceData);
spaces.value.push(response.data);
currentSpace.value = response.data;
localStorage.setItem("currentSpaceId", response.data.id);
await refreshSpaceData(response.data.id);
return response.data;
} catch (error) {
throw error.response?.data?.message || error.message;
}
};
const updateSpace = async (spaceId, spaceData) => {
try {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}`, spaceData);
const index = spaces.value.findIndex((s) => s.id === spaceId);
if (index !== -1) {
spaces.value[index] = response.data;
}
if (currentSpace.value?.id === spaceId) {
currentSpace.value = response.data;
}
return response.data;
} catch (error) {
throw error.response?.data?.message || error.message;
}
};
const createCategory = async (spaceId, categoryData) => {
try {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/categories`, categoryData);
await fetchCategories(spaceId);
return response.data;
} catch (error) {
throw error.response?.data?.message || error.message;
}
};
const updateCategory = async (spaceId, categoryId, categoryData) => {
try {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/categories/${categoryId}`, categoryData);
await fetchCategories(spaceId);
return response.data;
} catch (error) {
throw error.response?.data?.message || error.message;
}
};
const deleteCategory = async (spaceId, categoryId) => {
try {
await apiClient.delete(`/api/v1/spaces/${spaceId}/categories/${categoryId}`);
await refreshSpaceData(spaceId);
} catch (error) {
throw error.response?.data?.message || error.message;
}
};
const createNote = async (spaceId, noteData) => {
try {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/notes`, noteData);
await refreshSpaceData(spaceId);
return response.data;
} catch (error) {
throw error.response?.data?.message || error.message;
}
};
const updateNote = async (spaceId, noteData) => {
try {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/notes/${noteData.id}`, noteData);
const index = notes.value.findIndex((n) => n.id === noteData.id);
if (index !== -1) {
notes.value[index] = response.data;
}
await fetchCategories(spaceId);
return response.data;
} catch (error) {
throw error.response?.data?.message || error.message;
}
};
const deleteNote = async (spaceId, noteId) => {
try {
await apiClient.delete(`/api/v1/spaces/${spaceId}/notes/${noteId}`);
notes.value = notes.value.filter((n) => n.id !== noteId);
await fetchCategories(spaceId);
} catch (error) {
throw error.response?.data?.message || error.message;
}
};
const searchNotes = async (query) => {
try {
const response = await apiClient.get(`/api/v1/spaces/${currentSpace.value.id}/notes/search`, { params: { q: query } });
notes.value = response.data || [];
notesHasMore.value = false;
notesSkip.value = notes.value.length;
} catch (error) {
console.error("Error searching notes:", error);
}
};
return {
spaces,
currentSpace,
notes,
notesHasMore,
notesLoading,
categories,
categoryTree,
fetchSpaces,
selectSpace,
fetchNotes,
loadMoreNotes,
fetchCategories,
createSpace,
updateSpace,
createCategory,
updateCategory,
deleteCategory,
createNote,
updateNote,
deleteNote,
searchNotes,
};
});

View File

@@ -0,0 +1,17 @@
export const compareNotesByPriority = (a, b) => {
const pinnedDelta = (b?.is_pinned ? 1 : 0) - (a?.is_pinned ? 1 : 0);
if (pinnedDelta !== 0) {
return pinnedDelta;
}
const featuredDelta = (b?.is_favorite || b?.is_featured ? 1 : 0) - (a?.is_favorite || a?.is_featured ? 1 : 0);
if (featuredDelta !== 0) {
return featuredDelta;
}
const leftTitle = (a?.title || "").trim();
const rightTitle = (b?.title || "").trim();
return leftTitle.localeCompare(rightTitle, undefined, { sensitivity: "base" });
};
export const sortNotesByPriority = (notes = []) => [...notes].sort(compareNotesByPriority);