722 lines
32 KiB
Vue
722 lines
32 KiB
Vue
<template>
|
|
<div class="admin-page">
|
|
<div class="admin-topbar d-flex justify-content-between align-items-center mb-0 gap-2">
|
|
<button class="btn btn-outline-secondary d-md-none" type="button" aria-label="Open admin navigation" @click="showMobileSidebar = true">
|
|
<i class="mdi mdi-menu" aria-hidden="true"></i>
|
|
</button>
|
|
<div class="d-flex align-items-start 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>
|
|
</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>
|
|
|
|
<div class="admin-shell">
|
|
<div v-if="showMobileSidebar" class="admin-sidebar-backdrop" @click="showMobileSidebar = false"></div>
|
|
|
|
<aside class="admin-sidebar" :class="{ open: showMobileSidebar }">
|
|
<div class="admin-sidebar-inner">
|
|
<div class="d-flex justify-content-between align-items-center px-2 py-1 d-md-none">
|
|
<h6 class="mb-0">Admin Sections</h6>
|
|
<button type="button" class="btn-close" aria-label="Close" @click="showMobileSidebar = false"></button>
|
|
</div>
|
|
|
|
<nav class="nav nav-pills flex-column gap-1 admin-nav">
|
|
<button v-for="tab in adminTabs" :key="tab.id" class="nav-link text-start" :class="{ active: activeTab === tab.id }" @click="selectTab(tab.id)">
|
|
{{ tab.label }}
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
</aside>
|
|
|
|
<main class="admin-content">
|
|
<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="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>
|
|
|
|
<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>
|
|
<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>
|
|
</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-3">
|
|
<h5 class="mb-0">Identity Providers</h5>
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingProviders" @click="loadProviders">Refresh</button>
|
|
<button class="btn btn-sm btn-primary" @click="openCreateProviderModal"><i class="mdi mdi-plus me-1" aria-hidden="true"></i>Add Provider</button>
|
|
</div>
|
|
</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">
|
|
<div v-for="provider in providers" :key="provider.id" class="list-group-item d-flex justify-content-between align-items-center">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<i
|
|
class="mdi"
|
|
:class="provider.is_active ? 'mdi-check-circle text-success' : 'mdi-close-circle text-secondary'"
|
|
:title="provider.is_active ? 'Provider enabled' : 'Provider disabled'"
|
|
aria-hidden="true"
|
|
></i>
|
|
<span class="fw-semibold">{{ provider.name }}</span>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-sm btn-outline-secondary" @click="openEditProviderModal(provider)">Edit</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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="feature-flag-item border rounded p-3">
|
|
<div class="d-flex justify-content-between align-items-center mb-0" :class="{ 'mb-3': featureFlagsForm.file_explorer_enabled }">
|
|
<div>
|
|
<div class="fw-semibold">Enable File Explorer</div>
|
|
<div class="small text-muted">Allow users to browse and insert files from an S3 bucket directly into notes.</div>
|
|
</div>
|
|
<div class="form-check form-switch m-0">
|
|
<input id="flag-file-explorer" v-model="featureFlagsForm.file_explorer_enabled" class="form-check-input" type="checkbox" />
|
|
</div>
|
|
</div>
|
|
<div v-if="featureFlagsForm.file_explorer_enabled" class="row g-2 mt-1">
|
|
<div class="col-md-6">
|
|
<label class="form-label small mb-1">S3 Endpoint URL</label>
|
|
<input v-model="featureFlagsForm.s3_endpoint" type="url" class="form-control form-control-sm" placeholder="https://s3.amazonaws.com or custom endpoint" />
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label small mb-1">Bucket Name</label>
|
|
<input v-model="featureFlagsForm.s3_bucket" type="text" class="form-control form-control-sm" placeholder="my-bucket" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label small mb-1">Region</label>
|
|
<input v-model="featureFlagsForm.s3_region" type="text" class="form-control form-control-sm" placeholder="us-east-1" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label small mb-1">Access Key</label>
|
|
<input v-model="featureFlagsForm.s3_access_key" type="text" class="form-control form-control-sm" autocomplete="off" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label small mb-1">Secret Key</label>
|
|
<input
|
|
v-model="featureFlagsForm.s3_secret_key"
|
|
type="password"
|
|
class="form-control form-control-sm"
|
|
:placeholder="featureFlagsForm.s3_secret_key_set ? 'Leave blank to keep current secret' : 'Enter secret key'"
|
|
autocomplete="new-password"
|
|
/>
|
|
<div v-if="featureFlagsForm.s3_secret_key_set && !featureFlagsForm.s3_secret_key" class="small text-success mt-1">
|
|
<i class="mdi mdi-check-circle-outline" aria-hidden="true"></i> Secret key is set
|
|
</div>
|
|
</div>
|
|
</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>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
|
|
<AdminSpaceModal v-if="showSpaceModal && selectedSpace" :space="selectedSpace" :users="users" @close="showSpaceModal = false" @saved="onSpaceSaved" @deleted="onSpaceDeleted" />
|
|
|
|
<AdminGroupModal
|
|
v-if="showGroupModal"
|
|
:mode="groupModalMode"
|
|
:group="selectedGroup"
|
|
:is-system-group="isEditingSystemGroup"
|
|
:submitting="submittingGroupModal"
|
|
@close="closeGroupModal"
|
|
@submit="submitGroupModal"
|
|
/>
|
|
|
|
<AdminUserModal v-if="showUserModal && selectedUser" :user="selectedUser" :groups="groups" :submitting="submittingUserModal" @close="closeUserModal" @submit="submitUserModal" />
|
|
|
|
<AdminProviderModal
|
|
v-if="showProviderModal"
|
|
:mode="providerModalMode"
|
|
:provider="selectedProvider"
|
|
:submitting="submittingProviderModal"
|
|
:deleting="deletingProviderModal"
|
|
@close="closeProviderModal"
|
|
@submit="submitProviderModal"
|
|
@delete="deleteProviderFromModal"
|
|
/>
|
|
</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";
|
|
import AdminGroupModal from "../components/AdminGroupModal.vue";
|
|
import AdminUserModal from "../components/AdminUserModal.vue";
|
|
import AdminProviderModal from "../components/AdminProviderModal.vue";
|
|
|
|
const router = useRouter();
|
|
const activeTab = ref("users");
|
|
const showMobileSidebar = ref(false);
|
|
const error = ref("");
|
|
const successMessage = ref("");
|
|
|
|
const adminTabs = [
|
|
{ id: "users", label: "Users" },
|
|
{ id: "groups", label: "Groups" },
|
|
{ id: "spaces", label: "Spaces" },
|
|
{ id: "providers", label: "Identity Providers" },
|
|
{ id: "featureFlags", label: "Feature Flags" },
|
|
];
|
|
|
|
const selectTab = (tabId) => {
|
|
activeTab.value = tabId;
|
|
showMobileSidebar.value = false;
|
|
};
|
|
|
|
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);
|
|
const showGroupModal = ref(false);
|
|
const groupModalMode = ref("create");
|
|
const editingGroupId = ref("");
|
|
const submittingGroupModal = ref(false);
|
|
const selectedGroup = ref(null);
|
|
|
|
const spaces = ref([]);
|
|
const loadingSpaces = ref(false);
|
|
const showSpaceModal = ref(false);
|
|
const selectedSpace = ref(null);
|
|
|
|
const providers = ref([]);
|
|
const loadingProviders = ref(false);
|
|
const showProviderModal = ref(false);
|
|
const providerModalMode = ref("create");
|
|
const selectedProvider = ref(null);
|
|
const submittingProviderModal = ref(false);
|
|
const deletingProviderModal = ref(false);
|
|
|
|
const loadingFeatureFlags = ref(false);
|
|
const savingFeatureFlags = ref(false);
|
|
const featureFlagsForm = ref({
|
|
registration_enabled: true,
|
|
provider_login_enabled: true,
|
|
public_sharing_enabled: true,
|
|
file_explorer_enabled: false,
|
|
s3_endpoint: "",
|
|
s3_bucket: "",
|
|
s3_region: "",
|
|
s3_access_key: "",
|
|
s3_secret_key: "",
|
|
s3_secret_key_set: false,
|
|
});
|
|
|
|
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, options = {}) => {
|
|
if (!options.silent) {
|
|
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 };
|
|
}
|
|
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 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;
|
|
}
|
|
return !!selectedGroup.value?.is_system;
|
|
});
|
|
|
|
const splitPermissionsByNewline = (raw) =>
|
|
(raw || "")
|
|
.split(/\r?\n/)
|
|
.map((permission) => permission.trim())
|
|
.filter(Boolean);
|
|
|
|
const openCreateGroupModal = () => {
|
|
groupModalMode.value = "create";
|
|
editingGroupId.value = "";
|
|
selectedGroup.value = null;
|
|
showGroupModal.value = true;
|
|
};
|
|
|
|
const openEditGroupModal = (group) => {
|
|
groupModalMode.value = "edit";
|
|
editingGroupId.value = group.id;
|
|
selectedGroup.value = { ...group };
|
|
showGroupModal.value = true;
|
|
};
|
|
|
|
const closeGroupModal = () => {
|
|
showGroupModal.value = false;
|
|
submittingGroupModal.value = false;
|
|
selectedGroup.value = null;
|
|
};
|
|
|
|
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 (formData) => {
|
|
submittingGroupModal.value = true;
|
|
clearMessages();
|
|
try {
|
|
const payload = {
|
|
name: formData.name,
|
|
description: formData.description,
|
|
permissions: splitPermissionsByNewline(formData.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();
|
|
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 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();
|
|
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 openCreateProviderModal = () => {
|
|
providerModalMode.value = "create";
|
|
selectedProvider.value = null;
|
|
showProviderModal.value = true;
|
|
};
|
|
|
|
const openEditProviderModal = (provider) => {
|
|
providerModalMode.value = "edit";
|
|
selectedProvider.value = { ...provider };
|
|
showProviderModal.value = true;
|
|
};
|
|
|
|
const closeProviderModal = () => {
|
|
showProviderModal.value = false;
|
|
submittingProviderModal.value = false;
|
|
deletingProviderModal.value = false;
|
|
selectedProvider.value = null;
|
|
};
|
|
|
|
const submitProviderModal = async (formData) => {
|
|
submittingProviderModal.value = true;
|
|
clearMessages();
|
|
try {
|
|
if (providerModalMode.value === "create") {
|
|
await apiClient.post("/api/v1/admin/auth/providers", formData);
|
|
successMessage.value = "Provider added.";
|
|
} else {
|
|
await apiClient.put(`/api/v1/admin/auth/providers/${selectedProvider.value.id}`, formData);
|
|
successMessage.value = "Provider updated.";
|
|
}
|
|
|
|
closeProviderModal();
|
|
await loadProviders();
|
|
} catch (e) {
|
|
error.value = e.response?.data || `Failed to ${providerModalMode.value === "create" ? "create" : "update"} provider.`;
|
|
} finally {
|
|
submittingProviderModal.value = false;
|
|
}
|
|
};
|
|
|
|
const loadProviders = async () => {
|
|
loadingProviders.value = true;
|
|
clearMessages();
|
|
try {
|
|
const res = await apiClient.get("/api/v1/admin/auth/providers");
|
|
providers.value = res.data.providers || [];
|
|
} catch (e) {
|
|
error.value = e.response?.data || "Failed to load providers.";
|
|
} finally {
|
|
loadingProviders.value = false;
|
|
}
|
|
};
|
|
|
|
const deleteProviderFromModal = async (provider) => {
|
|
if (!provider?.id) {
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`Delete identity provider "${provider.name}"? This action cannot be undone.`)) {
|
|
return;
|
|
}
|
|
|
|
deletingProviderModal.value = true;
|
|
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.`;
|
|
closeProviderModal();
|
|
} catch (e) {
|
|
error.value = e.response?.data || "Failed to delete provider.";
|
|
} finally {
|
|
deletingProviderModal.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,
|
|
file_explorer_enabled: !!res.data.file_explorer_enabled,
|
|
s3_endpoint: res.data.s3_endpoint || "",
|
|
s3_bucket: res.data.s3_bucket || "",
|
|
s3_region: res.data.s3_region || "",
|
|
s3_access_key: res.data.s3_access_key || "",
|
|
s3_secret_key: "", // never pre-fill the secret
|
|
s3_secret_key_set: !!res.data.s3_secret_key_set,
|
|
};
|
|
} 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", {
|
|
registration_enabled: featureFlagsForm.value.registration_enabled,
|
|
provider_login_enabled: featureFlagsForm.value.provider_login_enabled,
|
|
public_sharing_enabled: featureFlagsForm.value.public_sharing_enabled,
|
|
file_explorer_enabled: featureFlagsForm.value.file_explorer_enabled,
|
|
s3_endpoint: featureFlagsForm.value.s3_endpoint,
|
|
s3_bucket: featureFlagsForm.value.s3_bucket,
|
|
s3_region: featureFlagsForm.value.s3_region,
|
|
s3_access_key: featureFlagsForm.value.s3_access_key,
|
|
s3_secret_key: featureFlagsForm.value.s3_secret_key, // blank = keep existing
|
|
});
|
|
featureFlagsForm.value = {
|
|
registration_enabled: !!res.data.registration_enabled,
|
|
provider_login_enabled: !!res.data.provider_login_enabled,
|
|
public_sharing_enabled: !!res.data.public_sharing_enabled,
|
|
file_explorer_enabled: !!res.data.file_explorer_enabled,
|
|
s3_endpoint: res.data.s3_endpoint || "",
|
|
s3_bucket: res.data.s3_bucket || "",
|
|
s3_region: res.data.s3_region || "",
|
|
s3_access_key: res.data.s3_access_key || "",
|
|
s3_secret_key: "",
|
|
s3_secret_key_set: !!res.data.s3_secret_key_set,
|
|
};
|
|
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 src="../assets/styles/scoped/pages/Admin.css"></style>
|