All checks were successful
Build and Push App Image / build-and-push (push) Successful in 34s
356 lines
13 KiB
Vue
356 lines
13 KiB
Vue
<template>
|
|
<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-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 />
|
|
<DangerZonePanel
|
|
class="mt-4"
|
|
title-id="danger-zone-title"
|
|
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" }}
|
|
</button>
|
|
</DangerZonePanel>
|
|
</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>
|
|
|
|
<ConfirmActionModal
|
|
:visible="showDeleteConfirmModal"
|
|
:title="deleteConfirmTitle"
|
|
:message="deleteConfirmMessage"
|
|
:busy="deleteConfirmBusy"
|
|
@close="closeDeleteConfirmModal"
|
|
@confirm="confirmDeleteAction"
|
|
/>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, ref, watch } from "vue";
|
|
import apiClient from "../services/apiClient";
|
|
import { useAuthStore } from "../stores/authStore";
|
|
import ConfirmActionModal from "./ConfirmActionModal.vue";
|
|
import DangerZonePanel from "./DangerZonePanel.vue";
|
|
|
|
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"));
|
|
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(
|
|
() => 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 = (member) => {
|
|
if (!canManageMembers.value) {
|
|
return;
|
|
}
|
|
|
|
if (!member?.user_id) {
|
|
return;
|
|
}
|
|
|
|
deleteConfirmIntent.value = {
|
|
type: "member",
|
|
payload: member,
|
|
};
|
|
showDeleteConfirmModal.value = true;
|
|
};
|
|
|
|
const removeMemberConfirmed = async (member) => {
|
|
if (!member?.user_id) {
|
|
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 requestDeleteSpace = () => {
|
|
deleteConfirmIntent.value = {
|
|
type: "space",
|
|
payload: props.space,
|
|
};
|
|
showDeleteConfirmModal.value = true;
|
|
};
|
|
|
|
const deleteSpaceConfirmed = async () => {
|
|
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.";
|
|
throw e;
|
|
} finally {
|
|
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>
|