feat: add authenticated admin shell
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-08 22:25:29 -04:00
parent a21d1ae854
commit 86c60f9381
10 changed files with 182 additions and 1 deletions
+15
View File
@@ -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); }
} }
+37
View File
@@ -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 = {};
+3
View File
@@ -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>
+8
View File
@@ -0,0 +1,8 @@
import AdminShell from './AdminShell.vue';
export default {
title: 'Pages/AdminShell',
component: AdminShell,
};
export const Navigation = { args: { routed: false } };
+30
View File
@@ -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>
+5
View File
@@ -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.' } } } };
+36
View File
@@ -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>
+20 -1
View File
@@ -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' };
}); });
+24
View File
@@ -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 } });
}