feat: Updated admin panel providers list & modal

This commit is contained in:
domrichardson
2026-03-26 16:27:14 +00:00
parent 9cf71ab4a0
commit 005a8f4cf0
40 changed files with 2391 additions and 1051 deletions

33
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,33 @@
import js from "@eslint/js";
import globals from "globals";
import pluginVue from "eslint-plugin-vue";
export default [
{
ignores: ["dist/**", "node_modules/**"],
},
js.configs.recommended,
...pluginVue.configs["flat/essential"],
{
files: ["**/*.{js,mjs,cjs,vue}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
...globals.node,
},
},
rules: {
"no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
"no-console": "off",
"vue/multi-word-component-names": "off",
},
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,9 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs"
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@mdi/font": "^7.4.47",
@@ -23,8 +25,13 @@
"vue-router": "^4.2.0"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@vitejs/plugin-vue": "^4.2.0",
"@vue/test-utils": "^2.4.0",
"eslint": "^9.22.0",
"eslint-plugin-vue": "^9.32.0",
"globals": "^16.0.0",
"jsdom": "^29.0.1",
"vite": "^4.3.0",
"vitest": "^0.34.0"
}

View File

@@ -808,11 +808,6 @@ const createSpace = async (spaceData) => {
await spaceStore.createSpace(spaceData);
};
const createCategory = async (categoryData) => {
showCreateCategoryModal.value = false;
await spaceStore.createCategory(currentSpace.value.id, categoryData);
};
const openCreateCategoryModal = () => {
if (!canCreateCategories.value) {
return;

View File

@@ -60,6 +60,20 @@
<label for="provider-active" class="form-check-label">Provider is active</label>
</div>
</div>
<div v-if="mode === 'edit'" class="col-12">
<div class="danger-zone border border-danger-subtle rounded p-3 mt-2">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2">
<div>
<div class="fw-semibold text-danger">Danger Zone</div>
<div class="small text-muted">Permanently delete this provider configuration.</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" :disabled="submitting || deleting" @click="emit('delete', props.provider)">
<i class="mdi mdi-trash-can-outline me-1" aria-hidden="true"></i>Delete Provider
</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
@@ -92,9 +106,13 @@ const props = defineProps({
type: Boolean,
default: false,
},
deleting: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["close", "submit"]);
const emit = defineEmits(["close", "submit", "delete"]);
const form = ref({
name: "",

View File

@@ -153,6 +153,12 @@
<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">
@@ -277,8 +283,10 @@
:mode="providerModalMode"
:provider="selectedProvider"
:submitting="submittingProviderModal"
:deleting="deletingProviderModal"
@close="closeProviderModal"
@submit="submitProviderModal"
@delete="deleteProviderFromModal"
/>
</template>
@@ -335,6 +343,7 @@ 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);
@@ -584,6 +593,7 @@ const openEditProviderModal = (provider) => {
const closeProviderModal = () => {
showProviderModal.value = false;
submittingProviderModal.value = false;
deletingProviderModal.value = false;
selectedProvider.value = null;
};
@@ -612,7 +622,7 @@ const loadProviders = async () => {
loadingProviders.value = true;
clearMessages();
try {
const res = await apiClient.get("/api/v1/auth/providers");
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.";
@@ -621,18 +631,26 @@ const loadProviders = async () => {
}
};
const deleteProvider = async (provider) => {
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;
}
};

View File

@@ -1,10 +1,22 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { useAuthStore } from "../../src/stores/authStore";
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
vi.mock("../src/services/apiClient.js", () => ({
default: {
get: vi.fn(),
post: vi.fn(() => Promise.resolve({})),
},
}));
import apiClient from "../src/services/apiClient.js";
import { useAuthStore } from "../src/stores/authStore.js";
describe("Auth Store", () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it("should initialize with no user", () => {
@@ -13,27 +25,76 @@ describe("Auth Store", () => {
expect(store.user).toBeNull();
});
it("should store user data on login", () => {
it("should store user data with setSession", () => {
const store = useAuthStore();
// Mock user data
const mockUser = {
id: "123",
email: "test@example.com",
username: "testuser",
permissions: ["space.demo.note.create"],
};
// In a real test, you'd mock the API call
// For now, just test the store structure
expect(store.user).toBeNull();
store.setSession({ user: mockUser });
expect(store.isAuthenticated).toBe(true);
expect(store.user).toEqual(mockUser);
expect(store.hasPermission("space.demo.note.create")).toBe(true);
});
it("should clear user data on logout", () => {
it("should login and persist returned user", async () => {
const store = useAuthStore();
apiClient.post.mockResolvedValueOnce({
data: {
user: {
id: "123",
email: "test@example.com",
username: "testuser",
permissions: [],
},
},
});
const result = await store.login(" test@example.com ", "password123");
expect(apiClient.post).toHaveBeenCalledWith("/api/v1/auth/login", {
email: "test@example.com",
password: "password123",
});
expect(result.user.username).toBe("testuser");
expect(store.user?.username).toBe("testuser");
});
it("should clear user data on logout", async () => {
const store = useAuthStore();
store.setSession({
user: {
id: "123",
email: "test@example.com",
username: "testuser",
permissions: ["space.demo.settings.delete"],
},
});
store.logout();
expect(store.isAuthenticated).toBe(false);
expect(store.user).toBeNull();
expect(store.accessToken).toBeNull();
expect(apiClient.post).toHaveBeenCalledWith("/api/v1/auth/logout");
});
it("should evaluate space permissions using the space permission key", () => {
const store = useAuthStore();
store.setSession({
user: {
id: "123",
email: "test@example.com",
username: "testuser",
permissions: ["space.docs.settings.delete", "space.*.note.create"],
},
});
expect(store.hasSpacePermission({ permission_key: "docs" }, "settings.delete")).toBe(true);
expect(store.hasSpacePermission({ permission_key: "docs" }, "note.create")).toBe(true);
expect(store.hasSpacePermission({ permission_key: "docs" }, "note.delete")).toBe(false);
});
});