Updates
This commit is contained in:
@@ -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
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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(() => {});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user