This commit is contained in:
@@ -39,6 +39,18 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
|
|||||||
.pagination button:disabled { cursor: not-allowed; opacity: 0.45; }
|
.pagination button:disabled { cursor: not-allowed; opacity: 0.45; }
|
||||||
|
|
||||||
.showcase-page { padding: var(--space-12) max(var(--space-4), calc((100vw - 1200px) / 2)); }
|
.showcase-page { padding: var(--space-12) max(var(--space-4), calc((100vw - 1200px) / 2)); }
|
||||||
|
.auth-page { display: grid; place-items: center; min-height: 100vh; padding: var(--space-8); background: var(--color-background); }
|
||||||
|
.auth-panel { display: grid; gap: var(--space-3); width: min(440px, 100%); border: var(--border-thin) solid var(--color-border); border-radius: var(--radius-xl); padding: var(--space-8); background: var(--color-surface); box-shadow: var(--shadow-md); }
|
||||||
|
.auth-panel h1 { font-size: var(--text-2xl); }
|
||||||
|
.admin-shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; background: var(--color-background); }
|
||||||
|
.admin-sidebar { display: grid; align-content: start; gap: var(--space-6); border-right: var(--border-thin) solid var(--color-border); padding: var(--space-8); background: var(--color-surface); }
|
||||||
|
.admin-sidebar h1 { font-size: var(--text-xl); }
|
||||||
|
.admin-sidebar nav { display: grid; gap: var(--space-3); }
|
||||||
|
.admin-sidebar nav a { color: var(--color-text-subtle); text-decoration: none; }
|
||||||
|
.admin-sidebar nav a.router-link-active { color: var(--color-interactive-text); font-weight: var(--weight-bold); }
|
||||||
|
.admin-sidebar__footer { display: grid; gap: var(--space-3); align-self: end; color: var(--color-text-muted); font-size: var(--text-sm); }
|
||||||
|
.admin-shell__content { padding: var(--space-12); }
|
||||||
|
.admin-page { max-width: 880px; }
|
||||||
.showcase-header { display: flex; align-items: end; justify-content: space-between; gap: var(--space-8); margin-bottom: var(--space-12); }
|
.showcase-header { display: flex; align-items: end; justify-content: space-between; gap: var(--space-8); margin-bottom: var(--space-12); }
|
||||||
.showcase-header h1 { max-width: 720px; }
|
.showcase-header h1 { max-width: 720px; }
|
||||||
.showcase-header p { max-width: 600px; color: var(--color-text-muted); }
|
.showcase-header p { max-width: 600px; color: var(--color-text-muted); }
|
||||||
@@ -94,4 +106,7 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
|
|||||||
.showcase-header { display: grid; align-items: start; }
|
.showcase-header { display: grid; align-items: start; }
|
||||||
.theme-controls { justify-content: start; }
|
.theme-controls { justify-content: start; }
|
||||||
.product-facts { grid-template-columns: 1fr; }
|
.product-facts { grid-template-columns: 1fr; }
|
||||||
|
.admin-shell { grid-template-columns: 1fr; }
|
||||||
|
.admin-sidebar { border-right: 0; border-bottom: var(--border-thin) solid var(--color-border); }
|
||||||
|
.admin-shell__content { padding: var(--space-6); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { getCurrentPrincipal, login, logout } from '@/services/auth-service.js';
|
||||||
|
|
||||||
|
const principal = ref(null);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
|
||||||
|
function csrfToken() {
|
||||||
|
return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const isAuthenticated = computed(() => Boolean(principal.value));
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
isLoading.value = true;
|
||||||
|
try { principal.value = await getCurrentPrincipal(); }
|
||||||
|
catch { principal.value = null; }
|
||||||
|
finally { isLoading.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn(email, password) {
|
||||||
|
error.value = null;
|
||||||
|
isLoading.value = true;
|
||||||
|
try { await login(email, password); await load(); }
|
||||||
|
catch (requestError) { error.value = requestError; throw requestError; }
|
||||||
|
finally { isLoading.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
await logout(csrfToken());
|
||||||
|
principal.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { principal, isAuthenticated, isLoading, error, load, signIn, signOut };
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import AdminHomePage from './AdminHomePage.vue';
|
||||||
|
|
||||||
|
export default { title: 'Pages/AdminHomePage', component: AdminHomePage };
|
||||||
|
export const Overview = {};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<section class="admin-page"><p class="catalog-kicker">Overview</p><h1>Collector workspace</h1><p class="detail-notes">Select an inventory item to manage its images and listing details.</p></section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import AdminShell from './AdminShell.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Pages/AdminShell',
|
||||||
|
component: AdminShell,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Navigation = { args: { routed: false } };
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup>
|
||||||
|
import { RouterView } from 'vue-router';
|
||||||
|
|
||||||
|
import { useAuth } from '@/composables/useAuth.js';
|
||||||
|
|
||||||
|
defineProps({ routed: { type: Boolean, default: true } });
|
||||||
|
const { principal, signOut } = useAuth();
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await signOut();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="admin-shell">
|
||||||
|
<aside class="admin-sidebar">
|
||||||
|
<p class="catalog-kicker">Collector workspace</p>
|
||||||
|
<h1>Inventory</h1>
|
||||||
|
<nav aria-label="Admin navigation">
|
||||||
|
<a href="/admin">Overview</a>
|
||||||
|
</nav>
|
||||||
|
<div class="admin-sidebar__footer">
|
||||||
|
<span>{{ principal?.email }}</span>
|
||||||
|
<button class="button button--secondary" type="button" @click="logout">Sign out</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<section class="admin-shell__content"><RouterView v-if="routed" /></section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import LoginPage from './LoginPage.vue';
|
||||||
|
|
||||||
|
export default { title: 'Pages/LoginPage', component: LoginPage };
|
||||||
|
export const Empty = {};
|
||||||
|
export const Loading = { parameters: { docs: { description: { story: 'Submit state is exercised through the live form.' } } } };
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { useAuth } from '@/composables/useAuth.js';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { signIn, isLoading, error } = useAuth();
|
||||||
|
const email = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
try {
|
||||||
|
await signIn(email.value, password.value);
|
||||||
|
await router.push({ name: 'admin-home' });
|
||||||
|
} catch {
|
||||||
|
// Error is exposed by the auth composable for the live form state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="auth-page">
|
||||||
|
<form class="auth-panel" @submit.prevent="submit">
|
||||||
|
<p class="catalog-kicker">Private workspace</p>
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<p class="detail-notes">Manage your collection, images, and listings.</p>
|
||||||
|
<label class="fixture-label" for="admin-email">Email</label>
|
||||||
|
<input id="admin-email" v-model="email" class="fixture-input" type="email" autocomplete="username" required />
|
||||||
|
<label class="fixture-label" for="admin-password">Password</label>
|
||||||
|
<input id="admin-password" v-model="password" class="fixture-input" type="password" autocomplete="current-password" required />
|
||||||
|
<p v-if="error" class="state state--error" role="alert">{{ error.message }}</p>
|
||||||
|
<button class="button button--primary" type="submit" :disabled="isLoading">{{ isLoading ? 'Signing in...' : 'Sign in' }}</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -3,6 +3,10 @@ import { createRouter, createWebHistory } from 'vue-router';
|
|||||||
import CatalogPage from '@/pages/CatalogPage.vue';
|
import CatalogPage from '@/pages/CatalogPage.vue';
|
||||||
import ListingDetailPage from '@/pages/ListingDetailPage.vue';
|
import ListingDetailPage from '@/pages/ListingDetailPage.vue';
|
||||||
import ProductDetailPage from '@/pages/ProductDetailPage.vue';
|
import ProductDetailPage from '@/pages/ProductDetailPage.vue';
|
||||||
|
import AdminHomePage from '@/pages/AdminHomePage.vue';
|
||||||
|
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 AdminImagePage from '@/pages/AdminImagePage.vue';
|
||||||
|
|
||||||
export default createRouter({
|
export default createRouter({
|
||||||
@@ -11,6 +15,21 @@ export default createRouter({
|
|||||||
{ path: '/', name: 'catalog', component: CatalogPage },
|
{ path: '/', name: 'catalog', component: CatalogPage },
|
||||||
{ path: '/listings/:id', name: 'listing-detail', component: ListingDetailPage },
|
{ path: '/listings/:id', name: 'listing-detail', component: ListingDetailPage },
|
||||||
{ path: '/products/:slug', name: 'product-detail', component: ProductDetailPage },
|
{ path: '/products/:slug', name: 'product-detail', component: ProductDetailPage },
|
||||||
{ path: '/admin/inventory/:id/images', name: 'admin-image-manager', component: AdminImagePage },
|
{ path: '/login', name: 'login', component: LoginPage },
|
||||||
|
{
|
||||||
|
path: '/admin',
|
||||||
|
component: AdminShell,
|
||||||
|
children: [
|
||||||
|
{ path: '', name: 'admin-home', component: AdminHomePage },
|
||||||
|
{ path: 'inventory/:id/images', name: 'admin-image-manager', component: AdminImagePage },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const auth = useAuth();
|
||||||
|
router.beforeEach(async (to) => {
|
||||||
|
if (!to.path.startsWith('/admin')) return true;
|
||||||
|
if (!auth.isAuthenticated.value) await auth.load();
|
||||||
|
return auth.isAuthenticated.value ? true : { name: 'login' };
|
||||||
|
});
|
||||||
@@ -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 ?? 'Authentication request failed.');
|
||||||
|
return payload?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function login(email, password) {
|
||||||
|
return request('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentPrincipal() {
|
||||||
|
return request('/auth/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout(csrfToken) {
|
||||||
|
return request('/auth/logout', { method: 'POST', headers: { 'x-csrf-token': csrfToken } });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user