@@ -0,0 +1,5 @@
|
||||
import AdminSettingsPage from './AdminSettingsPage.vue';
|
||||
|
||||
export default { title: 'Pages/AdminSettingsPage', component: AdminSettingsPage };
|
||||
|
||||
export const SettingsForm = {};
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { getAdminSettings, updateAdminSettings } from '@/services/admin-settings-service.js';
|
||||
|
||||
const settings = reactive({
|
||||
general: { publicContactEmail: '', publicContactPhone: '' },
|
||||
inventory: { maxImagesPerItem: 10 },
|
||||
pricing: { defaultStrategy: 'median' },
|
||||
security: { sessionIdleMinutes: 60 },
|
||||
});
|
||||
const isLoading = ref(true);
|
||||
const isSaving = ref(false);
|
||||
const message = ref('');
|
||||
const error = ref('');
|
||||
|
||||
function csrfToken() {
|
||||
return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? '';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
Object.assign(settings, await getAdminSettings());
|
||||
} catch (requestError) {
|
||||
error.value = requestError.message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
isSaving.value = true;
|
||||
message.value = '';
|
||||
error.value = '';
|
||||
try {
|
||||
Object.assign(settings, await updateAdminSettings(settings, csrfToken()));
|
||||
message.value = 'Settings saved.';
|
||||
} catch (requestError) {
|
||||
error.value = requestError.message;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-page">
|
||||
<p class="catalog-kicker">Configuration</p>
|
||||
<h1>Settings</h1>
|
||||
<p v-if="isLoading" class="state state--loading" role="status">Loading settings...</p>
|
||||
<p v-if="message" class="state" role="status">{{ message }}</p>
|
||||
<p v-if="error" class="state state--error" role="alert">{{ error }}</p>
|
||||
<form v-if="!isLoading" class="admin-workspace-grid" @submit.prevent="save">
|
||||
<fieldset class="showcase-panel">
|
||||
<legend>General</legend>
|
||||
<label class="fixture-label" for="settings-email">Public contact email</label>
|
||||
<input id="settings-email" v-model="settings.general.publicContactEmail" class="fixture-input" type="email" />
|
||||
<label class="fixture-label" for="settings-phone">Public contact phone</label>
|
||||
<input id="settings-phone" v-model="settings.general.publicContactPhone" class="fixture-input" />
|
||||
</fieldset>
|
||||
<fieldset class="showcase-panel">
|
||||
<legend>Inventory</legend>
|
||||
<label class="fixture-label" for="settings-images">Maximum images per item</label>
|
||||
<input id="settings-images" v-model.number="settings.inventory.maxImagesPerItem" class="fixture-input" type="number" min="1" max="100" />
|
||||
</fieldset>
|
||||
<fieldset class="showcase-panel">
|
||||
<legend>Pricing</legend>
|
||||
<label class="fixture-label" for="settings-strategy">Default valuation strategy</label>
|
||||
<select id="settings-strategy" v-model="settings.pricing.defaultStrategy" class="fixture-input">
|
||||
<option value="median">Median</option>
|
||||
<option value="average">Average</option>
|
||||
<option value="weighted_average">Weighted average</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
<fieldset class="showcase-panel">
|
||||
<legend>Security</legend>
|
||||
<label class="fixture-label" for="settings-idle">Session idle minutes</label>
|
||||
<input id="settings-idle" v-model.number="settings.security.sessionIdleMinutes" class="fixture-input" type="number" min="5" max="43200" />
|
||||
</fieldset>
|
||||
<button class="button button--primary" type="submit" :disabled="isSaving">Save settings</button>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
@@ -19,6 +19,7 @@ async function logout() {
|
||||
<h1>Inventory</h1>
|
||||
<nav aria-label="Admin navigation">
|
||||
<a href="/admin">Overview</a>
|
||||
<RouterLink :to="{ name: 'admin-settings' }">Settings</RouterLink>
|
||||
</nav>
|
||||
<div class="admin-sidebar__footer">
|
||||
<span>{{ principal?.email }}</span>
|
||||
|
||||
@@ -8,6 +8,7 @@ import AdminShell from '@/pages/AdminShell.vue';
|
||||
import LoginPage from '@/pages/LoginPage.vue';
|
||||
import { useAuth } from '@/composables/useAuth.js';
|
||||
import AdminImagePage from '@/pages/AdminImagePage.vue';
|
||||
import AdminSettingsPage from '@/pages/AdminSettingsPage.vue';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -22,6 +23,7 @@ const router = createRouter({
|
||||
children: [
|
||||
{ path: '', name: 'admin-home', component: AdminHomePage },
|
||||
{ path: 'inventory/:id/images', name: 'admin-image-manager', component: AdminImagePage },
|
||||
{ path: 'settings', name: 'admin-settings', component: AdminSettingsPage },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
credentials: 'include',
|
||||
...options,
|
||||
headers: { 'content-type': 'application/json', ...options.headers },
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(payload?.error?.message ?? 'Settings request failed.');
|
||||
return payload?.data;
|
||||
}
|
||||
|
||||
export function getAdminSettings() {
|
||||
return request('/admin/settings');
|
||||
}
|
||||
|
||||
export function updateAdminSettings(settings, csrfToken) {
|
||||
return request('/admin/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'x-csrf-token': csrfToken },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
}
|
||||
@@ -116,4 +116,24 @@ test('updates selected admin inventory tags through the bulk API', async () => {
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updates admin settings through the CSRF-protected API', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, options) => {
|
||||
assert.equal(url, '/api/v1/admin/settings');
|
||||
assert.equal(options.method, 'PATCH');
|
||||
assert.equal(options.headers['x-csrf-token'], 'csrf-token');
|
||||
assert.deepEqual(JSON.parse(options.body), { pricing: { defaultStrategy: 'median' } });
|
||||
return { ok: true, async json() { return { data: { pricing: { defaultStrategy: 'median' } } }; } };
|
||||
};
|
||||
|
||||
try {
|
||||
const { updateAdminSettings } = await import('../src/services/admin-settings-service.js');
|
||||
assert.deepEqual(await updateAdminSettings({ pricing: { defaultStrategy: 'median' } }, 'csrf-token'), {
|
||||
pricing: { defaultStrategy: 'median' },
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user