diff --git a/apps/web/src/assets/styles/global.scss b/apps/web/src/assets/styles/global.scss index 3f18156..3aaa876 100644 --- a/apps/web/src/assets/styles/global.scss +++ b/apps/web/src/assets/styles/global.scss @@ -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; } .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 h1 { max-width: 720px; } .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; } .theme-controls { justify-content: start; } .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); } } diff --git a/apps/web/src/composables/useAuth.js b/apps/web/src/composables/useAuth.js new file mode 100644 index 0000000..e911e1b --- /dev/null +++ b/apps/web/src/composables/useAuth.js @@ -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 }; +} diff --git a/apps/web/src/pages/AdminHomePage.stories.js b/apps/web/src/pages/AdminHomePage.stories.js new file mode 100644 index 0000000..11b72a1 --- /dev/null +++ b/apps/web/src/pages/AdminHomePage.stories.js @@ -0,0 +1,4 @@ +import AdminHomePage from './AdminHomePage.vue'; + +export default { title: 'Pages/AdminHomePage', component: AdminHomePage }; +export const Overview = {}; diff --git a/apps/web/src/pages/AdminHomePage.vue b/apps/web/src/pages/AdminHomePage.vue new file mode 100644 index 0000000..4bb402f --- /dev/null +++ b/apps/web/src/pages/AdminHomePage.vue @@ -0,0 +1,3 @@ + diff --git a/apps/web/src/pages/AdminShell.stories.js b/apps/web/src/pages/AdminShell.stories.js new file mode 100644 index 0000000..b463f31 --- /dev/null +++ b/apps/web/src/pages/AdminShell.stories.js @@ -0,0 +1,8 @@ +import AdminShell from './AdminShell.vue'; + +export default { + title: 'Pages/AdminShell', + component: AdminShell, +}; + +export const Navigation = { args: { routed: false } }; diff --git a/apps/web/src/pages/AdminShell.vue b/apps/web/src/pages/AdminShell.vue new file mode 100644 index 0000000..4d22b44 --- /dev/null +++ b/apps/web/src/pages/AdminShell.vue @@ -0,0 +1,30 @@ + + + diff --git a/apps/web/src/pages/LoginPage.stories.js b/apps/web/src/pages/LoginPage.stories.js new file mode 100644 index 0000000..c875193 --- /dev/null +++ b/apps/web/src/pages/LoginPage.stories.js @@ -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.' } } } }; diff --git a/apps/web/src/pages/LoginPage.vue b/apps/web/src/pages/LoginPage.vue new file mode 100644 index 0000000..db4e06e --- /dev/null +++ b/apps/web/src/pages/LoginPage.vue @@ -0,0 +1,36 @@ + + + diff --git a/apps/web/src/router/index.js b/apps/web/src/router/index.js index 51377df..4c72835 100644 --- a/apps/web/src/router/index.js +++ b/apps/web/src/router/index.js @@ -3,6 +3,10 @@ import { createRouter, createWebHistory } from 'vue-router'; import CatalogPage from '@/pages/CatalogPage.vue'; import ListingDetailPage from '@/pages/ListingDetailPage.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'; export default createRouter({ @@ -11,6 +15,21 @@ export default createRouter({ { path: '/', name: 'catalog', component: CatalogPage }, { path: '/listings/:id', name: 'listing-detail', component: ListingDetailPage }, { 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' }; }); \ No newline at end of file diff --git a/apps/web/src/services/auth-service.js b/apps/web/src/services/auth-service.js new file mode 100644 index 0000000..8d75aad --- /dev/null +++ b/apps/web/src/services/auth-service.js @@ -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 } }); +}