first commit
This commit is contained in:
10
frontend/.env.example
Normal file
10
frontend/.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# Frontend Environment Example
|
||||
|
||||
# API Base URL (Backend server)
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
|
||||
# Environment
|
||||
VITE_ENV=development
|
||||
|
||||
# Feature Flags
|
||||
VITE_ENABLE_ANALYTICS=false
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Notely</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
2502
frontend/package-lock.json
generated
Normal file
2502
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "noteapp-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "^7.4.47",
|
||||
"@mdi/js": "^7.2.0",
|
||||
"axios": "^1.4.0",
|
||||
"bootstrap": "^5.3.0",
|
||||
"dompurify": "^3.0.0",
|
||||
"marked": "^9.0.0",
|
||||
"pinia": "^2.1.0",
|
||||
"vue": "^3.3.0",
|
||||
"vue-router": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.2.0",
|
||||
"@vue/test-utils": "^2.4.0",
|
||||
"vite": "^4.3.0",
|
||||
"vitest": "^0.34.0"
|
||||
}
|
||||
}
|
||||
1081
frontend/src/App.vue
Normal file
1081
frontend/src/App.vue
Normal file
File diff suppressed because it is too large
Load Diff
45
frontend/src/assets/styles/main.css
Normal file
45
frontend/src/assets/styles/main.css
Normal 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;
|
||||
}
|
||||
254
frontend/src/components/AdminSpaceModal.vue
Normal file
254
frontend/src/components/AdminSpaceModal.vue
Normal 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>
|
||||
278
frontend/src/components/CategoryTree.vue
Normal file
278
frontend/src/components/CategoryTree.vue
Normal 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>
|
||||
93
frontend/src/components/CreateCategoryModal.vue
Normal file
93
frontend/src/components/CreateCategoryModal.vue
Normal 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>
|
||||
156
frontend/src/components/CreateNoteModal.vue
Normal file
156
frontend/src/components/CreateNoteModal.vue
Normal 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>
|
||||
52
frontend/src/components/CreateSpaceModal.vue
Normal file
52
frontend/src/components/CreateSpaceModal.vue
Normal 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>
|
||||
265
frontend/src/components/ManageAuthProvidersModal.vue
Normal file
265
frontend/src/components/ManageAuthProvidersModal.vue
Normal 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>
|
||||
287
frontend/src/components/NoteEditor.vue
Normal file
287
frontend/src/components/NoteEditor.vue
Normal 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>
|
||||
221
frontend/src/components/NoteList.vue
Normal file
221
frontend/src/components/NoteList.vue
Normal 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>
|
||||
175
frontend/src/components/NoteViewer.vue
Normal file
175
frontend/src/components/NoteViewer.vue
Normal 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>
|
||||
269
frontend/src/components/SpaceSettingsModal.vue
Normal file
269
frontend/src/components/SpaceSettingsModal.vue
Normal 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
13
frontend/src/main.js
Normal 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");
|
||||
631
frontend/src/pages/Admin.vue
Normal file
631
frontend/src/pages/Admin.vue
Normal 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 space.project_docs.category.create 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>
|
||||
7
frontend/src/pages/Home.vue
Normal file
7
frontend/src/pages/Home.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
298
frontend/src/pages/Login.vue
Normal file
298
frontend/src/pages/Login.vue
Normal 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>
|
||||
395
frontend/src/pages/PublicSpace.vue
Normal file
395
frontend/src/pages/PublicSpace.vue
Normal 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>
|
||||
205
frontend/src/pages/Register.vue
Normal file
205
frontend/src/pages/Register.vue
Normal 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>
|
||||
123
frontend/src/router/index.js
Normal file
123
frontend/src/router/index.js
Normal 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;
|
||||
27
frontend/src/services/apiClient.js
Normal file
27
frontend/src/services/apiClient.js
Normal 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;
|
||||
108
frontend/src/stores/authStore.js
Normal file
108
frontend/src/stores/authStore.js
Normal 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,
|
||||
};
|
||||
});
|
||||
47
frontend/src/stores/settingsStore.js
Normal file
47
frontend/src/stores/settingsStore.js
Normal 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,
|
||||
};
|
||||
});
|
||||
224
frontend/src/stores/spaceStore.js
Normal file
224
frontend/src/stores/spaceStore.js
Normal 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,
|
||||
};
|
||||
});
|
||||
17
frontend/src/utils/noteSort.js
Normal file
17
frontend/src/utils/noteSort.js
Normal 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);
|
||||
39
frontend/tests/auth.spec.js
Normal file
39
frontend/tests/auth.spec.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { useAuthStore } from "../../src/stores/authStore";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
|
||||
describe("Auth Store", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("should initialize with no user", () => {
|
||||
const store = useAuthStore();
|
||||
expect(store.isAuthenticated).toBe(false);
|
||||
expect(store.user).toBeNull();
|
||||
});
|
||||
|
||||
it("should store user data on login", () => {
|
||||
const store = useAuthStore();
|
||||
|
||||
// Mock user data
|
||||
const mockUser = {
|
||||
id: "123",
|
||||
email: "test@example.com",
|
||||
username: "testuser",
|
||||
};
|
||||
|
||||
// In a real test, you'd mock the API call
|
||||
// For now, just test the store structure
|
||||
expect(store.user).toBeNull();
|
||||
});
|
||||
|
||||
it("should clear user data on logout", () => {
|
||||
const store = useAuthStore();
|
||||
store.logout();
|
||||
|
||||
expect(store.isAuthenticated).toBe(false);
|
||||
expect(store.user).toBeNull();
|
||||
expect(store.accessToken).toBeNull();
|
||||
});
|
||||
});
|
||||
20
frontend/vite.config.js
Normal file
20
frontend/vite.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8080",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
assetsDir: "assets",
|
||||
},
|
||||
});
|
||||
14
frontend/vitest.config.js
Normal file
14
frontend/vitest.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// vitest.config.js
|
||||
import { defineConfig } from "vitest/config";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user