2 Commits

Author SHA1 Message Date
domrichardson
74d8899eec feat: Updates to dashboard and delete confirmations
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 34s
2026-04-01 13:40:18 +01:00
domrichardson
295e03feb4 fix: removed hardcoded api url
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m20s
2026-03-30 10:58:36 +01:00
27 changed files with 2765 additions and 1625 deletions

View File

@@ -10,8 +10,6 @@ JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters-change-in-production
ENCRYPTION_KEY=A5CC60AB92FCA026F5477DC486555882 ENCRYPTION_KEY=A5CC60AB92FCA026F5477DC486555882
FRONTEND_URL="http://localhost" FRONTEND_URL="http://localhost"
VITE_API_BASE_URL="http://localhost"
# Default Admin # Default Admin
DEFAULT_ADMIN_EMAIL=admin@notely.local DEFAULT_ADMIN_EMAIL=admin@notely.local
DEFAULT_ADMIN_USERNAME=admin DEFAULT_ADMIN_USERNAME=admin

View File

@@ -46,8 +46,6 @@ jobs:
context: . context: .
file: ./devops/docker/Dockerfile file: ./devops/docker/Dockerfile
push: true push: true
build-args: |
VITE_API_BASE_URL=${{ secrets.VITE_API_BASE_URL }}
tags: | tags: |
${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.short_sha }} ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.short_sha }}

View File

@@ -21,7 +21,6 @@ Required or commonly used:
- `JWT_SECRET` - `JWT_SECRET`
- `ENCRYPTION_KEY` - `ENCRYPTION_KEY`
- `FRONTEND_URL` - `FRONTEND_URL`
- `VITE_API_BASE_URL`
- `DEFAULT_ADMIN_EMAIL` - `DEFAULT_ADMIN_EMAIL`
- `DEFAULT_ADMIN_USERNAME` - `DEFAULT_ADMIN_USERNAME`
- `DEFAULT_ADMIN_PASSWORD` - `DEFAULT_ADMIN_PASSWORD`
@@ -41,7 +40,6 @@ Optional backend runtime values that Docker Compose will also pass through if pr
- MongoDB container: `mongodb://admin:password@mongodb:27017/noteapp?authSource=admin` - MongoDB container: `mongodb://admin:password@mongodb:27017/noteapp?authSource=admin`
- Backend port: `8080` - Backend port: `8080`
- Public frontend URL: `http://localhost` - Public frontend URL: `http://localhost`
- Browser API base URL for container builds: `http://localhost`
## 2. `backend/.env` ## 2. `backend/.env`
@@ -107,13 +105,12 @@ cp .env.example .env
### Frontend Variables In `frontend/.env.example` ### Frontend Variables In `frontend/.env.example`
- `VITE_API_BASE_URL`
- `VITE_ENV` - `VITE_ENV`
- `VITE_ENABLE_ANALYTICS` - `VITE_ENABLE_ANALYTICS`
### Variables Currently Relevant To The Frontend App ### Variables Currently Relevant To The Frontend App
- `VITE_API_BASE_URL`: used by the API client - API requests are sent to the current browser origin (same-origin runtime behavior)
The other example values are safe to keep, but the current checked-in frontend code does not actively consume them. The other example values are safe to keep, but the current checked-in frontend code does not actively consume them.

View File

@@ -133,7 +133,7 @@ Check `REDIS_ADDR`, `REDIS_PASSWORD`, and `REDIS_DB`. For local defaults, Redis
Check: Check:
- backend is running on port `8080` - backend is running on port `8080`
- frontend `VITE_API_BASE_URL` - frontend and API are reachable through the same host/origin
- Vite proxy settings in `frontend/vite.config.js` - Vite proxy settings in `frontend/vite.config.js`
### OAuth callback redirects to the wrong URL ### OAuth callback redirects to the wrong URL

View File

@@ -3,9 +3,6 @@ FROM node:25-alpine AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm install RUN npm install

View File

@@ -36,8 +36,6 @@ services:
build: build:
context: . context: .
dockerfile: ./devops/docker/Dockerfile dockerfile: ./devops/docker/Dockerfile
args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
container_name: notely-app container_name: notely-app
ports: ports:
- "${BACKEND_PORT}:${BACKEND_PORT}" - "${BACKEND_PORT}:${BACKEND_PORT}"

View File

@@ -1,8 +1,5 @@
# Frontend Environment Example # Frontend Environment Example
# API Base URL (Backend server)
VITE_API_BASE_URL=http://localhost:8080
# Environment # Environment
VITE_ENV=development VITE_ENV=development

File diff suppressed because it is too large Load Diff

View File

@@ -66,24 +66,6 @@
max-height: 600px; max-height: 600px;
} }
.danger-zone {
padding: 1rem;
border: 1px solid #f3b5b5;
border-radius: 0.75rem;
background: var(--color-surface)5f5;
}
.danger-zone-title {
color: #9f1c1c;
font-size: 1rem;
font-weight: 700;
}
.danger-zone-copy {
color: #7a2727;
font-size: 0.9rem;
}
.task-mention-panel { .task-mention-panel {
margin-top: 0.45rem; margin-top: 0.45rem;
border: 1px solid #dbe4f0; border: 1px solid #dbe4f0;
@@ -225,19 +207,6 @@
background-color: var(--color-surface); background-color: var(--color-surface);
} }
:root[data-bs-theme="dark"] .danger-zone {
background: #2d1a1a;
border-color: #7a3030;
}
:root[data-bs-theme="dark"] .danger-zone-title {
color: #fc8181;
}
:root[data-bs-theme="dark"] .danger-zone-copy {
color: #fca5a5;
}
:root[data-bs-theme="dark"] .task-mention-panel { :root[data-bs-theme="dark"] .task-mention-panel {
border-color: #3a4558; border-color: #3a4558;
background: #1f2733; background: #1f2733;
@@ -296,5 +265,3 @@
background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%); background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%);
color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%); color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%);
} }

View File

@@ -289,24 +289,6 @@
align-items: center; align-items: center;
} }
.danger-zone {
border: 1px solid #f3b5b5;
border-radius: 0.75rem;
background: var(--color-surface) 5f5;
padding: 0.75rem;
}
.danger-zone-title {
color: #9f1c1c;
margin: 0;
font-weight: 700;
}
.danger-zone-copy {
color: #7a2727;
font-size: 0.9rem;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.task-filters { .task-filters {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -0,0 +1,32 @@
.danger-zone {
padding: 1rem;
border: 1px solid #f3b5b5;
border-radius: 0.75rem;
background: #fff5f5;
}
.danger-zone-title {
color: #9f1c1c;
font-size: 1rem;
font-weight: 700;
margin: 0;
}
.danger-zone-copy {
color: #7a2727;
font-size: 0.9rem;
margin-bottom: 0;
}
:root[data-bs-theme="dark"] .danger-zone {
background: #2d1a1a;
border-color: #7a3030;
}
:root[data-bs-theme="dark"] .danger-zone-title {
color: #fc8181;
}
:root[data-bs-theme="dark"] .danger-zone-copy {
color: #fca5a5;
}

View File

@@ -62,17 +62,17 @@
</div> </div>
<div v-if="mode === 'edit'" class="col-12"> <div v-if="mode === 'edit'" class="col-12">
<div class="danger-zone border border-danger-subtle rounded p-3 mt-2"> <DangerZonePanel
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2"> class="mt-4"
<div> title-id="danger-zone-title"
<div class="fw-semibold text-danger">Danger Zone</div> title="Danger Zone"
<div class="small text-muted">Permanently delete this provider configuration.</div> description="Permanently delete this provider configuration. This action cannot be undone."
</div> >
<button type="button" class="btn btn-sm btn-outline-danger" :disabled="submitting || deleting" @click="emit('delete', props.provider)"> <button class="btn btn-danger" type="button" :disabled="submitting || deleting" @click="emit('delete', props.provider)">
<i class="mdi mdi-trash-can-outline me-1" aria-hidden="true"></i>Delete Provider <i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
Delete Provider
</button> </button>
</div> </DangerZonePanel>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -92,6 +92,7 @@
<script setup> <script setup>
import { ref, watch } from "vue"; import { ref, watch } from "vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({ const props = defineProps({
mode: { mode: {
@@ -179,6 +180,3 @@ const handleSubmit = () => {
</script> </script>
<style scoped src="../assets/styles/scoped/components/AdminProviderModal.css"></style> <style scoped src="../assets/styles/scoped/components/AdminProviderModal.css"></style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<teleport to="body"> <teleport v-if="!showDeleteConfirmModal" to="body">
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')"> <div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document"> <div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content"> <div class="modal-content">
@@ -85,24 +85,39 @@
<div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div> <div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div>
<hr /> <hr />
<div class="border border-danger rounded p-3 mt-3"> <DangerZonePanel
<h6 class="text-danger mb-1">Danger Zone</h6> class="mt-4"
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p> title-id="danger-zone-title"
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace"> title="Danger Zone"
description="Permanently delete this space and all its notes, categories, and members. This cannot be undone."
>
<button class="btn btn-danger" type="button" :disabled="deleting" @click="requestDeleteSpace">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
{{ deleting ? "Deleting..." : "Delete Space" }} {{ deleting ? "Deleting..." : "Delete Space" }}
</button> </button>
</div> </DangerZonePanel>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-backdrop fade show admin-modal-backdrop"></div> <div class="modal-backdrop fade show admin-modal-backdrop"></div>
</teleport> </teleport>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
:title="deleteConfirmTitle"
:message="deleteConfirmMessage"
:busy="deleteConfirmBusy"
@close="closeDeleteConfirmModal"
@confirm="confirmDeleteAction"
/>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import apiClient from "../services/apiClient"; import apiClient from "../services/apiClient";
import ConfirmActionModal from "./ConfirmActionModal.vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({ const props = defineProps({
space: { space: {
@@ -133,6 +148,20 @@ const error = ref("");
const success = ref(""); const success = ref("");
const newMember = ref({ user_id: "" }); const newMember = ref({ user_id: "" });
const deleting = ref(false); const deleting = ref(false);
const showDeleteConfirmModal = ref(false);
const deleteConfirmBusy = ref(false);
const deleteConfirmIntent = ref({
type: "",
payload: null,
});
const deleteConfirmTitle = computed(() => (deleteConfirmIntent.value.type === "member" ? "Remove Member" : "Delete Space"));
const deleteConfirmMessage = computed(() => {
if (deleteConfirmIntent.value.type === "member") {
const memberName = deleteConfirmIntent.value.payload?.username || deleteConfirmIntent.value.payload?.user_id || "this member";
return `Remove member "${memberName}" from this space?`;
}
return `Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
});
const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-"); const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-");
@@ -208,9 +237,20 @@ const addMember = async () => {
} }
}; };
const removeMember = async (member) => { const removeMember = (member) => {
const memberName = member?.username || member?.user_id; if (!member?.user_id) {
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) { return;
}
deleteConfirmIntent.value = {
type: "member",
payload: member,
};
showDeleteConfirmModal.value = true;
};
const removeMemberConfirmed = async (member) => {
if (!member?.user_id) {
return; return;
} }
@@ -236,10 +276,15 @@ watch(
{ immediate: true }, { immediate: true },
); );
const deleteSpace = async () => { const requestDeleteSpace = () => {
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) { deleteConfirmIntent.value = {
return; type: "space",
} payload: props.space,
};
showDeleteConfirmModal.value = true;
};
const deleteSpaceConfirmed = async () => {
deleting.value = true; deleting.value = true;
clearMessages(); clearMessages();
try { try {
@@ -247,13 +292,51 @@ const deleteSpace = async () => {
emit("deleted", props.space); emit("deleted", props.space);
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to delete space."; error.value = e.response?.data || "Failed to delete space.";
throw e;
} finally { } finally {
deleting.value = false; deleting.value = false;
} }
}; };
const closeDeleteConfirmModal = () => {
if (deleteConfirmBusy.value) {
return;
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
};
const confirmDeleteAction = async () => {
if (deleteConfirmBusy.value) {
return;
}
const { type, payload } = deleteConfirmIntent.value;
if (!type) {
return;
}
deleteConfirmBusy.value = true;
try {
if (type === "member") {
await removeMemberConfirmed(payload);
} else if (type === "space") {
await deleteSpaceConfirmed();
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
} finally {
deleteConfirmBusy.value = false;
}
};
</script> </script>
<style scoped src="../assets/styles/scoped/components/AdminSpaceModal.css"></style> <style scoped src="../assets/styles/scoped/components/AdminSpaceModal.css"></style>

View File

@@ -0,0 +1,62 @@
<template>
<teleport to="body">
<div v-if="visible" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<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-alert-outline text-danger" aria-hidden="true"></i>
<span>{{ title }}</span>
</h5>
<button type="button" class="btn-close" aria-label="Close" :disabled="busy" @click="emit('close')"></button>
</div>
<div class="modal-body">
<p class="text-muted mb-0">{{ message }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" :disabled="busy" @click="emit('close')">{{ cancelLabel }}</button>
<button type="button" class="btn btn-danger" :disabled="busy" @click="emit('confirm')">
{{ busy ? busyLabel : confirmLabel }}
</button>
</div>
</div>
</div>
</div>
<div v-if="visible" class="modal-backdrop fade show"></div>
</teleport>
</template>
<script setup>
defineProps({
visible: {
type: Boolean,
default: false,
},
title: {
type: String,
default: "Confirm Deletion",
},
message: {
type: String,
default: "Are you sure you want to continue?",
},
confirmLabel: {
type: String,
default: "Delete",
},
cancelLabel: {
type: String,
default: "Cancel",
},
busyLabel: {
type: String,
default: "Deleting...",
},
busy: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["close", "confirm"]);
</script>

View File

@@ -0,0 +1,24 @@
<template>
<section class="danger-zone" :aria-labelledby="titleId">
<h3 :id="titleId" class="danger-zone-title mb-2">{{ title }}</h3>
<p class="danger-zone-copy mb-3">{{ description }}</p>
<slot></slot>
</section>
</template>
<script setup>
defineProps({
titleId: {
type: String,
required: true,
},
title: {
type: String,
default: "Danger Zone",
},
description: {
type: String,
default: "This action is permanent and cannot be undone.",
},
});
</script>

View File

@@ -78,7 +78,7 @@
<i :class="fileIcon(obj)" style="font-size: 1rem; width: 1.1rem; flex-shrink: 0" aria-hidden="true"></i> <i :class="fileIcon(obj)" style="font-size: 1rem; width: 1.1rem; flex-shrink: 0" aria-hidden="true"></i>
<span class="flex-grow-1 text-truncate" style="font-size: 0.82rem">{{ displayName(obj) }}</span> <span class="flex-grow-1 text-truncate" style="font-size: 0.82rem">{{ displayName(obj) }}</span>
<span v-if="!obj.is_folder && obj.size > 0" class="text-muted flex-shrink-0" style="font-size: 0.68rem">{{ formatSize(obj.size) }}</span> <span v-if="!obj.is_folder && obj.size > 0" class="text-muted flex-shrink-0" style="font-size: 0.68rem">{{ formatSize(obj.size) }}</span>
<button class="btn-delete btn btn-sm btn-link p-0 text-danger ms-1" :title="obj.is_folder ? 'Delete folder' : 'Delete file'" @click.stop="deleteItem(obj)"> <button class="btn-delete btn btn-sm btn-link p-0 text-danger ms-1" :title="obj.is_folder ? 'Delete folder' : 'Delete file'" @click.stop="requestDeleteItem(obj)">
<i class="mdi mdi-trash-can-outline" style="font-size: 0.85rem" aria-hidden="true"></i> <i class="mdi mdi-trash-can-outline" style="font-size: 0.85rem" aria-hidden="true"></i>
</button> </button>
</div> </div>
@@ -87,11 +87,14 @@
<!-- Hidden file input --> <!-- Hidden file input -->
<input ref="fileInputRef" type="file" multiple class="d-none" @change="handleFilePick" /> <input ref="fileInputRef" type="file" multiple class="d-none" @change="handleFilePick" />
</div> </div>
<ConfirmActionModal :visible="showDeleteConfirmModal" title="Delete Item" :message="deleteConfirmMessage" :busy="deletingItem" @close="closeDeleteConfirmModal" @confirm="confirmDeleteItem" />
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, nextTick } from "vue"; import { ref, computed, watch, nextTick } from "vue";
import apiClient from "../services/apiClient"; import apiClient from "../services/apiClient";
import ConfirmActionModal from "./ConfirmActionModal.vue";
const props = defineProps({ const props = defineProps({
spaceId: { spaceId: {
@@ -117,6 +120,9 @@ const showNewFolderInput = ref(false);
const newFolderName = ref(""); const newFolderName = ref("");
const fileInputRef = ref(null); const fileInputRef = ref(null);
const newFolderInputRef = ref(null); const newFolderInputRef = ref(null);
const showDeleteConfirmModal = ref(false);
const pendingDeleteItem = ref(null);
const deletingItem = ref(false);
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
if (!currentPrefix.value) return []; if (!currentPrefix.value) return [];
@@ -215,9 +221,37 @@ const createFolder = async () => {
} }
}; };
const deleteItem = async (obj) => { const requestDeleteItem = (obj) => {
const label = displayName(obj); if (!obj) {
if (!confirm(`Delete "${label}"?${obj.is_folder ? "./nThis will delete all files inside the folder." : ""}`)) return; return;
}
pendingDeleteItem.value = obj;
showDeleteConfirmModal.value = true;
};
const closeDeleteConfirmModal = () => {
if (deletingItem.value) {
return;
}
showDeleteConfirmModal.value = false;
pendingDeleteItem.value = null;
};
const deleteConfirmMessage = computed(() => {
const obj = pendingDeleteItem.value;
const label = obj ? displayName(obj) : "this item";
return obj?.is_folder ? `Delete "${label}"? This will delete all files inside the folder.` : `Delete "${label}"?`;
});
const confirmDeleteItem = async () => {
const obj = pendingDeleteItem.value;
if (!obj) {
return;
}
deletingItem.value = true;
error.value = ""; error.value = "";
try { try {
if (obj.is_folder) { if (obj.is_folder) {
@@ -227,8 +261,12 @@ const deleteItem = async (obj) => {
await apiClient.delete(`/api/v1/spaces/${props.spaceId}/files/object`, { params: { key: obj.key } }); await apiClient.delete(`/api/v1/spaces/${props.spaceId}/files/object`, { params: { key: obj.key } });
} }
await loadFiles(); await loadFiles();
showDeleteConfirmModal.value = false;
pendingDeleteItem.value = null;
} catch (e) { } catch (e) {
error.value = e.response?.data || "Delete failed"; error.value = e.response?.data || "Delete failed";
} finally {
deletingItem.value = false;
} }
}; };

View File

@@ -125,16 +125,22 @@
<input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" /> <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>
<section v-if="canDelete && editingNote.id" class="danger-zone mt-4" aria-labelledby="danger-zone-title"> <DangerZonePanel v-if="canDelete && editingNote.id" class="mt-4" title-id="danger-zone-title" title="Danger Zone" description="Deleting this note is permanent and cannot be undone.">
<h3 id="danger-zone-title" class="danger-zone-title mb-2">Danger Zone</h3> <button class="btn btn-danger" type="button" @click="requestDelete">
<p class="danger-zone-copy mb-3">Deleting this note is permanent and cannot be undone.</p>
<button class="btn btn-danger" type="button" @click="confirmDelete">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i> <i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
Delete Note Delete Note
</button> </button>
</section> </DangerZonePanel>
</div> </div>
</div> </div>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
title="Delete Note"
message="Are you sure you want to delete this note? This action cannot be undone."
@close="showDeleteConfirmModal = false"
@confirm="confirmDelete"
/>
</template> </template>
<script setup> <script setup>
@@ -144,6 +150,8 @@ import { useSettingsStore } from "../stores/settingsStore";
import { useSpaceStore } from "../stores/spaceStore"; import { useSpaceStore } from "../stores/spaceStore";
import { renderMarkdown } from "../utils/markdown.js"; import { renderMarkdown } from "../utils/markdown.js";
import FileExplorer from "./FileExplorer.vue"; import FileExplorer from "./FileExplorer.vue";
import DangerZonePanel from "./DangerZonePanel.vue";
import ConfirmActionModal from "./ConfirmActionModal.vue";
const props = defineProps({ const props = defineProps({
note: { note: {
@@ -187,6 +195,7 @@ const linkedTasks = ref([]);
const showTaskPicker = ref(false); const showTaskPicker = ref(false);
const taskPickerQuery = ref(""); const taskPickerQuery = ref("");
const taskPickerLoading = ref(false); const taskPickerLoading = ref(false);
const showDeleteConfirmModal = ref(false);
const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value); const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value);
const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value); const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value);
@@ -374,13 +383,22 @@ const autoSave = () => {
detectTaskMention(); detectTaskMention();
}; };
const requestDelete = () => {
if (!props.canDelete) {
return;
}
showDeleteConfirmModal.value = true;
};
const confirmDelete = () => { const confirmDelete = () => {
if (!props.canDelete) { if (!props.canDelete) {
return; return;
} }
if (confirm("Are you sure you want to delete this note?")) { if (!editingNote.value?.id) {
emit("delete", editingNote.value.id); return;
} }
showDeleteConfirmModal.value = false;
emit("delete", editingNote.value.id);
}; };
/** Insert markdown snippet at the textarea cursor position. */ /** Insert markdown snippet at the textarea cursor position. */

View File

@@ -1,5 +1,5 @@
<template> <template>
<teleport to="body"> <teleport v-if="!showDeleteConfirmModal" to="body">
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')"> <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-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content"> <div class="modal-content">
@@ -76,13 +76,17 @@
<template v-if="canDeleteSpace"> <template v-if="canDeleteSpace">
<hr /> <hr />
<div class="border border-danger rounded p-3 mt-3"> <DangerZonePanel
<h6 class="text-danger mb-1">Danger Zone</h6> class="mt-4"
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p> title-id="danger-zone-title"
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace"> title="Danger Zone"
description="Permanently delete this space and all its notes, categories, and members. This cannot be undone."
>
<button class="btn btn-danger" type="button" :disabled="deleting" @click="requestDeleteSpace">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
{{ deleting ? "Deleting..." : "Delete Space" }} {{ deleting ? "Deleting..." : "Delete Space" }}
</button> </button>
</div> </DangerZonePanel>
</template> </template>
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div> <div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
@@ -93,12 +97,23 @@
</div> </div>
<div class="modal-backdrop fade show"></div> <div class="modal-backdrop fade show"></div>
</teleport> </teleport>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
:title="deleteConfirmTitle"
:message="deleteConfirmMessage"
:busy="deleteConfirmBusy"
@close="closeDeleteConfirmModal"
@confirm="confirmDeleteAction"
/>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import apiClient from "../services/apiClient"; import apiClient from "../services/apiClient";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
import ConfirmActionModal from "./ConfirmActionModal.vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({ const props = defineProps({
space: { space: {
@@ -130,6 +145,20 @@ const success = ref("");
const memberForm = ref({ user_id: "" }); const memberForm = ref({ user_id: "" });
const canViewMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.view")); const canViewMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.view"));
const canManageMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.manage")); const canManageMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.manage"));
const showDeleteConfirmModal = ref(false);
const deleteConfirmBusy = ref(false);
const deleteConfirmIntent = ref({
type: "",
payload: null,
});
const deleteConfirmTitle = computed(() => (deleteConfirmIntent.value.type === "member" ? "Remove Member" : "Delete Space"));
const deleteConfirmMessage = computed(() => {
if (deleteConfirmIntent.value.type === "member") {
const memberName = deleteConfirmIntent.value.payload?.username || deleteConfirmIntent.value.payload?.user_id || "this member";
return `Remove member "${memberName}" from this space?`;
}
return `Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
});
watch( watch(
() => props.space, () => props.space,
@@ -224,13 +253,24 @@ const addMember = async () => {
} }
}; };
const removeMember = async (member) => { const removeMember = (member) => {
if (!canManageMembers.value) { if (!canManageMembers.value) {
return; return;
} }
const memberName = member?.username || member?.user_id; if (!member?.user_id) {
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) { return;
}
deleteConfirmIntent.value = {
type: "member",
payload: member,
};
showDeleteConfirmModal.value = true;
};
const removeMemberConfirmed = async (member) => {
if (!member?.user_id) {
return; return;
} }
@@ -251,10 +291,15 @@ if (canViewMembers.value) {
Promise.all([loadMembers(), loadUserOptions()]); Promise.all([loadMembers(), loadUserOptions()]);
} }
const deleteSpace = async () => { const requestDeleteSpace = () => {
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) { deleteConfirmIntent.value = {
return; type: "space",
} payload: props.space,
};
showDeleteConfirmModal.value = true;
};
const deleteSpaceConfirmed = async () => {
deleting.value = true; deleting.value = true;
clearMessages(); clearMessages();
try { try {
@@ -262,8 +307,49 @@ const deleteSpace = async () => {
emit("deleted", props.space); emit("deleted", props.space);
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to delete space."; error.value = e.response?.data || "Failed to delete space.";
throw e;
} finally { } finally {
deleting.value = false; deleting.value = false;
} }
}; };
const closeDeleteConfirmModal = () => {
if (deleteConfirmBusy.value) {
return;
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
};
const confirmDeleteAction = async () => {
if (deleteConfirmBusy.value) {
return;
}
const { type, payload } = deleteConfirmIntent.value;
if (!type) {
return;
}
deleteConfirmBusy.value = true;
try {
if (type === "member") {
await removeMemberConfirmed(payload);
} else if (type === "space") {
await deleteSpaceConfirmed();
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
} finally {
deleteConfirmBusy.value = false;
}
};
</script> </script>

View File

@@ -185,11 +185,17 @@
</section> </section>
</div> </div>
<section v-if="selectedTaskList && canDeleteTaskList" class="danger-zone" aria-labelledby="task-list-danger-zone-title"> <DangerZonePanel
<h6 id="task-list-danger-zone-title" class="danger-zone-title">Danger Zone</h6> v-if="selectedTaskList && canDeleteTaskList"
<p class="danger-zone-copy mb-2">Delete this task list and all associated tasks permanently.</p> title-id="task-list-danger-zone-title"
<button type="button" class="btn btn-outline-danger" @click="emitDeleteTaskList">Delete Task List</button> title="Danger Zone"
</section> description="Delete this task list and all associated tasks permanently."
>
<button type="button" class="btn btn-danger" @click="emitDeleteTaskList">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
Delete Task List
</button>
</DangerZonePanel>
<teleport to="body"> <teleport to="body">
<div v-if="showStatusModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeStatusModal"> <div v-if="showStatusModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeStatusModal">
@@ -209,11 +215,16 @@
<input v-model="statusForm.color" type="text" class="form-control" placeholder="#7c8596" maxlength="20" /> <input v-model="statusForm.color" type="text" class="form-control" placeholder="#7c8596" maxlength="20" />
</div> </div>
<section v-if="statusMode === 'edit'" class="danger-zone mt-4" aria-labelledby="status-danger-zone-title"> <DangerZonePanel
<h6 id="status-danger-zone-title" class="danger-zone-title">Danger Zone</h6> v-if="statusMode === 'edit'"
<p class="danger-zone-copy mb-2">Deleting this status is permanent and cannot be undone.</p> class="mt-4"
title-id="status-danger-zone-title"
title="Danger Zone"
description="Deleting this status is permanent and cannot be undone."
copy-class="mb-2"
>
<button type="button" class="btn btn-outline-danger" @click="deleteStatusFromModal">Delete Status</button> <button type="button" class="btn btn-outline-danger" @click="deleteStatusFromModal">Delete Status</button>
</section> </DangerZonePanel>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="closeStatusModal">Cancel</button> <button type="button" class="btn btn-outline-secondary" @click="closeStatusModal">Cancel</button>
@@ -231,6 +242,7 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({ const props = defineProps({
tasks: { tasks: {

View File

@@ -0,0 +1,145 @@
<template>
<CreateSpaceModal v-if="showCreateSpaceModal" @close="emit('close-create-space')" @create="emit('create-space', $event)" />
<CreateCategoryModal
v-if="showCreateCategoryModal"
:category="editingCategory"
:parent-options="categoryParentOptions"
:parent-id="categoryModalParentId"
@close="emit('close-create-category')"
@submit="emit('submit-category', $event)"
/>
<CreateNoteModal
v-if="showCreateNoteModal"
:category-options="categoryOptions"
:default-category-id="selectedCategoryId"
@close="emit('close-create-note')"
@create="emit('create-note', $event)"
/>
<CreateTaskListModal
v-if="showCreateTaskListModal"
:category-options="categoryOptions"
:default-category-id="selectedCategoryId"
@close="emit('close-create-task-list')"
@create="emit('create-task-list', $event)"
/>
<SpaceSettingsModal
v-if="showSpaceSettingsModal && currentSpace && canManageSpaceSettings"
:space="currentSpace"
@close="emit('close-space-settings')"
@saved="emit('saved-space', $event)"
@deleted="emit('deleted-space', $event)"
/>
<TaskDetailModal
v-if="showTaskModal"
:task="taskModalDraft || {}"
:statuses="taskStatuses"
:parent-task-options="taskParentOptions"
:subtasks="taskDetailSubtasks"
@close="emit('close-task-modal')"
@save-task="emit('save-task', $event)"
@delete-task="emit('delete-task', $event)"
@transition="emit('transition-task', $event)"
@create-subtask="emit('create-subtask', $event)"
@open-task="emit('open-task', $event)"
/>
</template>
<script setup>
import CreateSpaceModal from "../CreateSpaceModal.vue";
import CreateCategoryModal from "../CreateCategoryModal.vue";
import CreateNoteModal from "../CreateNoteModal.vue";
import CreateTaskListModal from "../CreateTaskListModal.vue";
import SpaceSettingsModal from "../SpaceSettingsModal.vue";
import TaskDetailModal from "../TaskDetailModal.vue";
defineProps({
showCreateSpaceModal: {
type: Boolean,
default: false,
},
showCreateCategoryModal: {
type: Boolean,
default: false,
},
editingCategory: {
type: Object,
default: null,
},
categoryParentOptions: {
type: Array,
default: () => [],
},
categoryModalParentId: {
type: [String, Number, null],
default: null,
},
showCreateNoteModal: {
type: Boolean,
default: false,
},
categoryOptions: {
type: Array,
default: () => [],
},
selectedCategoryId: {
type: [String, Number, null],
default: null,
},
showCreateTaskListModal: {
type: Boolean,
default: false,
},
showSpaceSettingsModal: {
type: Boolean,
default: false,
},
currentSpace: {
type: Object,
default: null,
},
canManageSpaceSettings: {
type: Boolean,
default: false,
},
showTaskModal: {
type: Boolean,
default: false,
},
taskModalDraft: {
type: Object,
default: null,
},
taskStatuses: {
type: Array,
default: () => [],
},
taskParentOptions: {
type: Array,
default: () => [],
},
taskDetailSubtasks: {
type: Array,
default: () => [],
},
});
const emit = defineEmits([
"close-create-space",
"create-space",
"close-create-category",
"submit-category",
"close-create-note",
"create-note",
"close-create-task-list",
"create-task-list",
"close-space-settings",
"saved-space",
"deleted-space",
"close-task-modal",
"save-task",
"delete-task",
"transition-task",
"create-subtask",
"open-task",
]);
</script>

View File

@@ -0,0 +1,169 @@
<template>
<div class="content p-4">
<TaskBoard
v-if="activeView === 'tasks'"
:tasks="tasks"
:statuses="taskStatuses"
:selected-task-list="selectedTaskList"
:can-delete-task-list="canDeleteTasks"
@select-task="emit('select-task', $event)"
@filter-change="emit('filter-change', $event)"
@reorder-status="emit('reorder-status', $event)"
@create-status="emit('create-status', $event)"
@rename-status="emit('rename-status', $event)"
@delete-status="emit('delete-status', $event)"
@update-task-status="emit('update-task-status', $event)"
@delete-task-list="emit('delete-task-list', $event)"
/>
<SearchResultsPage
v-else-if="isSearchRoute"
:items="searchItems"
:query="searchQuery"
:current-page="searchPage"
:page-size="searchPageSize"
:view-mode="noteViewMode"
@select-note="emit('select-note', $event)"
@select-task-list="emit('select-task-list', $event)"
@page-change="emit('page-change', $event)"
/>
<NoteEditor
v-else-if="selectedNote && isEditingNote"
:note="selectedNote"
:category-options="categoryOptions"
:can-delete="canDeleteNotes"
:space-id="currentSpaceId"
@save="emit('save-note', $event)"
@delete="emit('delete-note', $event)"
@cancel="emit('cancel-edit-note')"
@open-linked-task="emit('open-linked-task', $event)"
/>
<NoteViewer
v-else-if="selectedNote"
:note="selectedNote"
:category-options="categoryOptions"
:space-id="currentSpaceId"
:linked-tasks="linkedTasksForSelectedNote"
@open-linked-task="emit('open-linked-task', $event)"
/>
<WorkspaceList
v-else
:items="displayedItems"
:can-load-more="canLoadMoreMainNotes"
:is-loading-more="isLoadingMoreMainNotes"
:view-mode="noteViewMode"
@select-note="emit('select-note', $event)"
@select-task-list="emit('select-task-list', $event)"
@load-more="emit('load-more')"
/>
</div>
</template>
<script setup>
import TaskBoard from "../TaskBoard.vue";
import SearchResultsPage from "../SearchResultsPage.vue";
import NoteEditor from "../NoteEditor.vue";
import NoteViewer from "../NoteViewer.vue";
import WorkspaceList from "../WorkspaceList.vue";
defineProps({
activeView: {
type: String,
required: true,
},
tasks: {
type: Array,
default: () => [],
},
taskStatuses: {
type: Array,
default: () => [],
},
selectedTaskList: {
type: Object,
default: null,
},
canDeleteTasks: {
type: Boolean,
default: false,
},
isSearchRoute: {
type: Boolean,
default: false,
},
searchItems: {
type: Array,
default: () => [],
},
searchQuery: {
type: String,
default: "",
},
searchPage: {
type: Number,
default: 1,
},
searchPageSize: {
type: Number,
default: 12,
},
noteViewMode: {
type: String,
default: "grid",
},
selectedNote: {
type: Object,
default: null,
},
isEditingNote: {
type: Boolean,
default: false,
},
categoryOptions: {
type: Array,
default: () => [],
},
canDeleteNotes: {
type: Boolean,
default: false,
},
currentSpaceId: {
type: String,
default: "",
},
linkedTasksForSelectedNote: {
type: Array,
default: () => [],
},
displayedItems: {
type: Array,
default: () => [],
},
canLoadMoreMainNotes: {
type: Boolean,
default: false,
},
isLoadingMoreMainNotes: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
"select-task",
"filter-change",
"reorder-status",
"create-status",
"rename-status",
"delete-status",
"update-task-status",
"delete-task-list",
"select-note",
"select-task-list",
"page-change",
"save-note",
"delete-note",
"cancel-edit-note",
"open-linked-task",
"load-more",
]);
</script>

View File

@@ -6,6 +6,7 @@ import "bootstrap/dist/css/bootstrap.min.css";
import "@mdi/font/css/materialdesignicons.min.css"; import "@mdi/font/css/materialdesignicons.min.css";
import "highlight.js/styles/github-dark.min.css"; import "highlight.js/styles/github-dark.min.css";
import "./assets/styles/main.css"; import "./assets/styles/main.css";
import "./assets/styles/shared/danger-zone.css";
const app = createApp(App); const app = createApp(App);

View File

@@ -73,7 +73,7 @@
<div class="user-row-actions"> <div class="user-row-actions">
<div class="d-flex gap-2 user-actions-stack"> <div class="d-flex gap-2 user-actions-stack">
<button class="btn btn-sm btn-outline-primary" @click="openEditUserModal(u)">Edit</button> <button class="btn btn-sm btn-outline-primary" @click="openEditUserModal(u)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @click="deleteUser(u)">Delete</button> <button class="btn btn-sm btn-outline-danger" @click="requestDeleteUser(u)">Delete</button>
</div> </div>
</div> </div>
</div> </div>
@@ -105,7 +105,7 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" @click="openEditGroupModal(group)">Edit</button> <button class="btn btn-sm btn-outline-primary" @click="openEditGroupModal(group)">Edit</button>
<button class="btn btn-sm btn-outline-danger" :disabled="group.is_system" @click="deleteGroup(group)">Delete</button> <button class="btn btn-sm btn-outline-danger" :disabled="group.is_system" @click="requestDeleteGroup(group)">Delete</button>
</div> </div>
</div> </div>
</div> </div>
@@ -286,7 +286,16 @@
:deleting="deletingProviderModal" :deleting="deletingProviderModal"
@close="closeProviderModal" @close="closeProviderModal"
@submit="submitProviderModal" @submit="submitProviderModal"
@delete="deleteProviderFromModal" @delete="requestDeleteProvider"
/>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
:title="deleteConfirmTitle"
:message="deleteConfirmMessage"
:busy="deleteConfirmBusy"
@close="closeDeleteConfirmModal"
@confirm="confirmDeleteAction"
/> />
</template> </template>
@@ -298,6 +307,7 @@ import AdminSpaceModal from "../components/AdminSpaceModal.vue";
import AdminGroupModal from "../components/AdminGroupModal.vue"; import AdminGroupModal from "../components/AdminGroupModal.vue";
import AdminUserModal from "../components/AdminUserModal.vue"; import AdminUserModal from "../components/AdminUserModal.vue";
import AdminProviderModal from "../components/AdminProviderModal.vue"; import AdminProviderModal from "../components/AdminProviderModal.vue";
import ConfirmActionModal from "../components/ConfirmActionModal.vue";
const router = useRouter(); const router = useRouter();
const activeTab = ref("users"); const activeTab = ref("users");
@@ -344,6 +354,12 @@ const providerModalMode = ref("create");
const selectedProvider = ref(null); const selectedProvider = ref(null);
const submittingProviderModal = ref(false); const submittingProviderModal = ref(false);
const deletingProviderModal = ref(false); const deletingProviderModal = ref(false);
const showDeleteConfirmModal = ref(false);
const deleteConfirmBusy = ref(false);
const deleteConfirmIntent = ref({
type: "",
payload: null,
});
const loadingFeatureFlags = ref(false); const loadingFeatureFlags = ref(false);
const savingFeatureFlags = ref(false); const savingFeatureFlags = ref(false);
@@ -365,6 +381,47 @@ const clearMessages = () => {
successMessage.value = ""; successMessage.value = "";
}; };
const deleteConfirmTitle = computed(() => {
if (deleteConfirmIntent.value.type === "user") {
return "Delete User";
}
if (deleteConfirmIntent.value.type === "group") {
return "Delete Group";
}
if (deleteConfirmIntent.value.type === "provider") {
return "Delete Identity Provider";
}
return "Confirm Deletion";
});
const deleteConfirmMessage = computed(() => {
if (deleteConfirmIntent.value.type === "user") {
const username = deleteConfirmIntent.value.payload?.username || "this user";
return `Delete user "${username}"? This action cannot be undone.`;
}
if (deleteConfirmIntent.value.type === "group") {
const name = deleteConfirmIntent.value.payload?.name || "this group";
return `Delete group "${name}"? This action cannot be undone.`;
}
if (deleteConfirmIntent.value.type === "provider") {
const name = deleteConfirmIntent.value.payload?.name || "this identity provider";
return `Delete identity provider "${name}"? This action cannot be undone.`;
}
return "Are you sure you want to continue?";
});
const closeDeleteConfirmModal = () => {
if (deleteConfirmBusy.value) {
return;
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
};
const formatDate = (iso) => { const formatDate = (iso) => {
if (!iso) return ""; if (!iso) return "";
return new Date(iso).toLocaleDateString(); return new Date(iso).toLocaleDateString();
@@ -438,8 +495,20 @@ const submitUserModal = async ({ group_ids }) => {
} }
}; };
const requestDeleteUser = (user) => {
if (!user?.id) {
return;
}
deleteConfirmIntent.value = {
type: "user",
payload: user,
};
showDeleteConfirmModal.value = true;
};
const deleteUser = async (user) => { const deleteUser = async (user) => {
if (!confirm(`Delete user "${user.username}"? This action cannot be undone.`)) { if (!user?.id) {
return; return;
} }
@@ -530,7 +599,7 @@ const deleteGroup = async (group) => {
if (group.is_system) { if (group.is_system) {
return; return;
} }
if (!confirm(`Delete group "${group.name}"? This action cannot be undone.`)) { if (!group?.id) {
return; return;
} }
@@ -541,9 +610,22 @@ const deleteGroup = async (group) => {
await Promise.all([loadGroups(), loadUsers()]); await Promise.all([loadGroups(), loadUsers()]);
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to delete group."; error.value = e.response?.data || "Failed to delete group.";
throw e;
} }
}; };
const requestDeleteGroup = (group) => {
if (!group?.id || group.is_system) {
return;
}
deleteConfirmIntent.value = {
type: "group",
payload: group,
};
showDeleteConfirmModal.value = true;
};
const loadSpaces = async () => { const loadSpaces = async () => {
loadingSpaces.value = true; loadingSpaces.value = true;
clearMessages(); clearMessages();
@@ -631,12 +713,21 @@ const loadProviders = async () => {
} }
}; };
const deleteProviderFromModal = async (provider) => { const requestDeleteProvider = (provider) => {
if (!provider?.id) { if (!provider?.id) {
return; return;
} }
if (!confirm(`Delete identity provider "${provider.name}"? This action cannot be undone.`)) { closeProviderModal();
deleteConfirmIntent.value = {
type: "provider",
payload: { ...provider },
};
showDeleteConfirmModal.value = true;
};
const deleteProviderFromModal = async (provider) => {
if (!provider?.id) {
return; return;
} }
@@ -649,11 +740,42 @@ const deleteProviderFromModal = async (provider) => {
closeProviderModal(); closeProviderModal();
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to delete provider."; error.value = e.response?.data || "Failed to delete provider.";
throw e;
} finally { } finally {
deletingProviderModal.value = false; deletingProviderModal.value = false;
} }
}; };
const confirmDeleteAction = async () => {
if (deleteConfirmBusy.value) {
return;
}
const { type, payload } = deleteConfirmIntent.value;
if (!type || !payload) {
return;
}
deleteConfirmBusy.value = true;
try {
if (type === "user") {
await deleteUser(payload);
} else if (type === "group") {
await deleteGroup(payload);
} else if (type === "provider") {
await deleteProviderFromModal(payload);
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
} finally {
deleteConfirmBusy.value = false;
}
};
const loadFeatureFlags = async () => { const loadFeatureFlags = async () => {
loadingFeatureFlags.value = true; loadingFeatureFlags.value = true;
clearMessages(); clearMessages();

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -18,13 +18,25 @@ const routes = [
{ {
path: "/", path: "/",
name: "Home", name: "Home",
component: () => import("../pages/Home.vue"), component: () => import("../pages/Dashboard.vue"),
meta: { requiresAuth: true },
},
{
path: "/dashboard/s/:spaceId/n/:noteId?",
name: "DashboardNote",
component: () => import("../pages/Dashboard.vue"),
meta: { requiresAuth: true },
},
{
path: "/dashboard/s/:spaceId/t/:taskListId",
name: "DashboardTaskList",
component: () => import("../pages/Dashboard.vue"),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {
path: "/search", path: "/search",
name: "Search", name: "Search",
component: () => import("../pages/Home.vue"), component: () => import("../pages/Dashboard.vue"),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {

View File

@@ -1,8 +1,10 @@
import axios from "axios"; import axios from "axios";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
const runtimeOrigin = typeof window !== "undefined" ? window.location.origin : "";
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || "http://localhost:8080", baseURL: runtimeOrigin,
withCredentials: true, withCredentials: true,
}); });