This commit is contained in:
2026-05-10 09:46:07 +12:00
parent cfc193b713
commit 2f2466ecac
81 changed files with 2571 additions and 413 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ describe('api fetch injection', () => {
await expect(call(injectedFetch)).resolves.toEqual(body);
expect(injectedFetch).toHaveBeenCalledTimes(1);
expect(injectedFetch.mock.calls[0]?.[0]).toBe(`http://127.0.0.1:8000${path}`);
expect(injectedFetch.mock.calls[0]?.[0]).toBe(path);
expect(globalFetch).not.toHaveBeenCalled();
});
+52 -28
View File
@@ -37,7 +37,6 @@ import type {
} from '$lib/types';
import { getStoredAdminSession, getStoredClientSession } from '$lib/session';
const DEFAULT_API_PORT = env.PUBLIC_API_PORT || '8000';
const BACKEND_UNAVAILABLE_MESSAGE = 'Unable to reach the server. Check that the backend is running and try again.';
type AuthMode = 'none' | 'client' | 'admin' | 'manager';
@@ -51,40 +50,62 @@ function getApiBaseUrl() {
}
}
const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim();
if (configuredBaseUrl) {
return configuredBaseUrl.replace(/\/+$/, '');
}
if (browser) {
return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_API_PORT}`;
const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim();
if (configuredBaseUrl) {
try {
const configuredUrl = new URL(configuredBaseUrl, window.location.origin);
// Keep browser API traffic same-origin by default. This avoids CORS,
// CSP `connect-src`, and cookie policy failures when the backend is
// reverse-proxied under `/api` on the same host.
if (configuredUrl.origin === window.location.origin || configuredUrl.hostname === window.location.hostname) {
return '';
}
return configuredUrl.toString().replace(/\/+$/, '');
} catch {
return '';
}
}
return '';
}
return `http://127.0.0.1:${DEFAULT_API_PORT}`;
const defaultApiPort = env.PUBLIC_API_PORT || '8000';
return `http://127.0.0.1:${defaultApiPort}`;
}
function buildApiUrl(path: string) {
return `${getApiBaseUrl()}${path}`;
}
function getToken(auth: AuthMode) {
if (!browser) {
return null;
}
function getSessionFingerprint(auth: AuthMode) {
if (auth === 'client') {
return getStoredClientSession()?.token ?? null;
const session = getStoredClientSession();
return session ? `${session.role}:${session.email}:${session.user_id ?? ''}` : '';
}
if (auth === 'admin') {
return getStoredAdminSession()?.token ?? null;
const session = getStoredAdminSession();
return session ? `${session.role}:${session.email}` : '';
}
if (auth === 'manager') {
return getStoredAdminSession()?.token ?? getStoredClientSession()?.token ?? null;
const admin = getStoredAdminSession();
if (admin) {
return `${admin.role}:${admin.email}`;
}
const client = getStoredClientSession();
return client ? `${client.role}:${client.email}:${client.user_id ?? ''}` : '';
}
return null;
return '';
}
function resolveRequestUrl(path: string, fetcher: ApiFetch) {
if (fetcher !== fetch) {
return path;
}
return buildApiUrl(path);
}
function normalizeRequestError(error: unknown) {
@@ -107,9 +128,8 @@ function normalizeRequestError(error: unknown) {
async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise<T> {
try {
const token = getToken(auth);
const response = await fetcher(buildApiUrl(path), {
headers: token ? { Authorization: `Bearer ${token}` } : undefined
const response = await fetcher(resolveRequestUrl(path, fetcher), {
credentials: 'include'
});
if (!response.ok) {
if (auth !== 'none') {
@@ -136,8 +156,8 @@ const inflightRequests = new Map<string, Promise<unknown>>();
const READ_CACHE_TTL_MS = 30_000;
function makeCacheKey(path: string, auth: AuthMode) {
const token = browser ? getToken(auth) ?? '' : '';
return `${auth}:${token.slice(-8)}:${path}`;
const sessionFingerprint = browser ? getSessionFingerprint(auth) : '';
return `${auth}:${sessionFingerprint}:${path}`;
}
async function cachedFetchJson<T>(
@@ -189,13 +209,12 @@ async function request<T>(
fetcher: ApiFetch = fetch
): Promise<T> {
try {
const token = getToken(auth);
const response = await fetcher(buildApiUrl(path), {
const response = await fetcher(resolveRequestUrl(path, fetcher), {
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers ?? {})
},
credentials: 'include',
...options
});
@@ -218,6 +237,9 @@ async function request<T>(
// after the user creates or updates anything.
clearApiCache();
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
} catch (error) {
throw normalizeRequestError(error);
@@ -230,9 +252,8 @@ async function requestBlob(
fetcher: ApiFetch = fetch
): Promise<Blob> {
try {
const token = getToken(auth);
const response = await fetcher(buildApiUrl(path), {
headers: token ? { Authorization: `Bearer ${token}` } : undefined
const response = await fetcher(resolveRequestUrl(path, fetcher), {
credentials: 'include'
});
if (!response.ok) {
@@ -326,6 +347,9 @@ export const api = {
}),
clientSession: (fetcher?: ApiFetch) => request<LoginResponse>('/api/auth/client/session', { method: 'GET' }, 'client', fetcher),
adminSession: (fetcher?: ApiFetch) => request<LoginResponse>('/api/auth/admin/session', { method: 'GET' }, 'admin', fetcher),
clientLogout: () => request<void>('/api/auth/client/logout', { method: 'POST' }, 'client'),
adminLogout: () => request<void>('/api/auth/admin/logout', { method: 'POST' }, 'admin'),
internalLogout: () => request<void>('/api/access/logout', { method: 'POST' }, 'client'),
login: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', {
method: 'POST',
+19 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { page } from '$app/state';
import { api } from '$lib/api';
import { adminSession, sessionHydrated } from '$lib/session';
const navigation = [
@@ -10,7 +11,7 @@
let { children } = $props();
let isRestoringSession = $state(false);
let restoredToken = $state<string | null>(null);
let restoredSessionKey = $state<string | null>(null);
function matchesRoute(href: string, pathname: string) {
return href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
@@ -29,31 +30,41 @@
.toUpperCase();
}
async function signOut() {
try {
await api.adminLogout();
} catch {
// Clearing the local session remains the safe fallback.
} finally {
adminSession.clear();
}
}
const isProtectedRoute = $derived(page.url.pathname !== '/admin');
$effect(() => {
const hydrated = $sessionHydrated;
const token = $adminSession?.token ?? null;
const sessionKey = $adminSession ? `${$adminSession.role}:${$adminSession.email}` : null;
if (!hydrated) {
return;
}
if (!token) {
if (!sessionKey) {
isRestoringSession = false;
restoredToken = null;
restoredSessionKey = null;
return;
}
if (restoredToken === token) {
if (restoredSessionKey === sessionKey) {
return;
}
restoredToken = token;
restoredSessionKey = sessionKey;
isRestoringSession = true;
invalidateAll().finally(() => {
if (restoredToken === token) {
if (restoredSessionKey === sessionKey) {
isRestoringSession = false;
}
});
@@ -87,7 +98,7 @@
<div class="admin-footer">
<a href="/">Open client workspace</a>
{#if $adminSession}
<button type="button" onclick={() => adminSession.clear()}>Sign out</button>
<button type="button" onclick={signOut}>Sign out</button>
{/if}
</div>
</aside>
@@ -0,0 +1,51 @@
<script lang="ts">
let {
blocked = false,
label = 'Checking Access',
title = 'Preparing your workspace.',
detail = 'Applying your access rules before rendering this page.',
children
} = $props();
</script>
{#if blocked}
<section class="auth-gate-card">
<p class="auth-gate-label">{label}</p>
<h2>{title}</h2>
<p>{detail}</p>
</section>
{:else}
{@render children()}
{/if}
<style>
.auth-gate-card {
display: grid;
gap: 0.5rem;
padding: 1.35rem 1.4rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel);
}
.auth-gate-label {
margin: 0;
color: var(--muted);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.auth-gate-card h2 {
margin: 0;
font-size: 1.18rem;
}
.auth-gate-card p:last-child {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.55;
}
</style>
+203 -116
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { api } from '$lib/api';
import AuthGate from '$lib/components/AuthGate.svelte';
import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte';
import ClientTopbar from '$lib/components/navigation/ClientTopbar.svelte';
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
@@ -8,6 +9,23 @@
import { page } from '$app/state';
import { clientSession, hasModuleAccess, sessionHydrated } from '$lib/session';
import { featureFlags } from '$lib/features';
import {
canCreateMixSession as sessionCanCreateMixSession,
canCreateMixWorksheet as sessionCanCreateMixWorksheet,
canOpenClientAccess as sessionCanOpenClientAccess,
canOpenDashboard as sessionCanOpenDashboard,
canOpenMixCalculator as sessionCanOpenMixCalculator,
canOpenMixMaster as sessionCanOpenMixMaster,
canOpenProducts as sessionCanOpenProducts,
canOpenRawMaterials as sessionCanOpenRawMaterials,
canOpenReporting as sessionCanOpenReporting,
canOpenScenarios as sessionCanOpenScenarios,
canOpenSettings as sessionCanOpenSettings,
canUseWorkspaceSearch as sessionCanUseWorkspaceSearch,
getWorkspaceRole,
getWorkspaceHomeHref as sessionWorkspaceHomeHref,
isWorkspaceRouteAllowed
} from '$lib/workspace-access';
import {
accessControlItem,
baseSearchItems,
@@ -44,34 +62,49 @@
let navOpen = $state(false);
let showBottomNav = $state(false);
let isRestoringSession = $state(false);
let restoredToken = $state<string | null>(null);
let restoredSessionKey = $state<string | null>(null);
let seededSearchItems = $state<SearchItem[]>([]);
let seededSearchToken = $state<string | null>(null);
let seededSearchKey = $state<string | null>(null);
let paletteInput: HTMLInputElement | null = $state(null);
const appVersion = `v${packageInfo.version}`;
const releaseStage = 'Beta';
const currentYear = new Date().getFullYear();
const visibleDashboardItem = $derived(
!$clientSession || !dashboardItem.moduleKey || hasModuleAccess($clientSession, dashboardItem.moduleKey) ? dashboardItem : null
);
const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession));
const canOpenRawMaterials = $derived(sessionCanOpenRawMaterials($clientSession));
const canOpenMixMaster = $derived(sessionCanOpenMixMaster($clientSession));
const canCreateMixWorksheet = $derived(sessionCanCreateMixWorksheet($clientSession));
const canOpenMixCalculator = $derived(sessionCanOpenMixCalculator($clientSession));
const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession));
const canOpenProducts = $derived(sessionCanOpenProducts($clientSession));
const canOpenScenarios = $derived(sessionCanOpenScenarios($clientSession));
const canOpenSettings = $derived(sessionCanOpenSettings($clientSession));
const canOpenClientAccess = $derived(sessionCanOpenClientAccess($clientSession));
const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession));
const workspaceHomeHref = $derived(sessionWorkspaceHomeHref($clientSession));
const currentRouteAllowed = $derived(isWorkspaceRouteAllowed($clientSession, page.url.pathname));
const routeGuardPending = $derived(!!$clientSession && (isRestoringSession || !currentRouteAllowed));
const shellPathname = $derived(routeGuardPending ? workspaceHomeHref : page.url.pathname);
const shellTitle = $derived(routeGuardPending ? 'Loading Workspace' : pageTitle(page.url.pathname));
const shellBreadcrumbs = $derived(routeGuardPending ? clientBreadcrumbs(workspaceHomeHref) : clientBreadcrumbs(page.url.pathname));
const visibleDashboardItem = $derived(canOpenDashboard ? dashboardItem : null);
const visibleWorkingDocumentItems = $derived(
!$clientSession
? workingDocumentItems
: workingDocumentItems.filter((item) => !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey))
);
const visibleMixCalculatorItem = $derived(
!$clientSession || !mixCalculatorItem.moduleKey || hasModuleAccess($clientSession, mixCalculatorItem.moduleKey)
? mixCalculatorItem
: null
);
const visibleReportingItem = $derived(
!$clientSession || !reportingItem.moduleKey || hasModuleAccess($clientSession, reportingItem.moduleKey)
? reportingItem
: null
: workingDocumentItems.filter((item) => {
if (item.href === '/raw-materials') return canOpenRawMaterials;
if (item.href === '/mixes') return canOpenMixMaster;
if (item.href === '/products') return canOpenProducts;
if (item.href === '/scenarios') return canOpenScenarios;
return !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey);
})
);
const visibleMixCalculatorItem = $derived(canOpenMixCalculator ? mixCalculatorItem : null);
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
const workspaceRole = $derived(getWorkspaceRole($clientSession));
const visibleFooterLinks = $derived([
...footerLinks,
...(!$clientSession || !hasModuleAccess($clientSession, 'client_access', 'manage')
...(!isOperationsUser ? footerLinks : []),
...(!canOpenClientAccess
? []
: [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel, icon: accessControlItem.icon }])
] as FooterLink[]);
@@ -85,7 +118,22 @@
const workingDocumentsActive = $derived(
visibleWorkingDocumentItems.some((item) => matchesRoute(item.href, page.url.pathname))
);
const searchItems = $derived([...baseSearchItems, ...seededSearchItems]);
const visibleBaseSearchItems = $derived(
baseSearchItems.filter((item) => {
if (item.href === '/') return canOpenDashboard;
if (item.href === '/raw-materials') return canOpenRawMaterials;
if (item.href === '/mixes') return canOpenMixMaster;
if (item.href === '/mixes/new') return canCreateMixWorksheet;
if (item.href === '/mix-calculator') return canOpenMixCalculator;
if (item.href === '/mix-calculator/new') return canCreateMixSession;
if (item.href === '/products') return canOpenProducts;
if (item.href === '/reporting') return sessionCanOpenReporting($clientSession);
if (item.href === '/settings') return canOpenSettings;
if (item.href === '/scenarios') return canOpenScenarios;
return true;
})
);
const searchItems = $derived([...visibleBaseSearchItems, ...seededSearchItems]);
function openPalette(query = '') {
paletteQuery = query;
@@ -116,6 +164,20 @@
await goto('/settings');
}
async function signOut() {
try {
if ($clientSession?.role === 'internal') {
await api.internalLogout();
} else {
await api.clientLogout();
}
} catch {
// Clearing the local session remains the safe fallback.
} finally {
clientSession.clear();
}
}
const filteredSearchItems = $derived(
searchItems.filter((item) => {
const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase();
@@ -140,23 +202,23 @@
$effect(() => {
const hydrated = $sessionHydrated;
const token = $clientSession?.token ?? null;
const sessionKey = $clientSession ? `${$clientSession.role}:${$clientSession.email}:${$clientSession.user_id ?? ''}` : null;
if (!hydrated) {
return;
}
if (!token) {
if (!sessionKey) {
isRestoringSession = false;
restoredToken = null;
restoredSessionKey = null;
return;
}
if (restoredToken === token) {
if (restoredSessionKey === sessionKey) {
return;
}
restoredToken = token;
restoredSessionKey = sessionKey;
isRestoringSession = true;
// Internal Hunter Stock Feeds users are refreshed against /api/access/me;
@@ -165,14 +227,12 @@
refresh
.then((session) => {
// /api/access/me does not re-issue a token; preserve the existing one.
const nextToken = session.token ?? token;
restoredToken = nextToken;
clientSession.set({ ...session, token: nextToken });
restoredSessionKey = `${session.role}:${session.email}:${session.user_id ?? ''}`;
clientSession.set(session);
return invalidateAll();
})
.catch(() => {
restoredToken = null;
restoredSessionKey = null;
clientSession.clear();
})
.finally(() => {
@@ -186,30 +246,30 @@
$effect(() => {
const hydrated = $sessionHydrated;
const session = $clientSession;
const token = session?.token ?? null;
const sessionKey = session ? `${session.role}:${session.email}:${session.user_id ?? ''}` : null;
const shouldSeed = paletteOpen;
if (!hydrated || !session || !token) {
if (!hydrated || !session || !sessionKey) {
seededSearchItems = [];
seededSearchToken = null;
seededSearchKey = null;
return;
}
if (!shouldSeed || seededSearchToken === token) {
if (!shouldSeed || seededSearchKey === sessionKey) {
return;
}
seededSearchToken = token;
seededSearchKey = sessionKey;
Promise.all([
hasModuleAccess(session, 'products') ? api.products() : Promise.resolve([]),
hasModuleAccess(session, 'mix_master') ? api.mixes() : Promise.resolve([]),
featureFlags.mixCalculatorSessionHistory && hasModuleAccess(session, 'mix_calculator')
sessionCanOpenProducts(session) ? api.products() : Promise.resolve([]),
sessionCanOpenMixMaster(session) ? api.mixes() : Promise.resolve([]),
featureFlags.mixCalculatorSessionHistory && sessionCanOpenMixCalculator(session)
? api.mixCalculatorSessions()
: Promise.resolve([])
])
.then(([products, mixes, sessions]) => {
if (seededSearchToken !== token) {
if (seededSearchKey !== sessionKey) {
return;
}
@@ -235,7 +295,7 @@
];
})
.catch(() => {
if (seededSearchToken === token) {
if (seededSearchKey === sessionKey) {
seededSearchItems = [];
}
});
@@ -247,6 +307,18 @@
}
});
$effect(() => {
if (!$sessionHydrated || !$clientSession) {
return;
}
if (currentRouteAllowed || page.url.pathname === workspaceHomeHref) {
return;
}
goto(workspaceHomeHref, { replaceState: true });
});
onMount(() => {
syncViewport();
@@ -258,7 +330,7 @@
target instanceof HTMLSelectElement ||
target?.isContentEditable;
if ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/')) {
if (canUseWorkspaceSearch && ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/'))) {
event.preventDefault();
openPalette();
}
@@ -291,7 +363,7 @@
</script>
<svelte:head>
<title>{pageTitle(page.url.pathname)} | Hunter Premium Produce</title>
<title>{shellTitle} | Hunter Premium Produce</title>
</svelte:head>
{#if !$clientSession}
@@ -314,77 +386,98 @@
{#if !showBottomNav}
<ClientPrimaryRail
currentPath={page.url.pathname}
currentPath={shellPathname}
primaryItems={[
...(visibleDashboardItem ? [visibleDashboardItem] : []),
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleReportingItem ? [visibleReportingItem] : [])
]}
brandHref={workspaceHomeHref}
workingDocumentItems={visibleWorkingDocumentItems}
footerItems={visibleFooterLinks}
{appVersion}
{releaseStage}
{currentYear}
{canOpenSettings}
onOpenSettings={openSettings}
onSignOut={() => clientSession.clear()}
onSignOut={signOut}
/>
{/if}
<div class:bottom-nav-layout={showBottomNav} class="main-shell">
<ClientTopbar
breadcrumbs={clientBreadcrumbs(page.url.pathname)}
title={pageTitle(page.url.pathname)}
breadcrumbs={shellBreadcrumbs}
title={shellTitle}
sessionHydrated={$sessionHydrated}
session={$clientSession}
{userInitials}
{userMenuOpen}
onOpenPalette={() => openPalette()}
{canUseWorkspaceSearch}
{canOpenSettings}
onOpenPalette={() => canUseWorkspaceSearch && openPalette()}
onToggleUserMenu={() => {
userMenuOpen = !userMenuOpen;
quickMenuOpen = false;
}}
onOpenSettings={openSettings}
onSignOut={() => clientSession.clear()}
onSignOut={signOut}
/>
<main class="content">
{#if !isRootRoute && isRestoringSession}
<section class="locked-card loading-card">
<p class="workspace-label">Checking Session</p>
<h2>Restoring your client workspace.</h2>
<p>Refreshing the current page with the saved browser session before deciding whether sign-in is required.</p>
</section>
{:else}
<AuthGate
blocked={routeGuardPending}
label={isRestoringSession ? 'Checking Session' : 'Applying Access Rules'}
title={isRestoringSession ? 'Restoring your client workspace.' : 'Routing you to an authorised page.'}
detail={
isRestoringSession
? 'Refreshing the saved session before rendering workspace content.'
: `The ${workspaceRole} role cannot open this route, so the workspace is redirecting before any page content mounts.`
}
>
{@render children()}
{/if}
</AuthGate>
</main>
</div>
<div class="quick-fab-wrap">
{#if quickMenuOpen}
<div class="menu-panel quick-fab-panel">
<a href="/mixes">Open mix costing</a>
<a href="/mixes/new">Create mix worksheet</a>
<a href={featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new'}>Open mix calculator</a>
<a href="/mix-calculator/new">Create mix session</a>
<a href="/products">Review delivered pricing</a>
<button type="button" onclick={() => openPalette('')}>Search the workspace</button>
{#if canOpenMixMaster}
<a href="/mixes">Open mix costing</a>
{/if}
{#if canCreateMixWorksheet}
<a href="/mixes/new">Create mix worksheet</a>
{/if}
{#if canOpenMixCalculator}
<a href={featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new'}>Open mix calculator</a>
{/if}
{#if canCreateMixSession}
<a href="/mix-calculator/new">Create mix session</a>
{/if}
{#if canOpenProducts}
<a href="/products">Review delivered pricing</a>
{/if}
{#if canUseWorkspaceSearch}
<button type="button" onclick={() => openPalette('')}>Search the workspace</button>
{/if}
</div>
{/if}
<button
aria-expanded={quickMenuOpen}
aria-label="Open quick access menu"
class="quick-fab"
type="button"
onclick={() => {
quickMenuOpen = !quickMenuOpen;
userMenuOpen = false;
}}
>
<span class={`quick-fab-plus ${quickMenuOpen ? 'open' : ''}`}></span>
<span>Quick Access</span>
</button>
{#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canOpenProducts || canUseWorkspaceSearch}
<button
aria-expanded={quickMenuOpen}
aria-label="Open quick access menu"
class="quick-fab"
type="button"
onclick={() => {
quickMenuOpen = !quickMenuOpen;
userMenuOpen = false;
}}
>
<span class={`quick-fab-plus ${quickMenuOpen ? 'open' : ''}`}></span>
<span>Quick Access</span>
</button>
{/if}
</div>
</div>
@@ -405,12 +498,11 @@
</nav>
{#if navOpen}
<section
<div
aria-label="Tablet navigation drawer"
class="bottom-drawer"
role="dialog"
aria-modal="true"
onclick={(event) => event.stopPropagation()}
>
<div class="drawer-handle"></div>
@@ -427,7 +519,7 @@
<WorkspaceSearchTrigger
className="drawer-search"
placeholder="Search the workspace..."
onClick={() => openPalette()}
onClick={() => canUseWorkspaceSearch && openPalette()}
/>
<div class="drawer-grid">
@@ -470,30 +562,40 @@
</nav>
<div class="drawer-section drawer-actions">
<a href="/mixes/new" onclick={() => (navOpen = false)}>
<span class="nav-icon"><Plus size={18} strokeWidth={1.75} /></span>
<span>Create mix worksheet</span>
</a>
<a href="/mix-calculator/new" onclick={() => (navOpen = false)}>
<span class="nav-icon"><Calculator size={18} strokeWidth={1.75} /></span>
<span>Create mix session</span>
</a>
<button type="button" onclick={openSettings}>
<span class="nav-icon"><Settings size={18} strokeWidth={1.75} /></span>
<span>Change settings</span>
</button>
<a href="/products" onclick={() => (navOpen = false)}>
<span class="nav-icon"><DollarSign size={18} strokeWidth={1.75} /></span>
<span>Review delivered pricing</span>
</a>
<button type="button" onclick={() => openPalette('')}>
<span class="nav-icon"><Search size={18} strokeWidth={1.75} /></span>
<span>Search the workspace</span>
</button>
{#if canCreateMixWorksheet}
<a href="/mixes/new" onclick={() => (navOpen = false)}>
<span class="nav-icon"><Plus size={18} strokeWidth={1.75} /></span>
<span>Create mix worksheet</span>
</a>
{/if}
{#if canCreateMixSession}
<a href="/mix-calculator/new" onclick={() => (navOpen = false)}>
<span class="nav-icon"><Calculator size={18} strokeWidth={1.75} /></span>
<span>Create mix session</span>
</a>
{/if}
{#if canOpenSettings}
<button type="button" onclick={openSettings}>
<span class="nav-icon"><Settings size={18} strokeWidth={1.75} /></span>
<span>Change settings</span>
</button>
{/if}
{#if canOpenProducts}
<a href="/products" onclick={() => (navOpen = false)}>
<span class="nav-icon"><DollarSign size={18} strokeWidth={1.75} /></span>
<span>Review delivered pricing</span>
</a>
{/if}
{#if canUseWorkspaceSearch}
<button type="button" onclick={() => openPalette('')}>
<span class="nav-icon"><Search size={18} strokeWidth={1.75} /></span>
<span>Search the workspace</span>
</button>
{/if}
{#if $clientSession}
<button type="button" onclick={() => clientSession.clear()}>
<button type="button" onclick={signOut}>
<span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
<span>Sign out</span>
<span>Logout</span>
</button>
{/if}
</div>
@@ -507,7 +609,7 @@
</a>
{/each}
</div>
</section>
</div>
{/if}
{/if}
@@ -835,15 +937,10 @@
background: var(--line);
}
.nav-sublist a {
.drawer-sublist a {
position: relative;
}
.bottom-nav-icon svg {
width: 0.9rem;
height: 0.9rem;
}
/* `.nav-icon.muted` is kept for the bottom-nav (still uses letter labels). */
.nav-icon.muted {
color: #fff;
@@ -1002,16 +1099,6 @@
color: var(--muted);
}
.locked-card a {
display: inline-flex;
margin-top: 1rem;
padding: 0.78rem 0.92rem;
border-radius: 0.88rem;
background: var(--color-brand);
color: #fff;
font-weight: 600;
}
.palette-overlay {
position: fixed;
inset: 0;
+1 -1
View File
@@ -3,7 +3,7 @@
function icon(type: Toast['type']) {
if (type === 'success') return '✓';
if (type === 'error') return '';
if (type === 'error') return '!';
if (type === 'loading') return null; // spinner shown separately
return '';
}
@@ -19,7 +19,7 @@
const todayIso = new Date().toISOString().slice(0, 10);
function initialClientNameValue() {
return initialSession?.client_name ?? options.clients[0] ?? '';
return initialSession?.client_name ?? '';
}
function initialProductIdValue() {
@@ -54,6 +54,7 @@
let notes = $state(initialNotesValue());
let preview = $state<MixCalculatorPreview | MixCalculatorSession | null>(initialPreviewValue());
let formError = $state('');
let formHint = $state('Select a mix date and prepared by name, then choose a client to unlock products.');
let previewLoading = $state(false);
let saveLoading = $state(false);
let previewModalOpen = $state(false);
@@ -84,18 +85,7 @@
const selectedProduct = $derived(filteredProducts.find((product) => product.product_id === productId) ?? null);
$effect(() => {
if (!clientName && availableClients.length) {
clientName = availableClients[0];
}
});
$effect(() => {
if (filteredProducts.length && !filteredProducts.some((product) => product.product_id === productId)) {
productId = filteredProducts[0].product_id;
return;
}
if (!filteredProducts.length) {
if (!filteredProducts.some((product) => product.product_id === productId)) {
productId = 0;
}
});
@@ -116,26 +106,32 @@
function buildPayload(): MixCalculatorCreateInput | null {
formError = '';
formHint = '';
const numericBatchSize = Number(batchSizeKg);
if (!mixDate) {
formError = 'Select a mix date.';
return null;
}
if (!clientName) {
formError = 'Select a client.';
return null;
}
if (!productId) {
formError = 'Select a product.';
return null;
}
if (!Number.isFinite(numericBatchSize) || numericBatchSize <= 0) {
formError = 'Enter a batch size greater than zero.';
formHint = 'Choose the production date before calculating the mix.';
return null;
}
if (!preparedByName.trim()) {
formError = 'Enter the prepared by name.';
formHint = 'Record the operator or staff member responsible for this mix.';
return null;
}
if (!clientName) {
formError = 'Select a client to unlock matching products.';
formHint = 'Products stay disabled until a client is selected.';
return null;
}
if (!productId) {
formError = 'Select a product.';
formHint = 'Pick one of the products available for the selected client.';
return null;
}
if (!Number.isFinite(numericBatchSize) || numericBatchSize <= 0) {
formError = 'Enter a batch size greater than zero.';
formHint = 'Batch size must be a positive number before the mix can be calculated.';
return null;
}
@@ -172,7 +168,7 @@
}
function clearForm() {
clientName = options.clients[0] ?? '';
clientName = '';
productId = 0;
mixDate = todayIso;
batchSizeKg = '';
@@ -180,8 +176,28 @@
notes = '';
preview = null;
formError = '';
formHint = 'Select a mix date and prepared by name, then choose a client to unlock products.';
}
$effect(() => {
if (!clientName) {
formHint = 'Select a client to unlock the product list.';
return;
}
if (!filteredProducts.length) {
formHint = `No products are available for ${clientName}.`;
return;
}
if (!productId) {
formHint = 'Select a product for the chosen client.';
return;
}
formHint = `Ready to calculate ${selectedProduct?.product_name ?? 'the selected product'}.`;
});
function printPreview() {
if (typeof window !== 'undefined') {
window.print();
@@ -287,15 +303,24 @@
<p class="message error">{formError}</p>
{/if}
{#if !formError && formHint}
<p class="message hint">{formHint}</p>
{/if}
<div class="field-grid">
<label>
<span>Mix date</span>
<input bind:value={mixDate} disabled={!canEdit} type="date" />
</label>
<label>
<span>Prepared by</span>
<input bind:value={preparedByName} disabled={!canEdit} placeholder="Staff name" type="text" />
</label>
<label>
<span>Client</span>
<select bind:value={clientName} disabled={!canEdit}>
<select bind:value={clientName} disabled={!canEdit} title="Select a client to unlock matching products.">
<option value="">Select a client</option>
{#each availableClients as client}
<option value={client}>{client}</option>
@@ -303,9 +328,13 @@
</select>
</label>
<label class="full-width">
<label>
<span>Product</span>
<select bind:value={productId} disabled={!canEdit || !filteredProducts.length}>
<select
bind:value={productId}
disabled={!canEdit || !clientName || !filteredProducts.length}
title={!clientName ? 'Select a client first.' : !filteredProducts.length ? 'No products are available for the selected client.' : 'Select a product.'}
>
<option value={0}>Select a product</option>
{#each filteredProducts as product}
<option value={product.product_id}>
@@ -317,12 +346,7 @@
<label>
<span>Batch size (kg)</span>
<input bind:value={batchSizeKg} disabled={!canEdit} inputmode="decimal" min="0" placeholder="560" type="number" />
</label>
<label>
<span>Prepared by</span>
<input bind:value={preparedByName} disabled={!canEdit} placeholder="Staff name" type="text" />
<input bind:value={batchSizeKg} disabled={!canEdit} inputmode="decimal" min="0" placeholder="Batch size" type="number" />
</label>
<label class="full-width">
@@ -554,6 +578,12 @@
color: #b2463f;
}
.message.hint {
background: var(--panel-soft);
color: var(--muted);
border: 1px solid var(--line);
}
.action-row {
margin-top: 1rem;
}
@@ -5,6 +5,7 @@
import { matchesRoute, type FooterLink, type NavItem } from '$lib/navigation/client-navigation';
let {
brandHref,
currentPath,
primaryItems,
workingDocumentItems,
@@ -12,9 +13,11 @@
appVersion,
releaseStage,
currentYear,
canOpenSettings,
onOpenSettings,
onSignOut
}: {
brandHref: string;
currentPath: string;
primaryItems: NavItem[];
workingDocumentItems: NavItem[];
@@ -22,6 +25,7 @@
appVersion: string;
releaseStage: string;
currentYear: number;
canOpenSettings: boolean;
onOpenSettings: () => void;
onSignOut: () => void;
} = $props();
@@ -29,7 +33,7 @@
<aside class="sidebar">
<div class="brand-row">
<a class="brand" href="/">
<a class="brand" href={brandHref}>
<img class="sidebar-logo" src="/logo-hsf.png" alt="Hunter Premium Produce" />
</a>
</div>
@@ -75,18 +79,22 @@
<AppNavSection
ariaLabel="Account actions"
items={[
...(canOpenSettings
? [
{
label: 'Settings',
icon: Settings,
active: currentPath.startsWith('/settings'),
onSelect: onOpenSettings,
type: 'button' as const
}
]
: []),
{
label: 'Settings',
icon: Settings,
active: currentPath.startsWith('/settings'),
onSelect: onOpenSettings,
type: 'button'
},
{
label: 'Sign out',
label: 'Logout',
icon: LogOut,
onSelect: onSignOut,
type: 'button'
type: 'button' as const
}
]}
/>
@@ -12,6 +12,8 @@
session,
userInitials,
userMenuOpen,
canUseWorkspaceSearch,
canOpenSettings,
onOpenPalette,
onToggleUserMenu,
onOpenSettings,
@@ -23,6 +25,8 @@
session: AppSession | null;
userInitials: string;
userMenuOpen: boolean;
canUseWorkspaceSearch: boolean;
canOpenSettings: boolean;
onOpenPalette: () => void;
onToggleUserMenu: () => void;
onOpenSettings: () => void;
@@ -47,9 +51,13 @@
</div>
</div>
<div class="topbar-middle">
<WorkspaceSearchTrigger className="topbar-search" onClick={onOpenPalette} />
</div>
{#if canUseWorkspaceSearch}
<div class="topbar-middle">
<WorkspaceSearchTrigger className="topbar-search" onClick={onOpenPalette} />
</div>
{:else}
<div class="topbar-middle"></div>
{/if}
<div class="topbar-actions">
<div class="menu-wrap user-menu-wrap">
@@ -86,10 +94,12 @@
</span>
</div>
</div>
<button type="button" class="menu-settings-btn" onclick={onOpenSettings}>
<Settings size={15} strokeWidth={1.75} />
Settings
</button>
{#if canOpenSettings}
<button type="button" class="menu-settings-btn" onclick={onOpenSettings}>
<Settings size={15} strokeWidth={1.75} />
Settings
</button>
{/if}
{#if session}
<button type="button" onclick={onSignOut}>Log out</button>
{:else if !sessionHydrated}
+5 -4
View File
@@ -5,7 +5,7 @@ export type AppSession = {
name: string;
email: string;
role: string;
token: string;
token?: string | null;
tenant_id?: string | null;
client_role?: string | null;
user_id?: number | null;
@@ -59,15 +59,16 @@ function createSessionStore(storageKey: string) {
return {
subscribe: store.subscribe,
set(session: AppSession) {
const storedSession = { ...session, token: null };
if (browser) {
localStorage.setItem(storageKey, JSON.stringify(session));
localStorage.setItem(storageKey, JSON.stringify(storedSession));
}
store.set(session);
store.set(storedSession);
},
clear() {
if (browser) {
localStorage.removeItem(storageKey);
// Drop any cached API responses keyed to the old session token.
// Drop any cached API responses keyed to the old session identity.
// Imported lazily so this module stays free of api.ts side-effects.
import('$lib/api').then(({ clearApiCache }) => clearApiCache()).catch(() => {});
}
+2 -1
View File
@@ -180,6 +180,7 @@ export type Product = {
mix_name: string;
sale_type: string;
own_bag?: boolean;
visible?: boolean;
unit_of_measure: string;
items_per_pallet?: number;
bagging_process?: string | null;
@@ -334,7 +335,7 @@ export type LoginResponse = {
name: string;
email: string;
role: string;
token: string;
token?: string | null;
tenant_id?: string | null;
client_role?: string | null;
user_id?: number | null;
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { canAccessRoute, getDefaultRouteForRole, getWorkspaceRole } from './workspace-access';
describe('workspace access policy', () => {
const operationsSession = {
role: 'internal',
role_name: 'Operations',
permissions: ['view_mix_calculator', 'use_mix_calculator', 'save_mix_calculator_session'],
name: 'Ops User',
email: 'ops@example.com',
token: 'token'
};
const adminSession = {
role: 'internal',
role_name: 'Admin',
permissions: ['view_dashboard', 'view_mix_calculator', 'use_mix_calculator'],
name: 'Admin User',
email: 'admin@example.com',
token: 'token'
};
it('classifies operations users and sends them to mix calculator by default', () => {
expect(getWorkspaceRole(operationsSession)).toBe('operations');
expect(getDefaultRouteForRole(operationsSession)).toBe('/mix-calculator/new');
});
it('prevents operations users from opening the dashboard route', () => {
expect(canAccessRoute(operationsSession, '/')).toBe(false);
expect(canAccessRoute(operationsSession, '/mix-calculator')).toBe(true);
});
it('keeps dashboard access for admins', () => {
expect(getWorkspaceRole(adminSession)).toBe('admin');
expect(canAccessRoute(adminSession, '/')).toBe(true);
});
});
+194
View File
@@ -0,0 +1,194 @@
import { featureFlags } from '$lib/features';
import { hasModuleAccess, hasPermission, type AppSession } from '$lib/session';
export type WorkspaceRole = 'admin' | 'operations' | 'full' | 'client' | 'unknown';
type RouteAccessRule = {
path: string;
roles: WorkspaceRole[];
matches: (pathname: string) => boolean;
};
function hasPathPrefix(pathname: string, prefix: string) {
return pathname === prefix || pathname.startsWith(`${prefix}/`);
}
function canAccessWorkspaceArea(
session: AppSession | null | undefined,
moduleKey: string,
permissionKeys: string[],
minimumLevel: 'view' | 'edit' | 'manage' = 'view'
) {
if (!session) {
return false;
}
if (session.role === 'internal') {
return permissionKeys.some((permissionKey) => hasPermission(session, permissionKey));
}
return hasModuleAccess(session, moduleKey, minimumLevel);
}
export function getWorkspaceRole(session: AppSession | null | undefined): WorkspaceRole {
if (!session) {
return 'unknown';
}
if (session.role === 'admin') {
return 'admin';
}
if (session.role !== 'internal') {
return 'client';
}
if (session.role_name === 'Admin') {
return 'admin';
}
if (session.role_name === 'Operations') {
return 'operations';
}
if (session.role_name === 'Full Access') {
return 'full';
}
return 'unknown';
}
export function canOpenDashboard(session: AppSession | null | undefined) {
return canAccessWorkspaceArea(session, 'dashboard', ['view_dashboard']);
}
export function canOpenRawMaterials(session: AppSession | null | undefined) {
return canAccessWorkspaceArea(session, 'raw_materials', ['view_raw_materials', 'edit_raw_materials']);
}
export function canOpenMixMaster(session: AppSession | null | undefined) {
return canAccessWorkspaceArea(session, 'mix_master', ['view_mixes', 'edit_mixes']);
}
export function canCreateMixWorksheet(session: AppSession | null | undefined) {
return canAccessWorkspaceArea(session, 'mix_master', ['edit_mixes'], 'edit');
}
export function canOpenMixCalculator(session: AppSession | null | undefined) {
return canAccessWorkspaceArea(session, 'mix_calculator', ['view_mix_calculator', 'use_mix_calculator']);
}
export function canCreateMixSession(session: AppSession | null | undefined) {
return canAccessWorkspaceArea(session, 'mix_calculator', ['use_mix_calculator', 'save_mix_calculator_session'], 'edit');
}
export function canOpenProducts(session: AppSession | null | undefined) {
return canAccessWorkspaceArea(session, 'products', ['view_products', 'edit_products']);
}
export function canOpenScenarios(session: AppSession | null | undefined) {
return !!session && hasModuleAccess(session, 'scenarios');
}
export function canOpenReporting(session: AppSession | null | undefined) {
return canOpenProducts(session);
}
export function canOpenSettings(session: AppSession | null | undefined) {
if (!session) {
return false;
}
return session.role === 'internal'
? hasPermission(session, 'view_settings') || hasPermission(session, 'edit_settings')
: true;
}
export function canOpenClientAccess(session: AppSession | null | undefined) {
return !!session && hasModuleAccess(session, 'client_access', 'manage');
}
export const routeAccessRules: RouteAccessRule[] = [
{ path: '/', roles: ['admin', 'full', 'client'], matches: (pathname) => pathname === '/' },
{
path: '/mix-calculator',
roles: ['admin', 'operations', 'full', 'client'],
matches: (pathname) => hasPathPrefix(pathname, '/mix-calculator')
},
{
path: '/raw-materials',
roles: ['admin', 'full', 'client'],
matches: (pathname) => hasPathPrefix(pathname, '/raw-materials')
},
{ path: '/mixes', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/mixes') },
{ path: '/products', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/products') },
{ path: '/reporting', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/reporting') },
{ path: '/scenarios', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/scenarios') },
{
path: '/settings',
roles: ['admin', 'full', 'operations', 'client'],
matches: (pathname) => hasPathPrefix(pathname, '/settings')
},
{
path: '/client-access',
roles: ['admin', 'client'],
matches: (pathname) => hasPathPrefix(pathname, '/client-access')
}
];
export function getDefaultRouteForRole(session: AppSession | null | undefined) {
const role = getWorkspaceRole(session);
if (role === 'operations') {
return featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new';
}
if (role === 'admin' || role === 'full' || role === 'client') {
if (canOpenDashboard(session)) return '/';
if (canOpenMixCalculator(session)) return featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new';
if (canOpenRawMaterials(session)) return '/raw-materials';
if (canOpenMixMaster(session)) return '/mixes';
if (canOpenProducts(session)) return '/products';
if (canOpenScenarios(session)) return '/scenarios';
if (canOpenSettings(session)) return '/settings';
}
return '/';
}
export function canAccessRoute(session: AppSession | null | undefined, pathname: string) {
const rule = routeAccessRules.find((candidate) => candidate.matches(pathname));
if (!rule) {
return true;
}
const role = getWorkspaceRole(session);
if (!rule.roles.includes(role)) {
return false;
}
if (pathname === '/') return canOpenDashboard(session);
if (pathname.startsWith('/mix-calculator')) return canOpenMixCalculator(session);
if (pathname.startsWith('/raw-materials')) return canOpenRawMaterials(session);
if (pathname.startsWith('/mixes')) return canOpenMixMaster(session);
if (pathname.startsWith('/products')) return canOpenProducts(session);
if (pathname.startsWith('/scenarios')) return canOpenScenarios(session);
if (pathname.startsWith('/reporting')) return canOpenReporting(session);
if (pathname.startsWith('/settings')) return canOpenSettings(session);
if (pathname.startsWith('/client-access')) return canOpenClientAccess(session);
return true;
}
export function canUseWorkspaceSearch(session: AppSession | null | undefined) {
return (
canOpenDashboard(session) ||
canOpenRawMaterials(session) ||
canOpenMixMaster(session) ||
canOpenMixCalculator(session) ||
canOpenProducts(session) ||
canOpenScenarios(session)
);
}
export const getWorkspaceHomeHref = getDefaultRouteForRole;
export const isWorkspaceRouteAllowed = canAccessRoute;
+244
View File
@@ -0,0 +1,244 @@
<script lang="ts">
let { error, status } = $props();
const title = $derived(status === 404 ? 'Page Not Found' : 'Something Went Wrong');
const detail = $derived(
status === 404
? 'That route does not exist in the Hunter Premium Produce workspace. Check the address or return to login.'
: error instanceof Error
? error.message
: 'The workspace hit an unexpected error while loading this page.'
);
</script>
<svelte:head>
<title>{title} | Hunter Premium Produce</title>
</svelte:head>
<section class="error-stage">
<div class="error-card">
<div class="error-backdrop" aria-hidden="true">
<span class="glow glow-one"></span>
<span class="glow glow-two"></span>
<span class="grid-band"></span>
</div>
<div class="error-header">
<div class="brand-lockup">
<img class="brand-logo" src="/logo-hsf.png" alt="Hunter Premium Produce" />
<div>
<p class="eyebrow">Workspace Error</p>
<strong>Hunter Premium Produce</strong>
</div>
</div>
<span class="status-pill">{status}</span>
</div>
<div class="error-copy">
<p class="eyebrow">Route Response</p>
<h1>{title}</h1>
<p>{detail}</p>
</div>
<div class="error-actions">
<a class="primary-link" href="/">Return to Workspace</a>
<a class="secondary-link" href="/mix-calculator/new">Open Mix Calculator</a>
</div>
</div>
</section>
<style>
:global(body) {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(214, 234, 221, 0.86), transparent 36%),
linear-gradient(180deg, #f4f7f2 0%, #eef4ee 100%);
color: #1d3528;
font-family:
"Segoe UI",
system-ui,
sans-serif;
}
.error-stage {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
}
.error-card {
position: relative;
overflow: hidden;
width: min(100%, 58rem);
padding: 2rem;
border: 1px solid rgba(32, 52, 41, 0.08);
border-radius: 1.5rem;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 24px 60px rgba(39, 63, 52, 0.12);
}
.error-backdrop {
position: absolute;
inset: 0;
pointer-events: none;
}
.glow {
position: absolute;
border-radius: 999px;
filter: blur(16px);
opacity: 0.8;
}
.glow-one {
top: -3rem;
right: -2rem;
width: 15rem;
height: 15rem;
background: rgba(160, 199, 124, 0.25);
}
.glow-two {
bottom: -4rem;
left: -3rem;
width: 18rem;
height: 18rem;
background: rgba(214, 166, 90, 0.16);
}
.grid-band {
position: absolute;
inset: auto 0 0;
height: 8rem;
background:
linear-gradient(rgba(33, 54, 42, 0.07) 1px, transparent 1px),
linear-gradient(90deg, rgba(33, 54, 42, 0.07) 1px, transparent 1px);
background-size: 2rem 2rem;
mask-image: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.7));
}
.error-header,
.error-copy,
.error-actions {
position: relative;
z-index: 1;
}
.error-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.brand-lockup {
display: flex;
align-items: center;
gap: 0.9rem;
}
.brand-logo {
width: 3rem;
height: 3rem;
object-fit: contain;
}
.eyebrow {
margin: 0 0 0.22rem;
color: #5d7568;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.status-pill {
padding: 0.45rem 0.72rem;
border-radius: 999px;
background: rgba(31, 53, 40, 0.08);
color: #244331;
font-size: 0.84rem;
font-weight: 700;
}
.error-copy {
margin-top: 2.2rem;
max-width: 36rem;
}
.error-copy h1 {
margin: 0;
font-size: clamp(2.4rem, 6vw, 4.8rem);
line-height: 0.95;
letter-spacing: -0.04em;
}
.error-copy p:last-child {
margin: 1rem 0 0;
color: #587063;
font-size: 1rem;
line-height: 1.6;
}
.error-actions {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
margin-top: 2rem;
}
.primary-link,
.secondary-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.8rem;
padding: 0.7rem 1.15rem;
border-radius: 0.9rem;
font-weight: 700;
text-decoration: none;
transition:
transform 140ms ease,
box-shadow 140ms ease,
background 140ms ease;
}
.primary-link {
background: #274934;
color: #fff;
box-shadow: 0 14px 28px rgba(39, 73, 52, 0.22);
}
.secondary-link {
border: 1px solid rgba(39, 73, 52, 0.14);
background: rgba(255, 255, 255, 0.72);
color: #244331;
}
.primary-link:hover,
.secondary-link:hover {
transform: translateY(-1px);
}
@media (max-width: 640px) {
.error-stage {
padding: 1rem;
}
.error-card {
padding: 1.35rem;
border-radius: 1.15rem;
}
.error-header {
align-items: flex-start;
flex-direction: column;
}
.error-actions {
flex-direction: column;
}
}
</style>
+60 -2
View File
@@ -1,10 +1,13 @@
<script lang="ts">
import { api } from '$lib/api';
import { goto } from '$app/navigation';
import { clientSession, sessionHydrated } from '$lib/session';
import Skeleton from '$lib/components/Skeleton.svelte';
import type { DashboardSummary } from '$lib/types';
import { getWorkspaceHomeHref } from '$lib/workspace-access';
import packageInfo from '../../package.json';
import { Sunrise, Sun, Sunset, Moon } from 'lucide-svelte';
import { tick } from 'svelte';
type Segment = {
label: string;
@@ -32,8 +35,11 @@
let email = $state('');
let password = $state('');
let isLoggingIn = $state(false);
let postLoginRedirecting = $state(false);
let loginError = $state('');
let passwordInput: HTMLInputElement | null = null;
let emailInput = $state<HTMLInputElement | null>(null);
let passwordInput = $state<HTMLInputElement | null>(null);
let loginFocusArmed = $state(true);
const currentYear = new Date().getFullYear();
const appVersion = `v${packageInfo.version}`;
const releaseStage = 'Beta';
@@ -50,11 +56,17 @@
// system. The response is shape-compatible with the legacy client
// session, so the rest of the app continues to work unchanged.
const session = await api.internalLogin(email, password);
const targetHref = getWorkspaceHomeHref(session);
postLoginRedirecting = targetHref !== '/';
clientSession.set(session);
if (targetHref !== '/') {
await goto(targetHref, { replaceState: true });
}
} catch (error) {
loginError = error instanceof Error ? error.message : 'Unable to sign in';
triggerPasswordShake();
} finally {
postLoginRedirecting = false;
isLoggingIn = false;
}
}
@@ -84,6 +96,18 @@
);
}
$effect(() => {
if ($sessionHydrated && !$clientSession) {
if (loginFocusArmed && emailInput) {
loginFocusArmed = false;
tick().then(() => emailInput?.focus());
}
return;
}
loginFocusArmed = true;
});
function currency(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) {
return 'N/A';
@@ -363,7 +387,13 @@
<form class="signin-form auth-form" onsubmit={handleLogin}>
<label class="field">
<span>Email</span>
<input bind:value={email} type="email" autocomplete="username" placeholder="Email" autofocus />
<input
bind:this={emailInput}
bind:value={email}
type="email"
autocomplete="username"
placeholder="Email"
/>
</label>
<label class="field field-password" class:is-invalid={Boolean(loginError)}>
@@ -401,6 +431,34 @@
</div>
</div>
</section>
{:else if postLoginRedirecting}
<section class="auth-stage auth-stage-loading">
<div class="auth-card auth-card-loading">
<div class="auth-header">
<div class="client-logo-block">
<img class="hero-login-logo" src="/logo-hsf.png" alt="Lean 101" />
<div class="client-logo-copy">
<p class="eyebrow">Opening Workspace</p>
<strong>Hunter Premium Produce</strong>
<span>Applying your role permissions now</span>
</div>
</div>
</div>
<div class="auth-copy">
<h2>Preparing your workspace.</h2>
<p>Routing you directly to the first area your role is allowed to open.</p>
</div>
<div class="auth-loading-panel">
<span class="loading-pulse" aria-hidden="true"></span>
<div>
<strong>Applying Access Rules</strong>
<p>Dashboard access is skipped for roles that do not have permission.</p>
</div>
</div>
</div>
</section>
{:else}
<section class="dashboard-intro">
<div class="greeting-row">
+4 -11
View File
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canOpenDashboard, getWorkspaceHomeHref } from '$lib/workspace-access';
import type { DashboardSummary } from '$lib/types';
const EMPTY_SUMMARY: DashboardSummary = {
@@ -18,18 +20,9 @@ export function load({ fetch }) {
return { summary: Promise.resolve(EMPTY_SUMMARY) };
}
// Skip data fetching for sessions that lack any dashboard-eligible module
// — the backend would just return nulls anyway.
const session = getStoredClientSession();
const permissions = session?.module_permissions ?? {};
const hasAnyDashboardData =
session?.role === 'admin' ||
permissions.dashboard ||
permissions.raw_materials ||
permissions.mix_master ||
permissions.products;
if (!hasAnyDashboardData) {
return { summary: Promise.resolve(EMPTY_SUMMARY) };
if (!canOpenDashboard(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
return {
+2 -2
View File
@@ -4,8 +4,8 @@
let { data } = $props();
let email = $state('admin@lean101.local');
let password = $state('lean101-admin');
let email = $state('');
let password = $state('');
let isLoggingIn = $state(false);
let loginError = $state('');
+10 -1
View File
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { hasStoredAdminSession, hasStoredClientSession } from '$lib/session';
import { getStoredClientSession, hasStoredAdminSession, hasStoredClientSession } from '$lib/session';
import { canOpenClientAccess, getWorkspaceHomeHref } from '$lib/workspace-access';
function emptyPayload() {
return {
@@ -21,6 +23,13 @@ export async function load({ fetch }) {
return emptyPayload();
}
if (hasStoredClientSession()) {
const session = getStoredClientSession();
if (session && !canOpenClientAccess(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
}
try {
const [clients, exportPreview] = await Promise.all([api.clientAccess(fetch), api.clientAccessExport(fetch)]);
return { clients, exportPreview };
+5 -1
View File
@@ -2,6 +2,7 @@ import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { featureFlags } from '$lib/features';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { canOpenMixCalculator, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!featureFlags.mixCalculatorSessionHistory) {
@@ -15,10 +16,13 @@ export async function load({ fetch }) {
}
const session = getStoredClientSession();
if (!canOpenMixCalculator(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
return {
sessions: hasModuleAccess(session, 'mix_calculator') ? await api.mixCalculatorSessions(fetch) : []
sessions: hasModuleAccess(session, 'mix_calculator') || session?.role === 'internal' ? await api.mixCalculatorSessions(fetch) : []
};
} catch {
return {
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { canCreateMixSession, canOpenMixCalculator, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ params, fetch }) {
if (!hasStoredClientSession()) {
@@ -10,20 +12,17 @@ export async function load({ params, fetch }) {
}
const session = getStoredClientSession();
const canView = hasModuleAccess(session, 'mix_calculator');
const canEdit = hasModuleAccess(session, 'mix_calculator', 'edit');
const canView = canOpenMixCalculator(session);
const canEdit = canCreateMixSession(session);
if (!canView) {
return {
session: null,
options: { clients: [], products: [] }
};
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const [savedSession, options] = await Promise.all([
api.mixCalculatorSession(Number(params.id), fetch),
canEdit ? api.mixCalculatorOptions(fetch) : Promise.resolve({ clients: [], products: [] })
canEdit || session?.role === 'internal' ? api.mixCalculatorOptions(fetch) : Promise.resolve({ clients: [], products: [] })
]);
return {
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { canOpenMixCalculator, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ params, fetch }) {
if (!hasStoredClientSession()) {
@@ -10,10 +12,8 @@ export async function load({ params, fetch }) {
const session = getStoredClientSession();
if (!hasModuleAccess(session, 'mix_calculator')) {
return {
session: null
};
if (!canOpenMixCalculator(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { canCreateMixSession, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
@@ -9,10 +11,13 @@ export async function load({ fetch }) {
}
const session = getStoredClientSession();
if (!canCreateMixSession(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
return {
options: hasModuleAccess(session, 'mix_calculator', 'edit')
options: hasModuleAccess(session, 'mix_calculator', 'edit') || session?.role === 'internal'
? await api.mixCalculatorOptions(fetch)
: { clients: [], products: [] }
};
+6 -1
View File
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import { canOpenMixMaster, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
@@ -9,10 +11,13 @@ export async function load({ fetch }) {
}
const session = getStoredClientSession();
if (!canOpenMixMaster(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
return {
mixes: hasModuleAccess(session, 'mix_master') ? await api.mixes(fetch) : []
mixes: hasModuleAccess(session, 'mix_master') || session?.role === 'internal' ? await api.mixes(fetch) : []
};
} catch {
return {
+5 -7
View File
@@ -1,6 +1,7 @@
import { error } from '@sveltejs/kit';
import { error, redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { canOpenMixMaster, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ params, fetch }) {
const mixId = Number(params.id);
@@ -17,17 +18,14 @@ export async function load({ params, fetch }) {
}
const session = getStoredClientSession();
if (!hasModuleAccess(session, 'mix_master')) {
return {
mix: null,
rawMaterials: []
};
if (!canOpenMixMaster(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const [mix, rawMaterials] = await Promise.all([
api.mix(mixId, fetch),
hasModuleAccess(session, 'raw_materials') ? api.rawMaterials(fetch) : Promise.resolve([])
hasModuleAccess(session, 'raw_materials') || session?.role === 'internal' ? api.rawMaterials(fetch) : Promise.resolve([])
]);
return {
+9 -1
View File
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import { canCreateMixWorksheet, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
@@ -9,10 +11,16 @@ export async function load({ fetch }) {
}
const session = getStoredClientSession();
if (!canCreateMixWorksheet(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
return {
rawMaterials: hasModuleAccess(session, 'mix_master') && hasModuleAccess(session, 'raw_materials') ? await api.rawMaterials(fetch) : []
rawMaterials:
(hasModuleAccess(session, 'mix_master') && hasModuleAccess(session, 'raw_materials')) || session?.role === 'internal'
? await api.rawMaterials(fetch)
: []
};
} catch {
return {
+7 -2
View File
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import { canOpenProducts, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
@@ -10,11 +12,14 @@ export async function load({ fetch }) {
}
const session = getStoredClientSession();
if (!canOpenProducts(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const [products, productCosts] = await Promise.all([
hasModuleAccess(session, 'products') ? api.products(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'products') ? api.productCosts(fetch) : Promise.resolve([])
hasModuleAccess(session, 'products') || session?.role === 'internal' ? api.products(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'products') || session?.role === 'internal' ? api.productCosts(fetch) : Promise.resolve([])
]);
return {
products,
+9 -4
View File
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import { canOpenRawMaterials, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
@@ -12,13 +14,16 @@ export async function load({ fetch }) {
}
const session = getStoredClientSession();
if (!canOpenRawMaterials(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const [rawMaterials, mixes, products, productCosts] = await Promise.all([
hasModuleAccess(session, 'raw_materials') ? api.rawMaterials(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'mix_master') ? api.mixes(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'products') ? api.products(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'products') ? api.productCosts(fetch) : Promise.resolve([])
hasModuleAccess(session, 'raw_materials') || session?.role === 'internal' ? api.rawMaterials(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'mix_master') || session?.role === 'internal' ? api.mixes(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'products') || session?.role === 'internal' ? api.products(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'products') || session?.role === 'internal' ? api.productCosts(fetch) : Promise.resolve([])
]);
return {
+16
View File
@@ -0,0 +1,16 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canOpenReporting, getWorkspaceHomeHref } from '$lib/workspace-access';
export function load() {
if (!hasStoredClientSession()) {
return {};
}
const session = getStoredClientSession();
if (session && !canOpenReporting(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
return {};
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from 'vitest';
const apiMocks = vi.hoisted(() => ({
dashboardSummary: vi.fn()
}));
const sessionMocks = vi.hoisted(() => ({
getStoredClientSession: vi.fn(),
hasStoredClientSession: vi.fn(),
hasModuleAccess: vi.fn(),
hasPermission: vi.fn()
}));
vi.mock('$lib/api', () => ({
api: apiMocks
}));
vi.mock('$lib/session', () => sessionMocks);
import { load } from './+page';
describe('root route access', () => {
it('redirects operations users away from the dashboard route', () => {
sessionMocks.hasStoredClientSession.mockReturnValue(true);
sessionMocks.getStoredClientSession.mockReturnValue({
role: 'internal',
role_name: 'Operations',
permissions: ['view_mix_calculator']
});
sessionMocks.hasPermission.mockImplementation((session, key) => session?.permissions?.includes(key) ?? false);
sessionMocks.hasModuleAccess.mockReturnValue(false);
expect(() => load({ fetch: vi.fn() as typeof fetch })).toThrow(
expect.objectContaining({ status: 307, location: '/mix-calculator/new' })
);
});
it('loads the dashboard summary for users with dashboard access', () => {
sessionMocks.hasStoredClientSession.mockReturnValue(true);
sessionMocks.getStoredClientSession.mockReturnValue({ role: 'internal', permissions: ['view_dashboard'] });
sessionMocks.hasPermission.mockImplementation((session, key) => session?.permissions?.includes(key) ?? false);
apiMocks.dashboardSummary.mockResolvedValue({ ok: true });
const result = load({ fetch: vi.fn() as typeof fetch });
expect(apiMocks.dashboardSummary).toHaveBeenCalled();
expect(result).toHaveProperty('summary');
});
});
+5
View File
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import { canOpenScenarios, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
@@ -9,6 +11,9 @@ export async function load({ fetch }) {
}
const session = getStoredClientSession();
if (!canOpenScenarios(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
return {
@@ -29,7 +29,6 @@
...$clientSession!,
name: updated.name,
email: updated.email,
token: updated.token ?? $clientSession!.token,
});
toast.dismiss(tid);
toast.success('Profile updated');
+20
View File
@@ -0,0 +1,20 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canOpenSettings, getWorkspaceHomeHref } from '$lib/workspace-access';
export function load() {
if (!hasStoredClientSession()) {
return {};
}
const session = getStoredClientSession();
if (!session) {
return {};
}
if (!canOpenSettings(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
return {};
}
@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from 'vitest';
const sessionMocks = vi.hoisted(() => ({
getStoredClientSession: vi.fn(),
hasStoredClientSession: vi.fn(),
hasModuleAccess: vi.fn(),
hasPermission: vi.fn()
}));
vi.mock('$lib/session', () => sessionMocks);
import { load } from './+page';
describe('settings route access', () => {
it('allows users with settings access', () => {
sessionMocks.hasStoredClientSession.mockReturnValue(true);
sessionMocks.getStoredClientSession.mockReturnValue({ role: 'internal', permissions: ['view_settings'] });
sessionMocks.hasPermission.mockImplementation((session, key) => session?.permissions?.includes(key) ?? false);
expect(load()).toEqual({});
});
it('redirects users without settings access to their allowed home route', () => {
sessionMocks.hasStoredClientSession.mockReturnValue(true);
sessionMocks.getStoredClientSession.mockReturnValue({
role: 'internal',
role_name: 'Operations',
permissions: ['view_mix_calculator']
});
sessionMocks.hasPermission.mockImplementation((session, key) => session?.permissions?.includes(key) ?? false);
sessionMocks.hasModuleAccess.mockReturnValue(false);
expect(() => load()).toThrow(expect.objectContaining({ status: 307, location: '/mix-calculator/new' }));
});
});