feat: Updated admin panel styles
This commit is contained in:
125
frontend/src/components/AdminGroupModal.vue
Normal file
125
frontend/src/components/AdminGroupModal.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<teleport 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-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ mode === "create" ? "Create Group" : "Edit Group" }}</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
|
||||
</div>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Group name</label>
|
||||
<input v-model="form.name" class="form-control" type="text" required :disabled="isSystemGroup" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<input v-model="form.description" class="form-control" type="text" :disabled="isSystemGroup" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Permissions (one per line)</label>
|
||||
<textarea
|
||||
v-model="form.permissionsText"
|
||||
class="form-control permissions-textarea"
|
||||
rows="10"
|
||||
placeholder="space.create space.project_docs.category.create space.project_docs.*"
|
||||
:disabled="isSystemGroup"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="emit('close')">Cancel</button>
|
||||
<button v-if="!isSystemGroup" type="submit" class="btn btn-primary" :disabled="submitting">
|
||||
{{ submitting ? "Saving..." : mode === "create" ? "Create Group" : "Save Changes" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: "create",
|
||||
},
|
||||
group: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isSystemGroup: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
submitting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "submit"]);
|
||||
|
||||
const form = ref({
|
||||
name: "",
|
||||
description: "",
|
||||
permissionsText: "",
|
||||
});
|
||||
|
||||
const hydrateForm = () => {
|
||||
form.value = {
|
||||
name: props.group?.name || "",
|
||||
description: props.group?.description || "",
|
||||
permissionsText: (props.group?.permissions || []).join("\n"),
|
||||
};
|
||||
};
|
||||
|
||||
watch(() => [props.mode, props.group], hydrateForm, { immediate: true });
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit("submit", {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
permissionsText: form.value.permissionsText,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-modal {
|
||||
z-index: 2000;
|
||||
overflow-y: auto;
|
||||
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal-backdrop {
|
||||
z-index: 1990;
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
.permissions-textarea {
|
||||
font-family: "Courier New", monospace;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.admin-modal {
|
||||
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 0.75rem;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<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 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-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit Space</h5>
|
||||
@@ -96,7 +96,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
@@ -252,3 +252,30 @@ const deleteSpace = async () => {
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-modal {
|
||||
z-index: 2000;
|
||||
overflow-y: auto;
|
||||
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal-backdrop {
|
||||
z-index: 1990;
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.admin-modal {
|
||||
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 0.75rem;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
111
frontend/src/components/AdminUserModal.vue
Normal file
111
frontend/src/components/AdminUserModal.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<teleport 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-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit User</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
|
||||
</div>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input class="form-control" :value="user?.username || ''" type="text" disabled />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email</label>
|
||||
<input class="form-control" :value="user?.email || ''" type="text" disabled />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Status</label>
|
||||
<input class="form-control" :value="user?.is_active ? 'Active' : 'Inactive'" type="text" disabled />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Groups</label>
|
||||
<select v-model="groupIds" class="form-select" multiple>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="emit('close')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||||
{{ submitting ? "Saving..." : "Save Changes" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
groups: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
submitting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "submit"]);
|
||||
|
||||
const groupIds = ref([]);
|
||||
|
||||
watch(
|
||||
() => props.user,
|
||||
(user) => {
|
||||
groupIds.value = [...(user?.group_ids || [])];
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit("submit", { group_ids: groupIds.value });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-modal {
|
||||
z-index: 2000;
|
||||
overflow-y: auto;
|
||||
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal-backdrop {
|
||||
z-index: 1990;
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.admin-modal {
|
||||
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 0.75rem;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -38,48 +38,39 @@
|
||||
|
||||
<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 v-else class="list-group users-list">
|
||||
<div v-for="u in users" :key="u.id" class="list-group-item user-row">
|
||||
<div class="user-row-main">
|
||||
<div class="user-name-line">
|
||||
<span class="fw-semibold user-name">{{ u.username }}</span>
|
||||
<span class="badge" :class="u.is_active ? 'text-bg-success' : 'text-bg-secondary'">
|
||||
{{ u.is_active ? "Active" : "Inactive" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="user-meta-grid">
|
||||
<div class="user-meta-item">
|
||||
<div class="user-meta-label">Email</div>
|
||||
<div class="user-meta-value">{{ u.email }}</div>
|
||||
</div>
|
||||
<div class="user-meta-item">
|
||||
<div class="user-meta-label">Joined</div>
|
||||
<div class="user-meta-value">{{ formatDate(u.created_at) }}</div>
|
||||
</div>
|
||||
<div class="user-meta-item user-meta-item-groups">
|
||||
<div class="user-meta-label">Groups</div>
|
||||
<div class="user-meta-value">{{ getUserGroupSummary(u) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-row-actions">
|
||||
<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-danger" @click="deleteUser(u)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -106,7 +97,10 @@
|
||||
<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 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-danger" :disabled="group.is_system" @click="deleteGroup(group)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,9 +148,12 @@
|
||||
<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 class="d-flex align-items-center gap-2">
|
||||
<span class="badge" :class="provider.is_active ? 'text-bg-success' : 'text-bg-secondary'">
|
||||
{{ provider.is_active ? "Active" : "Disabled" }}
|
||||
</span>
|
||||
<button class="btn btn-sm btn-outline-danger" @click="deleteProvider(provider)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -309,49 +306,17 @@
|
||||
|
||||
<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>
|
||||
<AdminGroupModal
|
||||
v-if="showGroupModal"
|
||||
:mode="groupModalMode"
|
||||
:group="selectedGroup"
|
||||
:is-system-group="isEditingSystemGroup"
|
||||
:submitting="submittingGroupModal"
|
||||
@close="closeGroupModal"
|
||||
@submit="submitGroupModal"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<AdminUserModal v-if="showUserModal && selectedUser" :user="selectedUser" :groups="groups" :submitting="submittingUserModal" @close="closeUserModal" @submit="submitUserModal" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -359,6 +324,8 @@ import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import apiClient from "../services/apiClient";
|
||||
import AdminSpaceModal from "../components/AdminSpaceModal.vue";
|
||||
import AdminGroupModal from "../components/AdminGroupModal.vue";
|
||||
import AdminUserModal from "../components/AdminUserModal.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const activeTab = ref("users");
|
||||
@@ -367,6 +334,9 @@ const successMessage = ref("");
|
||||
|
||||
const users = ref([]);
|
||||
const loadingUsers = ref(false);
|
||||
const showUserModal = ref(false);
|
||||
const submittingUserModal = ref(false);
|
||||
const selectedUser = ref(null);
|
||||
|
||||
const groups = ref([]);
|
||||
const loadingGroups = ref(false);
|
||||
@@ -374,11 +344,7 @@ const showGroupModal = ref(false);
|
||||
const groupModalMode = ref("create");
|
||||
const editingGroupId = ref("");
|
||||
const submittingGroupModal = ref(false);
|
||||
const groupModalForm = ref({
|
||||
name: "",
|
||||
description: "",
|
||||
permissionsText: "",
|
||||
});
|
||||
const selectedGroup = ref(null);
|
||||
|
||||
const spaces = ref([]);
|
||||
const loadingSpaces = ref(false);
|
||||
@@ -439,8 +405,10 @@ const loadUsers = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateUserGroups = async (userId, groupIds) => {
|
||||
clearMessages();
|
||||
const updateUserGroups = async (userId, groupIds, options = {}) => {
|
||||
if (!options.silent) {
|
||||
clearMessages();
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.put(`/api/v1/admin/users/${userId}/groups`, { group_ids: groupIds });
|
||||
const updatedUser = response.data;
|
||||
@@ -448,26 +416,70 @@ const updateUserGroups = async (userId, groupIds) => {
|
||||
if (userIndex !== -1) {
|
||||
users.value[userIndex] = { ...users.value[userIndex], ...updatedUser };
|
||||
}
|
||||
successMessage.value = "User groups updated.";
|
||||
if (!options.silent) {
|
||||
successMessage.value = "User groups updated.";
|
||||
}
|
||||
return updatedUser;
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || "Failed to update user groups.";
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const resetGroupModalForm = () => {
|
||||
groupModalForm.value = {
|
||||
name: "",
|
||||
description: "",
|
||||
permissionsText: "",
|
||||
};
|
||||
const getUserGroupSummary = (user) => {
|
||||
const ids = user?.group_ids || [];
|
||||
if (!ids.length) {
|
||||
return "No groups";
|
||||
}
|
||||
const names = ids.map((groupID) => groups.value.find((group) => group.id === groupID)?.name).filter(Boolean);
|
||||
return names.length ? names.join(", ") : "No groups";
|
||||
};
|
||||
|
||||
const openEditUserModal = (user) => {
|
||||
selectedUser.value = { ...user };
|
||||
showUserModal.value = true;
|
||||
};
|
||||
|
||||
const closeUserModal = () => {
|
||||
showUserModal.value = false;
|
||||
submittingUserModal.value = false;
|
||||
selectedUser.value = null;
|
||||
};
|
||||
|
||||
const submitUserModal = async ({ group_ids }) => {
|
||||
submittingUserModal.value = true;
|
||||
clearMessages();
|
||||
try {
|
||||
await updateUserGroups(selectedUser.value.id, group_ids, { silent: true });
|
||||
successMessage.value = "User updated.";
|
||||
closeUserModal();
|
||||
} catch {
|
||||
// error message handled in updateUserGroups
|
||||
} finally {
|
||||
submittingUserModal.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async (user) => {
|
||||
if (!confirm(`Delete user "${user.username}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
try {
|
||||
await apiClient.delete(`/api/v1/admin/users/${user.id}`);
|
||||
users.value = users.value.filter((item) => item.id !== user.id);
|
||||
successMessage.value = `User "${user.username}" deleted.`;
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || "Failed to delete user.";
|
||||
}
|
||||
};
|
||||
|
||||
const isEditingSystemGroup = computed(() => {
|
||||
if (groupModalMode.value !== "edit") {
|
||||
return false;
|
||||
}
|
||||
const group = groups.value.find((item) => item.id === editingGroupId.value);
|
||||
return !!group?.is_system;
|
||||
return !!selectedGroup.value?.is_system;
|
||||
});
|
||||
|
||||
const splitPermissionsByNewline = (raw) =>
|
||||
@@ -479,24 +491,21 @@ const splitPermissionsByNewline = (raw) =>
|
||||
const openCreateGroupModal = () => {
|
||||
groupModalMode.value = "create";
|
||||
editingGroupId.value = "";
|
||||
resetGroupModalForm();
|
||||
selectedGroup.value = null;
|
||||
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"),
|
||||
};
|
||||
selectedGroup.value = { ...group };
|
||||
showGroupModal.value = true;
|
||||
};
|
||||
|
||||
const closeGroupModal = () => {
|
||||
showGroupModal.value = false;
|
||||
submittingGroupModal.value = false;
|
||||
selectedGroup.value = null;
|
||||
};
|
||||
|
||||
const loadGroups = async () => {
|
||||
@@ -512,14 +521,14 @@ const loadGroups = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const submitGroupModal = async () => {
|
||||
const submitGroupModal = async (formData) => {
|
||||
submittingGroupModal.value = true;
|
||||
clearMessages();
|
||||
try {
|
||||
const payload = {
|
||||
name: groupModalForm.value.name,
|
||||
description: groupModalForm.value.description,
|
||||
permissions: splitPermissionsByNewline(groupModalForm.value.permissionsText),
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
permissions: splitPermissionsByNewline(formData.permissionsText),
|
||||
};
|
||||
|
||||
if (groupModalMode.value === "create") {
|
||||
@@ -531,7 +540,6 @@ const submitGroupModal = async () => {
|
||||
}
|
||||
|
||||
closeGroupModal();
|
||||
resetGroupModalForm();
|
||||
await Promise.all([loadGroups(), loadUsers()]);
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || `Failed to ${groupModalMode.value === "create" ? "create" : "update"} group.`;
|
||||
@@ -540,6 +548,24 @@ const submitGroupModal = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGroup = async (group) => {
|
||||
if (group.is_system) {
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Delete group "${group.name}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
try {
|
||||
await apiClient.delete(`/api/v1/admin/groups/${group.id}`);
|
||||
successMessage.value = `Group "${group.name}" deleted.`;
|
||||
await Promise.all([loadGroups(), loadUsers()]);
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || "Failed to delete group.";
|
||||
}
|
||||
};
|
||||
|
||||
const loadSpaces = async () => {
|
||||
loadingSpaces.value = true;
|
||||
clearMessages();
|
||||
@@ -625,6 +651,21 @@ const createProvider = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteProvider = async (provider) => {
|
||||
if (!confirm(`Delete identity provider "${provider.name}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
try {
|
||||
await apiClient.delete(`/api/v1/admin/auth/providers/${provider.id}`);
|
||||
providers.value = providers.value.filter((item) => item.id !== provider.id);
|
||||
successMessage.value = `Provider "${provider.name}" deleted.`;
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || "Failed to delete provider.";
|
||||
}
|
||||
};
|
||||
|
||||
const loadFeatureFlags = async () => {
|
||||
loadingFeatureFlags.value = true;
|
||||
clearMessages();
|
||||
@@ -699,7 +740,96 @@ onMounted(async () => {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.permissions-textarea {
|
||||
font-family: "Courier New", monospace;
|
||||
.users-list .list-group-item {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.user-row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-row-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-actions-stack {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.user-name-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.user-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem 1.25rem;
|
||||
}
|
||||
|
||||
.user-meta-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #6c757d;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.user-meta-value {
|
||||
color: #495057;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.user-meta-item-groups {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.user-meta-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.user-meta-item-groups {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.user-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.user-row-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-row-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-actions-stack {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,15 +15,12 @@
|
||||
*/
|
||||
export function preprocessMarkdown(content) {
|
||||
if (!content) return content;
|
||||
return content.replace(
|
||||
/!\[([^\]]*)\]\(([^\s)"]+)(?:\s+"([^"]*)")?\s+=(\d*%?)[xX](\d*%?)\)/gi,
|
||||
(_, alt, url, title, w, h) => {
|
||||
const safeAlt = alt.replace(/"/g, """);
|
||||
let attrs = `src="${url}" alt="${safeAlt}"`;
|
||||
if (title) attrs += ` title="${title.replace(/"/g, """)}"`;
|
||||
if (w) attrs += ` width="${w}"`;
|
||||
if (h) attrs += ` height="${h}"`;
|
||||
return `<img ${attrs}>`;
|
||||
},
|
||||
);
|
||||
return content.replace(/!\[([^\]]*)\]\(([^\s)"]+)(?:\s+"([^"]*)")?\s+=(\d*%?)[xX](\d*%?)\)/gi, (_, alt, url, title, w, h) => {
|
||||
const safeAlt = alt.replace(/"/g, """);
|
||||
let attrs = `src="${url}" alt="${safeAlt}"`;
|
||||
if (title) attrs += ` title="${title.replace(/"/g, """)}"`;
|
||||
if (w) attrs += ` width="${w}"`;
|
||||
if (h) attrs += ` height="${h}"`;
|
||||
return `<img ${attrs}>`;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user