Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live.
Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows
Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator).
Fix: Security headers on all API responses (hardening)
Add: New mix button available on the Mix Editor.
Add: New ingredient button available on the Ingredient Editor
This commit is contained in:
2026-06-17 21:55:04 +12:00
parent 7db95e2027
commit 3f8279af10
24 changed files with 3820 additions and 37 deletions
+54
View File
@@ -7,6 +7,14 @@ import type {
ClientUserCreateInput,
ClientUserModulePermission,
ClientUserUpdateInput,
InternalUser,
InternalRoleOption,
InternalRole,
InternalRoleCreateInput,
InternalRoleModuleDefinition,
InternalRoleUpdateInput,
InternalUserCreateInput,
InternalUserUpdateInput,
LoginResponse,
EditorMixCreateInput,
EditorMixUpdateInput,
@@ -17,6 +25,7 @@ import type {
EditorIngredientRow,
EditorIngredientCreateInput,
EditorIngredientUpdateInput,
EditorChangeEvent,
EditorProductFormula,
EditorProductRow,
EditorProductUpdateInput,
@@ -49,6 +58,7 @@ import type {
XeroContactList,
XeroContactLinkRow,
Scenario,
ThroughputDeleteAllResult,
ThroughputEntry,
ThroughputEntryCreateInput,
ThroughputEntryUpdateInput,
@@ -453,6 +463,10 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
editorMixHistory: (mixId: number) =>
request<EditorChangeEvent[]>(`/api/editor/mixes/${mixId}/history`, {}, 'client'),
editorIngredientHistory: (ingredientId: number) =>
request<EditorChangeEvent[]>(`/api/editor/ingredients/${ingredientId}/history`, {}, 'client'),
productCosts: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', 'client', fetcher),
productCostingItems: (fetcher?: ApiFetch) =>
@@ -505,6 +519,8 @@ export const api = {
formData.append('file', file);
return uploadFile<ThroughputImportResult>('/api/throughput/import', formData, 'client');
},
deleteAllThroughputEntries: () =>
request<ThroughputDeleteAllResult>('/api/throughput/entries', { method: 'DELETE' }, 'client'),
createThroughputProduct: (payload: ThroughputProductCreateInput) =>
request<ThroughputProduct>('/api/throughput/products', {
method: 'POST',
@@ -541,6 +557,44 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
// --- Internal user management (lean/admin: manage_users) ------------------
accessUsers: (fetcher?: ApiFetch) =>
request<InternalUser[]>('/api/access/users', { method: 'GET' }, 'client', fetcher),
accessAssignableRoles: (fetcher?: ApiFetch) =>
request<InternalRoleOption[]>('/api/access/assignable-roles', { method: 'GET' }, 'client', fetcher),
accessRoles: (fetcher?: ApiFetch) =>
request<InternalRole[]>('/api/access/roles', { method: 'GET' }, 'client', fetcher),
accessRoleModules: (fetcher?: ApiFetch) =>
request<InternalRoleModuleDefinition[]>('/api/access/role-modules', { method: 'GET' }, 'client', fetcher),
createAccessRole: (payload: InternalRoleCreateInput) =>
request<InternalRole>('/api/access/roles', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateAccessRole: (roleId: number, payload: InternalRoleUpdateInput) =>
request<InternalRole>(`/api/access/roles/${roleId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
deleteAccessRole: (roleId: number) =>
request<void>(`/api/access/roles/${roleId}`, { method: 'DELETE' }, 'client'),
createAccessUser: (payload: InternalUserCreateInput) =>
request<InternalUser>('/api/access/users', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateAccessUser: (userId: number, payload: InternalUserUpdateInput) =>
request<InternalUser>(`/api/access/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
setAccessUserPassword: (userId: number, newPassword: string) =>
request<InternalUser>(`/api/access/users/${userId}/password`, {
method: 'POST',
body: JSON.stringify({ new_password: newPassword })
}, 'client'),
deleteAccessUser: (userId: number) =>
request<void>(`/api/access/users/${userId}`, { method: 'DELETE' }, 'client'),
adminLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/admin/login', {
method: 'POST',
@@ -0,0 +1,353 @@
<script lang="ts">
import { api } from '$lib/api';
import type { EditorChangeEvent } from '$lib/types';
import { Clock, History, X } from 'lucide-svelte';
import { onMount } from 'svelte';
let {
entityType,
entityId,
title,
subtitle = '',
onClose
}: {
entityType: 'mix' | 'ingredient';
entityId: number;
title: string;
subtitle?: string;
onClose: () => void;
} = $props();
let events = $state<EditorChangeEvent[] | null>(null);
let error = $state<string | null>(null);
onMount(async () => {
try {
events =
entityType === 'mix'
? await api.editorMixHistory(entityId)
: await api.editorIngredientHistory(entityId);
} catch (err) {
error = err instanceof Error ? err.message : 'Unable to load history';
}
});
const ACTION_LABELS: Record<string, string> = {
created: 'Created',
updated: 'Updated',
formula_updated: 'Formula updated',
ingredient_added: 'Ingredient added',
ingredient_updated: 'Ingredient updated',
ingredient_removed: 'Ingredient removed'
};
function actionLabel(action: string) {
return ACTION_LABELS[action] ?? action.replace(/_/g, ' ');
}
function formatWhen(value: string) {
// Stored as a naive UTC timestamp; treat it as UTC for display.
const iso = value.endsWith('Z') || value.includes('+') ? value : `${value}Z`;
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
</script>
<div class="history-backdrop" role="presentation" onclick={onClose}>
<div
class="history-modal"
role="dialog"
aria-modal="true"
aria-label={`Change history for ${title}`}
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => {
if (event.key === 'Escape') onClose();
}}
>
<header class="history-head">
<div class="history-title">
<span class="kicker"><History size={14} strokeWidth={2.2} /> Change history</span>
<h3>{title}</h3>
{#if subtitle}<p class="subtitle">{subtitle}</p>{/if}
</div>
<button class="icon-close" type="button" onclick={onClose} aria-label="Close history">
<X size={18} strokeWidth={2.2} />
</button>
</header>
<div class="history-body">
{#if error}
<p class="state error">{error}</p>
{:else if events === null}
<p class="state">Loading history…</p>
{:else if events.length === 0}
<div class="empty">
<Clock size={22} strokeWidth={1.8} />
<p>No changes recorded yet.</p>
<span>Edits made from here on will appear in this list.</span>
</div>
{:else}
<ol class="timeline">
{#each events as event (event.id)}
<li class="event">
<div class="event-head">
<span class="badge">{actionLabel(event.action)}</span>
<time>{formatWhen(event.created_at)}</time>
</div>
<p class="summary">{event.summary}</p>
{#if event.changes.length}
<ul class="deltas">
{#each event.changes as delta}
<li>
<span class="delta-label">{delta.label}</span>
<span class="delta-values">
<span class="before">{delta.before ?? '—'}</span>
<span class="arrow" aria-hidden="true"></span>
<span class="after">{delta.after ?? '—'}</span>
</span>
</li>
{/each}
</ul>
{/if}
<p class="actor">by {event.actor_name}{#if event.actor_role} · {event.actor_role}{/if}</p>
</li>
{/each}
</ol>
{/if}
</div>
</div>
</div>
<style>
h3,
p {
margin: 0;
}
.history-backdrop {
position: fixed;
inset: 0;
z-index: 70;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(17, 24, 20, 0.52);
backdrop-filter: blur(8px);
}
.history-modal {
display: flex;
flex-direction: column;
width: min(620px, 100%);
max-height: calc(100vh - 2rem);
border: 1px solid var(--color-border);
border-radius: 0.9rem;
background: var(--color-bg-surface);
overflow: hidden;
}
.history-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.1rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-app);
}
.history-title {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.kicker {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--color-text-muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.history-title h3 {
font-size: 1.1rem;
font-weight: 700;
color: var(--color-text-primary);
}
.subtitle {
color: var(--color-text-secondary);
font-size: 0.84rem;
}
.icon-close {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 34px;
height: 34px;
border: 1px solid var(--color-border);
border-radius: 0.45rem;
background: var(--color-bg-surface);
color: var(--color-text-secondary);
cursor: pointer;
}
.icon-close:hover {
border-color: var(--color-text-muted);
color: var(--color-text-primary);
}
.history-body {
padding: 1rem 1.1rem 1.2rem;
overflow-y: auto;
}
.state {
padding: 1.5rem 0;
text-align: center;
color: var(--color-text-secondary);
font-weight: 600;
}
.state.error {
color: var(--color-error);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
padding: 2rem 1rem;
text-align: center;
color: var(--color-text-secondary);
}
.empty p {
font-weight: 650;
color: var(--color-text-primary);
}
.empty span {
font-size: 0.84rem;
}
.timeline {
display: flex;
flex-direction: column;
gap: 0.7rem;
margin: 0;
padding: 0;
list-style: none;
}
.event {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.8rem 0.9rem;
border: 1px solid var(--color-divider);
border-radius: 0.6rem;
background: var(--color-bg-app);
}
.event-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.18rem 0.55rem;
border-radius: 999px;
background: var(--color-brand-tint);
color: var(--color-brand);
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.event-head time {
color: var(--color-text-muted);
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.summary {
color: var(--color-text-primary);
font-size: 0.9rem;
font-weight: 600;
}
.deltas {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin: 0.1rem 0 0;
padding: 0.5rem 0.6rem;
list-style: none;
border-radius: 0.45rem;
background: var(--color-bg-surface);
border: 1px solid var(--color-divider);
}
.deltas li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.delta-label {
color: var(--color-text-secondary);
font-size: 0.82rem;
font-weight: 600;
}
.delta-values {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
}
.before {
color: var(--color-text-muted);
text-decoration: line-through;
}
.arrow {
color: var(--color-text-muted);
}
.after {
color: var(--color-text-primary);
font-weight: 700;
}
.actor {
color: var(--color-text-muted);
font-size: 0.78rem;
}
</style>
@@ -0,0 +1,792 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import type {
InternalRole,
InternalRoleCreateInput,
InternalRoleModuleDefinition,
InternalRoleUpdateInput
} from '$lib/types';
import { Pencil, ShieldCheck, Trash2, TriangleAlert, Waypoints, Plus } from 'lucide-svelte';
let roles = $state<InternalRole[]>([]);
let modules = $state<InternalRoleModuleDefinition[]>([]);
let loading = $state(true);
let loadError = $state('');
async function load() {
loading = true;
loadError = '';
try {
const [roleList, moduleList] = await Promise.all([
api.accessRoles(),
api.accessRoleModules()
]);
roles = roleList;
modules = moduleList;
} catch (err: unknown) {
loadError = err instanceof Error ? err.message : 'Failed to load roles';
} finally {
loading = false;
}
}
onMount(load);
function emptyModulePermissions() {
return Object.fromEntries(modules.map((module) => [module.key, 'none'])) as Record<string, string>;
}
type FormMode = 'create' | 'edit';
let formOpen = $state(false);
let formMode = $state<FormMode>('create');
let formRoleId = $state<number | null>(null);
let formName = $state('');
let formDescription = $state('');
let formModulePermissions = $state<Record<string, string>>({});
let formProtected = $state(false);
let formSaving = $state(false);
let formError = $state('');
function openCreate() {
formMode = 'create';
formRoleId = null;
formName = '';
formDescription = '';
formModulePermissions = emptyModulePermissions();
formProtected = false;
formError = '';
formOpen = true;
}
function openEdit(role: InternalRole) {
formMode = 'edit';
formRoleId = role.id;
formName = role.name;
formDescription = role.description ?? '';
formModulePermissions = { ...emptyModulePermissions(), ...role.module_permissions };
formProtected = role.is_protected;
formError = '';
formOpen = true;
}
function closeForm() {
if (formSaving) return;
formOpen = false;
}
function setModuleLevel(moduleKey: string, level: string) {
formModulePermissions = { ...formModulePermissions, [moduleKey]: level };
}
function summary(role: InternalRole) {
return modules
.map((module) => {
const level = role.module_permissions[module.key];
return level && level !== 'none' ? `${module.label}: ${level}` : null;
})
.filter(Boolean)
.join(' • ');
}
async function saveForm() {
formError = '';
const name = formName.trim();
if (!name) {
formError = 'Role name is required';
return;
}
formSaving = true;
const tid = toast.loading(formMode === 'create' ? 'Creating role…' : 'Saving role…');
try {
const payload: InternalRoleCreateInput | InternalRoleUpdateInput = {
name,
description: formDescription.trim() || null,
module_permissions: formModulePermissions
};
if (formMode === 'create') {
await api.createAccessRole(payload as InternalRoleCreateInput);
} else if (formRoleId != null) {
await api.updateAccessRole(formRoleId, payload);
}
toast.dismiss(tid);
toast.success(formMode === 'create' ? 'Role created' : 'Role updated');
formOpen = false;
await load();
} catch (err: unknown) {
toast.dismiss(tid);
const message = err instanceof Error ? err.message : 'An error occurred';
formError = message;
toast.error(message);
} finally {
formSaving = false;
}
}
let deleteRole = $state<InternalRole | null>(null);
let deleting = $state(false);
function openDelete(role: InternalRole) {
deleteRole = role;
}
function closeDelete() {
if (deleting) return;
deleteRole = null;
}
async function confirmDelete() {
if (!deleteRole) return;
deleting = true;
const tid = toast.loading('Deleting role…');
try {
await api.deleteAccessRole(deleteRole.id);
toast.dismiss(tid);
toast.success(`Deleted ${deleteRole.name}`);
deleteRole = null;
await load();
} catch (err: unknown) {
toast.dismiss(tid);
toast.error(err instanceof Error ? err.message : 'Failed to delete role');
} finally {
deleting = false;
}
}
</script>
<div class="panel-section">
<header class="panel-header">
<div class="header-copy">
<h2>Roles</h2>
<p>Define which modules each role can open, edit, or manage.</p>
</div>
<button type="button" class="btn-primary" onclick={openCreate}>
<Plus size={16} strokeWidth={2.2} /> Add role
</button>
</header>
{#if loading}
<p class="state-msg">Loading roles…</p>
{:else if loadError}
<p class="state-msg error"><TriangleAlert size={15} strokeWidth={2.2} /> {loadError}</p>
{:else}
<div class="table-wrap">
<table class="roles-table">
<thead>
<tr>
<th>Role</th>
<th>Assigned users</th>
<th>Module access</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{#each roles as role (role.id)}
<tr>
<td>
<div class="role-cell">
<div class="role-title-row">
<strong>{role.name}</strong>
{#if role.is_protected}
<span class="protected-chip">
<ShieldCheck size={12} strokeWidth={2.4} /> Protected
</span>
{/if}
</div>
{#if role.description}
<p>{role.description}</p>
{/if}
</div>
</td>
<td>{role.user_count}</td>
<td class="summary-cell">{summary(role) || 'No module access'}</td>
<td class="actions-col">
<div class="row-actions">
<button type="button" class="icon-btn" title="Edit role" onclick={() => openEdit(role)}>
<Pencil size={15} strokeWidth={2.1} />
</button>
<button
type="button"
class="icon-btn danger"
title={role.is_protected
? 'Lean and admin roles cannot be deleted'
: role.user_count > 0
? 'Reassign users before deleting this role'
: 'Delete role'}
disabled={role.is_protected || role.user_count > 0}
onclick={() => openDelete(role)}
>
<Trash2 size={15} strokeWidth={2.1} />
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{#if formOpen}
<div class="modal-backdrop" role="presentation" onclick={closeForm}>
<div
class="modal-card modal-card-wide"
role="dialog"
aria-modal="true"
aria-labelledby="role-form-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') closeForm(); }}
>
<div class="modal-top">
<div class="modal-icon"><Waypoints size={20} strokeWidth={2.2} /></div>
<div>
<h2 id="role-form-title" class="modal-title">{formMode === 'create' ? 'Add role' : 'Edit role'}</h2>
<p class="modal-text">Module access levels are translated into the underlying permissions automatically.</p>
</div>
</div>
<form class="modal-form" onsubmit={(event) => { event.preventDefault(); saveForm(); }}>
<div class="field-row">
<div class="field">
<label for="rf-name">Role name</label>
<input id="rf-name" type="text" bind:value={formName} disabled={formProtected && formMode === 'edit'} required />
</div>
<div class="field">
<label for="rf-description">Description</label>
<input id="rf-description" type="text" bind:value={formDescription} />
</div>
</div>
<div class="permissions-section">
<div class="permissions-head">
<div>
<h3>Module access</h3>
<p>Each row controls where this role can go and what it can do there.</p>
</div>
</div>
<div class="permissions-scroll">
<table class="permissions-table">
<thead>
<tr>
<th>Module</th>
<th>What it covers</th>
<th>Access level</th>
</tr>
</thead>
<tbody>
{#each modules as module (module.key)}
<tr>
<td class="module-name-cell">
<strong>{module.label}</strong>
</td>
<td class="module-description-cell">{module.description}</td>
<td class="module-level-cell">
<label class="matrix-select">
<span class="sr-only">Access level for {module.label}</span>
<select
value={formModulePermissions[module.key] ?? 'none'}
onchange={(event) => setModuleLevel(module.key, (event.currentTarget as HTMLSelectElement).value)}
>
{#each module.levels as level (level)}
<option value={level}>{level}</option>
{/each}
</select>
</label>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{#if formError}
<p class="form-error"><TriangleAlert size={15} strokeWidth={2.2} /> {formError}</p>
{/if}
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={closeForm} disabled={formSaving}>Cancel</button>
<button type="submit" class="btn-primary" disabled={formSaving}>
{formSaving ? 'Saving…' : formMode === 'create' ? 'Create role' : 'Save changes'}
</button>
</div>
</form>
</div>
</div>
{/if}
{#if deleteRole}
<div class="modal-backdrop" role="presentation" onclick={closeDelete}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="delete-role-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') closeDelete(); }}
>
<div class="modal-icon danger"><Trash2 size={20} strokeWidth={2.2} /></div>
<h2 id="delete-role-title" class="modal-title">Delete role?</h2>
<p class="modal-text">
This removes <strong>{deleteRole.name}</strong>. Users must be reassigned first.
</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={closeDelete} disabled={deleting}>Cancel</button>
<button type="button" class="modal-confirm" onclick={confirmDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete role'}
</button>
</div>
</div>
</div>
{/if}
<style>
.panel-section {
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.5rem 1.75rem 1.25rem;
border-bottom: 1px solid var(--line);
}
.header-copy h2,
.modal-title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
}
.header-copy p,
.modal-text {
margin: 0.3rem 0 0;
font-size: 0.85rem;
color: var(--muted);
}
.btn-primary,
.modal-confirm,
.modal-cancel,
.icon-btn {
transition: opacity 140ms ease, border-color 140ms ease, color 140ms ease, background-color 140ms ease;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.55rem 1.1rem;
background: var(--color-brand);
color: #fff;
border: none;
border-radius: 0.6rem;
font-size: 0.86rem;
font-weight: 600;
cursor: pointer;
}
.btn-primary:disabled,
.modal-confirm:disabled,
.modal-cancel:disabled,
.icon-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.state-msg {
padding: 1.5rem 1.75rem;
margin: 0;
color: var(--muted);
}
.state-msg.error,
.form-error {
display: flex;
align-items: center;
gap: 0.4rem;
color: #c53030;
}
.table-wrap {
overflow-x: auto;
padding: 0.5rem 1.75rem 1.75rem;
}
.roles-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.roles-table th,
.roles-table td {
padding: 0.75rem;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.roles-table th {
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.role-cell p,
.summary-cell {
margin: 0.25rem 0 0;
color: var(--muted);
line-height: 1.45;
}
.role-title-row {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.protected-chip {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.08rem 0.42rem;
border-radius: 0.5rem;
background: color-mix(in srgb, var(--color-brand) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-brand) 30%, transparent);
color: var(--color-brand);
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
}
.actions-col {
text-align: right;
white-space: nowrap;
}
.row-actions {
display: inline-flex;
gap: 0.3rem;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--panel);
color: var(--muted);
cursor: pointer;
}
.icon-btn:hover:not(:disabled) {
color: var(--text);
border-color: var(--color-brand);
}
.icon-btn.danger:hover:not(:disabled) {
color: #c53030;
border-color: color-mix(in srgb, #e53e3e 45%, transparent);
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1.5rem;
background: color-mix(in srgb, var(--color-text-primary, #000) 32%, transparent);
backdrop-filter: blur(6px);
}
.modal-card {
width: min(34rem, 100%);
display: grid;
gap: 0.8rem;
padding: 1.6rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
}
.modal-card-wide {
width: min(54rem, 100%);
max-height: min(88vh, 60rem);
}
.modal-top {
display: flex;
gap: 0.85rem;
align-items: flex-start;
}
.modal-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.6rem;
height: 2.6rem;
border-radius: 0.8rem;
background: color-mix(in srgb, var(--color-brand) 12%, transparent);
color: var(--color-brand);
}
.modal-icon.danger {
background: #fdecee;
color: #b3261e;
}
.modal-form {
display: grid;
gap: 0.85rem;
min-height: 0;
}
.field-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.field,
.matrix-select {
display: grid;
gap: 0.4rem;
}
.field label,
.matrix-select span {
font-size: 0.82rem;
font-weight: 600;
color: var(--text);
}
.field input,
.matrix-select select {
width: 100%;
padding: 0.58rem 0.8rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--panel-soft);
color: var(--text);
font-size: 0.9rem;
box-sizing: border-box;
}
.permissions-section {
display: grid;
gap: 0.75rem;
min-height: 0;
}
.permissions-head h3 {
margin: 0;
font-size: 0.94rem;
font-weight: 700;
color: var(--text);
}
.permissions-head p {
margin: 0.28rem 0 0;
font-size: 0.82rem;
color: var(--muted);
}
.permissions-scroll {
min-height: 0;
max-height: min(46vh, 30rem);
overflow: auto;
border: 1px solid var(--line);
border-radius: 0.8rem;
background: var(--panel-soft);
}
.permissions-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
.permissions-table th,
.permissions-table td {
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.permissions-table th {
position: sticky;
top: 0;
z-index: 1;
background: color-mix(in srgb, var(--panel) 92%, var(--panel-soft));
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.permissions-table tbody tr:last-child td {
border-bottom: none;
}
.module-name-cell {
width: 11rem;
}
.module-name-cell strong {
display: block;
color: var(--text);
}
.module-description-cell {
color: var(--muted);
font-size: 0.83rem;
line-height: 1.45;
}
.module-level-cell {
width: 11rem;
}
.module-level-cell .matrix-select {
gap: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.form-error {
margin: 0;
padding: 0.6rem 0.8rem;
background: color-mix(in srgb, #e53e3e 8%, transparent);
border: 1px solid color-mix(in srgb, #e53e3e 25%, transparent);
border-radius: 0.55rem;
font-size: 0.83rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 0.3rem;
}
.modal-cancel {
padding: 0.55rem 1.1rem;
background: var(--panel);
border: 1px solid var(--line);
color: var(--muted);
border-radius: 0.6rem;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
}
.modal-confirm {
padding: 0.55rem 1.1rem;
background: #b3261e;
border: 1px solid #b3261e;
color: #fff;
border-radius: 0.6rem;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
}
@media (max-width: 860px) {
.field-row {
grid-template-columns: 1fr;
}
.permissions-scroll {
max-height: min(44vh, 26rem);
}
.permissions-table,
.permissions-table thead,
.permissions-table tbody,
.permissions-table tr,
.permissions-table th,
.permissions-table td {
display: block;
}
.permissions-table thead {
display: none;
}
.permissions-table tbody {
display: grid;
}
.permissions-table tr {
display: grid;
gap: 0.55rem;
padding: 0.95rem 1rem;
border-bottom: 1px solid var(--line);
}
.permissions-table td {
width: auto;
padding: 0;
border: none;
}
.module-level-cell .matrix-select {
gap: 0.35rem;
}
.module-level-cell .matrix-select .sr-only {
position: static;
width: auto;
height: auto;
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
font-size: 0.78rem;
font-weight: 600;
color: var(--muted);
}
}
@media (max-width: 720px) {
.panel-header {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -0,0 +1,851 @@
<script lang="ts">
import { onMount } from 'svelte';
import { tooltip } from '$lib/actions/tooltip';
import { api } from '$lib/api';
import { clientSession } from '$lib/session';
import { toast } from '$lib/toast';
import type { InternalRoleOption, InternalUser } from '$lib/types';
import { UserPlus, Pencil, KeyRound, Trash2, ShieldCheck, TriangleAlert } from 'lucide-svelte';
let users = $state<InternalUser[]>([]);
let roles = $state<InternalRoleOption[]>([]);
let loading = $state(true);
let loadError = $state('');
const currentUserId = $derived($clientSession?.user_id ?? null);
async function load() {
loading = true;
loadError = '';
try {
const [userList, roleList] = await Promise.all([
api.accessUsers(),
api.accessAssignableRoles()
]);
users = userList;
roles = roleList;
} catch (err: unknown) {
loadError = err instanceof Error ? err.message : 'Failed to load users';
} finally {
loading = false;
}
}
onMount(load);
// ── Create / edit modal ───────────────────────────────────────
type FormMode = 'create' | 'edit';
let formOpen = $state(false);
let formMode = $state<FormMode>('create');
let formUserId = $state<number | null>(null);
let formName = $state('');
let formEmail = $state('');
let formRoleId = $state<number | null>(null);
let formActive = $state(true);
let formPassword = $state('');
let formSaving = $state(false);
let formError = $state('');
function openCreate() {
formMode = 'create';
formUserId = null;
formName = '';
formEmail = '';
formRoleId = roles[0]?.id ?? null;
formActive = true;
formPassword = '';
formError = '';
formOpen = true;
}
function openEdit(user: InternalUser) {
formMode = 'edit';
formUserId = user.id;
formName = user.name;
formEmail = user.email;
formRoleId = user.role_id;
formActive = user.is_active;
formPassword = '';
formError = '';
formOpen = true;
}
function closeForm() {
if (formSaving) return;
formOpen = false;
}
const editingSelf = $derived(formMode === 'edit' && formUserId === currentUserId);
async function saveForm() {
formError = '';
const name = formName.trim();
const email = formEmail.trim().toLowerCase();
if (!name) {
formError = 'Name is required';
return;
}
if (!email || !email.includes('@')) {
formError = 'A valid email is required';
return;
}
if (formMode === 'create' && formPassword && formPassword.length < 8) {
formError = 'Password must be at least 8 characters';
return;
}
formSaving = true;
const tid = toast.loading(formMode === 'create' ? 'Creating user…' : 'Saving user…');
try {
if (formMode === 'create') {
await api.createAccessUser({
name,
email,
role_id: formRoleId,
is_active: formActive,
password: formPassword ? formPassword : null
});
} else if (formUserId != null) {
await api.updateAccessUser(formUserId, {
name,
email,
role_id: formRoleId,
is_active: formActive
});
}
toast.dismiss(tid);
toast.success(formMode === 'create' ? 'User created' : 'User updated');
formOpen = false;
await load();
} catch (err: unknown) {
toast.dismiss(tid);
const msg = err instanceof Error ? err.message : 'An error occurred';
formError = msg;
toast.error(msg);
} finally {
formSaving = false;
}
}
// ── Password reset modal ──────────────────────────────────────
let pwOpen = $state(false);
let pwUser = $state<InternalUser | null>(null);
let pwNew = $state('');
let pwConfirm = $state('');
let pwSaving = $state(false);
let pwError = $state('');
function openPassword(user: InternalUser) {
pwUser = user;
pwNew = '';
pwConfirm = '';
pwError = '';
pwOpen = true;
}
function closePassword() {
if (pwSaving) return;
pwOpen = false;
}
async function savePassword() {
pwError = '';
if (pwNew.length < 8) {
pwError = 'Password must be at least 8 characters';
return;
}
if (pwNew !== pwConfirm) {
pwError = 'Passwords do not match';
return;
}
if (!pwUser) return;
pwSaving = true;
const tid = toast.loading('Updating password…');
try {
await api.setAccessUserPassword(pwUser.id, pwNew);
toast.dismiss(tid);
toast.success(`Password updated for ${pwUser.name}`);
pwOpen = false;
} catch (err: unknown) {
toast.dismiss(tid);
const msg = err instanceof Error ? err.message : 'An error occurred';
pwError = msg;
toast.error(msg);
} finally {
pwSaving = false;
}
}
// ── Delete modal ──────────────────────────────────────────────
let deleteUser = $state<InternalUser | null>(null);
let deleting = $state(false);
function openDelete(user: InternalUser) {
deleteUser = user;
}
function closeDelete() {
if (deleting) return;
deleteUser = null;
}
async function confirmDelete() {
if (!deleteUser) return;
deleting = true;
const tid = toast.loading('Deleting user…');
try {
await api.deleteAccessUser(deleteUser.id);
toast.dismiss(tid);
toast.success(`Deleted ${deleteUser.name}`);
deleteUser = null;
await load();
} catch (err: unknown) {
toast.dismiss(tid);
toast.error(err instanceof Error ? err.message : 'Failed to delete user');
} finally {
deleting = false;
}
}
// ── Quick active toggle ───────────────────────────────────────
async function toggleActive(user: InternalUser) {
if (user.id === currentUserId) {
toast.error('You cannot deactivate your own account');
return;
}
const next = !user.is_active;
const tid = toast.loading(next ? 'Enabling access…' : 'Disabling access…');
try {
const updated = await api.updateAccessUser(user.id, { is_active: next });
users = users.map((u) => (u.id === user.id ? updated : u));
toast.dismiss(tid);
toast.success(next ? `${user.name} can sign in` : `${user.name}'s access is off`);
} catch (err: unknown) {
toast.dismiss(tid);
toast.error(err instanceof Error ? err.message : 'Failed to update access');
}
}
</script>
<div class="panel-section">
<header class="panel-header">
<div class="header-copy">
<h2>Users</h2>
<p>Manage who can sign in to the workspace, their role, and their access.</p>
</div>
<button type="button" class="btn-primary" onclick={openCreate}>
<UserPlus size={16} strokeWidth={2.2} /> Add user
</button>
</header>
{#if loading}
<p class="state-msg">Loading users…</p>
{:else if loadError}
<p class="state-msg error"><TriangleAlert size={15} strokeWidth={2.2} /> {loadError}</p>
{:else}
<div class="table-wrap">
<table class="users-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Access</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{#each users as user (user.id)}
<tr class:inactive={!user.is_active}>
<td>
<span class="user-name">{user.name}</span>
{#if user.id === currentUserId}<span class="you-chip">You</span>{/if}
{#if user.is_protected}
<span class="lean-chip" use:tooltip={'Lean owner, this account cannot be deleted'}>
<ShieldCheck size={12} strokeWidth={2.4} /> Lean
</span>
{/if}
</td>
<td class="email-cell">{user.email}</td>
<td>{user.role ?? '—'}</td>
<td>
<button
type="button"
class="status-toggle"
class:on={user.is_active}
disabled={user.id === currentUserId}
aria-label={user.id === currentUserId ? 'You cannot change your own access' : 'Toggle access'}
use:tooltip={user.id === currentUserId
? 'You cannot change your own access'
: user.is_active
? 'Turn sign-in access off'
: 'Turn sign-in access on'}
onclick={() => toggleActive(user)}
>
<span class="dot"></span>
{user.is_active ? 'Active' : 'Off'}
</button>
</td>
<td class="actions-col">
<div class="row-actions">
<button
type="button"
class="icon-btn"
aria-label="Edit user"
use:tooltip={'Edit user details'}
onclick={() => openEdit(user)}
>
<Pencil size={15} strokeWidth={2.1} />
</button>
<button
type="button"
class="icon-btn"
aria-label="Reset password"
use:tooltip={'Reset password'}
onclick={() => openPassword(user)}
>
<KeyRound size={15} strokeWidth={2.1} />
</button>
<button
type="button"
class="icon-btn danger"
aria-label="Delete user"
use:tooltip={user.is_protected
? 'Lean accounts cannot be deleted'
: user.id === currentUserId
? 'You cannot delete your own account'
: 'Delete user'}
disabled={user.is_protected || user.id === currentUserId}
onclick={() => openDelete(user)}
>
<Trash2 size={15} strokeWidth={2.1} />
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Create / edit modal -->
{#if formOpen}
<div class="modal-backdrop" role="presentation" onclick={closeForm}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="user-form-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeForm(); }}
>
<h2 id="user-form-title" class="modal-title">{formMode === 'create' ? 'Add user' : 'Edit user'}</h2>
<form class="modal-form" onsubmit={(e) => { e.preventDefault(); saveForm(); }}>
<div class="field">
<label for="uf-name">Full name</label>
<input id="uf-name" type="text" bind:value={formName} autocomplete="off" required />
</div>
<div class="field">
<label for="uf-email">Email address</label>
<input id="uf-email" type="email" bind:value={formEmail} autocomplete="off" required />
</div>
<div class="field">
<label for="uf-role">Role</label>
<select id="uf-role" bind:value={formRoleId}>
<option value={null}>No role (no access)</option>
{#each roles as role (role.id)}
<option value={role.id}>{role.name}</option>
{/each}
</select>
</div>
{#if formMode === 'create'}
<div class="field">
<label for="uf-pass">Initial password <span class="optional">(optional)</span></label>
<input id="uf-pass" type="password" bind:value={formPassword} autocomplete="new-password" placeholder="Leave blank to use the shared password" />
</div>
{/if}
<label class="check-row" class:disabled={editingSelf}>
<input type="checkbox" bind:checked={formActive} disabled={editingSelf} />
<span>Access enabled {#if editingSelf}<em>(you cannot disable your own access)</em>{/if}</span>
</label>
{#if formError}
<p class="form-error"><TriangleAlert size={15} strokeWidth={2.2} /> {formError}</p>
{/if}
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={closeForm} disabled={formSaving}>Cancel</button>
<button type="submit" class="btn-primary" disabled={formSaving}>
{formSaving ? 'Saving…' : formMode === 'create' ? 'Create user' : 'Save changes'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Password reset modal -->
{#if pwOpen && pwUser}
<div class="modal-backdrop" role="presentation" onclick={closePassword}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="pw-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closePassword(); }}
>
<div class="modal-icon"><KeyRound size={20} strokeWidth={2.2} /></div>
<h2 id="pw-title" class="modal-title">Reset password</h2>
<p class="modal-text">Set a new password for <strong>{pwUser.name}</strong>. They can change it later in their own settings.</p>
<form class="modal-form" onsubmit={(e) => { e.preventDefault(); savePassword(); }}>
<div class="field">
<label for="pw-new">New password</label>
<input id="pw-new" type="password" bind:value={pwNew} autocomplete="new-password" required />
</div>
<div class="field">
<label for="pw-confirm">Confirm password</label>
<input id="pw-confirm" type="password" bind:value={pwConfirm} autocomplete="new-password" required />
</div>
{#if pwError}
<p class="form-error"><TriangleAlert size={15} strokeWidth={2.2} /> {pwError}</p>
{/if}
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={closePassword} disabled={pwSaving}>Cancel</button>
<button type="submit" class="btn-primary" disabled={pwSaving}>
{pwSaving ? 'Updating…' : 'Set password'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Delete confirmation -->
{#if deleteUser}
<div class="modal-backdrop" role="presentation" onclick={closeDelete}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="del-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeDelete(); }}
>
<div class="modal-icon danger"><Trash2 size={20} strokeWidth={2.2} /></div>
<h2 id="del-title" class="modal-title">Delete user?</h2>
<p class="modal-text">
This permanently removes <strong>{deleteUser.name}</strong> ({deleteUser.email}) and their
access. This cannot be undone.
</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={closeDelete} disabled={deleting}>Cancel</button>
<button type="button" class="modal-confirm" onclick={confirmDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete user'}
</button>
</div>
</div>
</div>
{/if}
<style>
.panel-section {
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.5rem 1.75rem 1.25rem;
border-bottom: 1px solid var(--line);
}
.header-copy h2 {
margin: 0 0 0.3rem;
font-size: 1.1rem;
font-weight: 700;
}
.header-copy p {
margin: 0;
font-size: 0.85rem;
color: var(--muted);
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.45rem;
flex-shrink: 0;
padding: 0.55rem 1.1rem;
background: var(--color-brand);
color: #fff;
border: none;
border-radius: 0.6rem;
font-size: 0.86rem;
font-weight: 600;
cursor: pointer;
transition: opacity 140ms ease;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.88;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.state-msg {
padding: 1.5rem 1.75rem;
margin: 0;
font-size: 0.9rem;
color: var(--muted);
}
.state-msg.error {
display: flex;
align-items: center;
gap: 0.4rem;
color: #c53030;
}
/* ── Table ──────────────────────────────────────────────────── */
.table-wrap {
overflow-x: auto;
padding: 0.5rem 1.75rem 1.75rem;
}
.users-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.users-table th {
text-align: left;
padding: 0.7rem 0.75rem;
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
border-bottom: 1px solid var(--line);
}
.users-table td {
padding: 0.75rem;
border-bottom: 1px solid var(--line);
color: var(--text);
vertical-align: middle;
}
.users-table tr.inactive td {
color: var(--muted);
}
.user-name {
font-weight: 600;
}
.email-cell {
color: var(--muted);
}
.you-chip,
.lean-chip {
display: inline-flex;
align-items: center;
gap: 0.2rem;
margin-left: 0.4rem;
padding: 0.08rem 0.42rem;
border-radius: 0.5rem;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
vertical-align: middle;
}
.you-chip {
background: var(--panel-soft);
border: 1px solid var(--line);
color: var(--muted);
}
.lean-chip {
background: color-mix(in srgb, var(--color-brand) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-brand) 30%, transparent);
color: var(--color-brand);
}
.actions-col {
text-align: right;
white-space: nowrap;
}
.row-actions {
display: inline-flex;
gap: 0.3rem;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--panel);
color: var(--muted);
cursor: pointer;
transition: color 140ms ease, border-color 140ms ease, background-color 140ms ease;
}
.icon-btn:hover:not(:disabled) {
color: var(--text);
border-color: var(--color-brand);
}
.icon-btn.danger:hover:not(:disabled) {
color: #c53030;
border-color: color-mix(in srgb, #e53e3e 45%, transparent);
}
.icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.status-toggle {
display: inline-flex;
align-items: center;
gap: 0.42rem;
padding: 0.32rem 0.7rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-soft);
color: var(--muted);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: border-color 140ms ease, color 140ms ease;
}
.status-toggle .dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--muted);
}
.status-toggle.on {
color: var(--color-brand);
border-color: color-mix(in srgb, var(--color-brand) 35%, transparent);
}
.status-toggle.on .dot {
background: var(--color-brand);
}
.status-toggle:disabled {
cursor: not-allowed;
opacity: 0.7;
}
/* ── Modal ──────────────────────────────────────────────────── */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1.5rem;
background: color-mix(in srgb, var(--color-text-primary, #000) 32%, transparent);
backdrop-filter: blur(6px);
}
.modal-card {
width: min(30rem, 100%);
display: grid;
gap: 0.7rem;
padding: 1.6rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
}
.modal-card:focus {
outline: none;
}
.modal-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.6rem;
height: 2.6rem;
border-radius: 0.8rem;
background: color-mix(in srgb, var(--color-brand) 12%, transparent);
color: var(--color-brand);
}
.modal-icon.danger {
background: #fdecee;
color: #b3261e;
}
.modal-title {
margin: 0;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
}
.modal-text {
margin: 0;
font-size: 0.9rem;
line-height: 1.5;
color: var(--muted);
}
.modal-form {
display: grid;
gap: 0.85rem;
margin-top: 0.3rem;
}
.field {
display: grid;
gap: 0.4rem;
}
.field label {
font-size: 0.82rem;
font-weight: 600;
color: var(--text);
}
.field .optional {
font-weight: 400;
color: var(--muted);
}
.field input,
.field select {
width: 100%;
padding: 0.58rem 0.8rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--panel-soft);
color: var(--text);
font-size: 0.9rem;
box-sizing: border-box;
}
.field input:focus,
.field select:focus {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
}
.check-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--text);
cursor: pointer;
}
.check-row.disabled {
color: var(--muted);
cursor: not-allowed;
}
.check-row em {
color: var(--muted);
font-style: normal;
}
.form-error {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0;
padding: 0.6rem 0.8rem;
background: color-mix(in srgb, #e53e3e 8%, transparent);
border: 1px solid color-mix(in srgb, #e53e3e 25%, transparent);
border-radius: 0.55rem;
color: #c53030;
font-size: 0.83rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 0.4rem;
}
.modal-cancel,
.modal-confirm {
padding: 0.55rem 1.1rem;
border-radius: 0.6rem;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: background-color 150ms ease, opacity 150ms ease;
}
.modal-cancel {
background: var(--panel);
border: 1px solid var(--line);
color: var(--muted);
}
.modal-cancel:hover:not(:disabled) {
color: var(--text);
}
.modal-confirm {
background: #b3261e;
border: 1px solid #b3261e;
color: #fff;
}
.modal-confirm:hover:not(:disabled) {
background: #95201a;
}
.modal-confirm:disabled,
.modal-cancel:disabled {
opacity: 0.55;
cursor: not-allowed;
}
@media (max-width: 720px) {
.panel-header {
flex-direction: column;
align-items: flex-start;
}
}
</style>
+87
View File
@@ -419,6 +419,26 @@ export type EditorIngredientCreateInput = {
export type EditorIngredientUpdateInput = Partial<EditorIngredientCreateInput>;
export type EditorChangeFieldDelta = {
field: string;
label: string;
before: string | null;
after: string | null;
};
export type EditorChangeEvent = {
id: number;
entity_type: 'mix' | 'ingredient';
entity_id: number;
action: string;
actor_name: string;
actor_email: string;
actor_role: string | null;
summary: string;
changes: EditorChangeFieldDelta[];
created_at: string;
};
export type Scenario = {
id: number;
name: string;
@@ -592,6 +612,69 @@ export type LoginResponse = {
role_name?: string | null;
};
// Internal Hunter Stock Feeds user (the access-control system), as returned by
// /api/access/users. Distinct from the B2B ordering ClientUser accounts.
export type InternalUser = {
id: number;
email: string;
name: string;
is_active: boolean;
role: string | null;
role_id: number | null;
// Lean owner accounts: editable but never deletable.
is_protected: boolean;
};
export type InternalRoleOption = {
id: number;
name: string;
description: string | null;
};
export type InternalRoleModuleDefinition = {
key: string;
label: string;
description: string;
levels: string[];
};
export type InternalRole = {
id: number;
name: string;
description: string | null;
permissions: string[];
module_permissions: Record<string, string>;
is_protected: boolean;
user_count: number;
};
export type InternalRoleCreateInput = {
name: string;
description?: string | null;
module_permissions: Record<string, string>;
};
export type InternalRoleUpdateInput = {
name?: string;
description?: string | null;
module_permissions?: Record<string, string>;
};
export type InternalUserCreateInput = {
email: string;
name: string;
role_id?: number | null;
is_active?: boolean;
password?: string | null;
};
export type InternalUserUpdateInput = {
name?: string;
email?: string;
role_id?: number | null;
is_active?: boolean;
};
export type RawMaterialCreateInput = {
name: string;
supplier?: string | null;
@@ -716,6 +799,10 @@ export type ThroughputImportResult = {
errors: string[];
};
export type ThroughputDeleteAllResult = {
entries_deleted: number;
};
export type ThroughputEntryListParams = {
date_from?: string;
date_to?: string;
+19 -1
View File
@@ -2,6 +2,7 @@
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import ChangeHistoryModal from '$lib/components/editor/ChangeHistoryModal.svelte';
import SortHeader from '$lib/table/SortHeader.svelte';
import { TableController } from '$lib/table/table.svelte';
import type {
@@ -11,7 +12,7 @@
EditorMixUpdateInput,
RawMaterial
} from '$lib/types';
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Plus, Save, Search, X } from 'lucide-svelte';
import { ChevronLeft, ChevronRight, FlaskConical, History, ListFilter, Plus, Save, Search, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
let { data } = $props();
@@ -42,6 +43,9 @@
// rescales every row's kg from its %.
let totalReference = $state(0);
// The mix whose change history is open in the modal (null = closed).
let historyMix = $state<EditableRow | null>(null);
// Inline "create new mix" form state.
let creatingMix = $state(false);
let newMixClient = $state('');
@@ -531,6 +535,10 @@
<FlaskConical size={16} strokeWidth={2.2} />
{expandedMixId === row.id ? 'Close ingredients' : savingKey === `mix-load:${row.id}` ? 'Loading...' : 'Ingredients'}
</button>
<button class="clear-button" type="button" onclick={() => (historyMix = row)} aria-label={`History for ${row.name}`}>
<History size={16} strokeWidth={2.2} />
History
</button>
<button class="apply-button" type="button" disabled={!rowDirty(row) || savingKey === `row:${row.id}`} onclick={() => saveRow(row)}>
<Save size={16} strokeWidth={2.4} />
{savingKey === `row:${row.id}` ? 'Saving...' : 'Save mix'}
@@ -617,6 +625,16 @@
{/each}
</div>
</section>
{#if historyMix}
<ChangeHistoryModal
entityType="mix"
entityId={historyMix.id}
title={historyMix.name}
subtitle={historyMix.client_name}
onClose={() => (historyMix = null)}
/>
{/if}
</AppSecondaryRailLayout>
<style>
+18 -1
View File
@@ -2,11 +2,12 @@
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import ChangeHistoryModal from '$lib/components/editor/ChangeHistoryModal.svelte';
import SortHeader from '$lib/table/SortHeader.svelte';
import { TableController } from '$lib/table/table.svelte';
import { formatNumber } from '$lib/format';
import type { EditorIngredientRow } from '$lib/types';
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Plus, Save, Search, X } from 'lucide-svelte';
import { ChevronLeft, ChevronRight, FlaskConical, History, ListFilter, Plus, Save, Search, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
let { data } = $props();
@@ -37,6 +38,8 @@
let query = $state('');
let statusFilter = $state<'active' | 'archived' | 'all'>('active');
let savingKey = $state<string | null>(null);
// The ingredient whose change history is open in the modal (null = closed).
let historyIngredient = $state<EditableIngredient | null>(null);
$effect(() => {
if (rows.length === 0 && (data.ingredients as EditorIngredientRow[]).length > 0) {
@@ -380,6 +383,10 @@
</div>
<div class="row-actions">
<button class="clear-button" type="button" onclick={() => (historyIngredient = row)} aria-label={`History for ${row.name}`}>
<History size={16} strokeWidth={2.2} />
History
</button>
<button class="apply-button" type="button" disabled={!rowDirty(row) || savingKey === `row:${row.id}`} onclick={() => saveRow(row)}>
<Save size={16} strokeWidth={2.4} />
{savingKey === `row:${row.id}` ? 'Saving...' : 'Save'}
@@ -406,6 +413,16 @@
{/each}
</div>
</section>
{#if historyIngredient}
<ChangeHistoryModal
entityType="ingredient"
entityId={historyIngredient.id}
title={historyIngredient.name}
subtitle={historyIngredient.unit_of_measure}
onClose={() => (historyIngredient = null)}
/>
{/if}
</AppSecondaryRailLayout>
<style>
+318 -3
View File
@@ -2,18 +2,28 @@
import { api } from '$lib/api';
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import { clientSession } from '$lib/session';
import RoleManagementPanel from '$lib/components/settings/RoleManagementPanel.svelte';
import UserManagementPanel from '$lib/components/settings/UserManagementPanel.svelte';
import { clientSession, hasPermission } from '$lib/session';
import { canEditThroughput } from '$lib/workspace-access';
import { toast } from '$lib/toast';
import type { ThroughputImportResult } from '$lib/types';
import { CircleUserRound, LockKeyhole, Upload, FileSpreadsheet, TriangleAlert } from 'lucide-svelte';
import { CircleUserRound, LockKeyhole, Upload, FileSpreadsheet, TriangleAlert, Trash2, Users } from 'lucide-svelte';
type Section = 'profile' | 'security' | 'import';
type Section = 'profile' | 'security' | 'import' | 'users' | 'roles';
let activeSection = $state<Section>('profile');
// Only operators who can edit throughput see (and can use) the import tool.
const canImportThroughput = $derived(canEditThroughput($clientSession));
// Lean owners and admins (manage_users permission) get the User management
// section. Visibility is a convenience — every endpoint enforces the
// permission itself.
const canManageUsers = $derived(hasPermission($clientSession, 'manage_users'));
const canManageRoles = $derived(
$clientSession?.role === 'internal' && ['lean', 'admin'].includes($clientSession?.role_name?.toLowerCase() ?? '')
);
let name = $state($clientSession?.name ?? '');
let email = $state($clientSession?.email ?? '');
@@ -117,6 +127,47 @@
}
}
// ── Delete all throughput entries ─────────────────────────────
// A destructive maintenance action — clearing a bad import in one go rather
// than deleting runs one by one. Guarded behind a typed confirmation.
let showDeleteAll = $state(false);
let deleteAllConfirmText = $state('');
let deletingAll = $state(false);
let deleteAllResult = $state<number | null>(null);
function openDeleteAll() {
deleteAllConfirmText = '';
deleteAllResult = null;
showDeleteAll = true;
}
function cancelDeleteAll() {
if (deletingAll) return;
showDeleteAll = false;
deleteAllConfirmText = '';
}
async function confirmDeleteAll() {
if (deleteAllConfirmText.trim().toUpperCase() !== 'DELETE') return;
deletingAll = true;
const tid = toast.loading('Deleting all entries…');
try {
const result = await api.deleteAllThroughputEntries();
deleteAllResult = result.entries_deleted;
showDeleteAll = false;
deleteAllConfirmText = '';
toast.dismiss(tid);
toast.success(
`Deleted ${result.entries_deleted} ${result.entries_deleted === 1 ? 'entry' : 'entries'}`
);
} catch (err: unknown) {
toast.dismiss(tid);
toast.error(err instanceof Error ? err.message : 'Failed to delete entries');
} finally {
deletingAll = false;
}
}
// Build a small sample CSV in the browser so operators have a working header
// row to copy from. The backend matches these headers case-insensitively.
function downloadTemplate() {
@@ -171,6 +222,8 @@
{ id: 'profile', label: 'Profile', icon: CircleUserRound },
{ id: 'security', label: 'Security', icon: LockKeyhole },
...(canImportThroughput ? [{ id: 'import' as Section, label: 'Import', icon: Upload }] : []),
...(canManageUsers ? [{ id: 'users' as Section, label: 'Users', icon: Users }] : []),
...(canManageRoles ? [{ id: 'roles' as Section, label: 'Roles', icon: Users }] : []),
]);
const railGroups = $derived([{ items: navItems }]);
@@ -339,11 +392,81 @@
</div>
</div>
</div>
<div class="danger-zone">
<div class="danger-copy">
<h3><TriangleAlert size={16} strokeWidth={2.2} /> Delete all entries</h3>
<p>
Permanently remove every throughput entry. Products are kept, but all
packing runs are erased. This cannot be undone — use it to clear a bad
import before re-uploading.
</p>
{#if deleteAllResult !== null}
<p class="danger-result" role="status">
Deleted <strong>{deleteAllResult}</strong>
{deleteAllResult === 1 ? 'entry' : 'entries'}.
</p>
{/if}
</div>
<button type="button" class="btn-danger" onclick={openDeleteAll}>
<Trash2 size={15} strokeWidth={2.2} /> Delete all entries
</button>
</div>
</div>
{:else if activeSection === 'users' && canManageUsers}
<UserManagementPanel />
{:else if activeSection === 'roles' && canManageRoles}
<RoleManagementPanel />
{/if}
</div>
</AppSecondaryRailLayout>
{#if showDeleteAll}
<div class="modal-backdrop" role="presentation" onclick={cancelDeleteAll}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="delete-all-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') cancelDeleteAll(); }}
>
<div class="modal-icon"><Trash2 size={22} strokeWidth={2.2} /></div>
<h2 id="delete-all-title" class="modal-title">Delete all throughput entries?</h2>
<p class="modal-text">
This permanently removes <strong>every</strong> throughput entry for your
workspace. Products are kept, but the packing-run history cannot be recovered.
</p>
<label class="modal-confirm-field">
<span>Type <strong>DELETE</strong> to confirm</span>
<input
type="text"
bind:value={deleteAllConfirmText}
autocomplete="off"
spellcheck="false"
placeholder="DELETE"
/>
</label>
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={cancelDeleteAll} disabled={deletingAll}>
Cancel
</button>
<button
type="button"
class="modal-confirm"
disabled={deletingAll || deleteAllConfirmText.trim().toUpperCase() !== 'DELETE'}
onclick={confirmDeleteAll}
>
{deletingAll ? 'Deleting…' : 'Delete all entries'}
</button>
</div>
</div>
</div>
{/if}
<style>
.settings-panel {
display: flex;
@@ -622,6 +745,193 @@
overflow-y: auto;
}
/* ── Danger zone ────────────────────────────────────────────── */
.danger-zone {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.25rem;
margin: 0 1.75rem 1.75rem;
padding: 1.1rem 1.25rem;
border: 1px solid color-mix(in srgb, #e53e3e 30%, transparent);
border-radius: 0.75rem;
background: color-mix(in srgb, #e53e3e 5%, transparent);
}
.danger-copy {
min-width: 0;
}
.danger-copy h3 {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0 0 0.35rem;
font-size: 0.92rem;
font-weight: 700;
color: #c53030;
}
.danger-copy p {
margin: 0;
font-size: 0.83rem;
line-height: 1.5;
color: var(--muted);
max-width: 34rem;
}
.danger-result {
margin-top: 0.5rem !important;
color: var(--text) !important;
}
.btn-danger {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.58rem 1.1rem;
background: #b3261e;
color: #fff;
border: 1px solid #b3261e;
border-radius: 0.6rem;
font-size: 0.86rem;
font-weight: 600;
cursor: pointer;
transition: background-color 140ms ease;
}
.btn-danger:hover {
background: #95201a;
}
/* ── Confirmation modal ─────────────────────────────────────── */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1.5rem;
background: color-mix(in srgb, var(--color-text-primary, #000) 32%, transparent);
backdrop-filter: blur(6px);
}
.modal-card {
width: min(28rem, 100%);
display: grid;
gap: 0.7rem;
padding: 1.6rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
}
.modal-card:focus {
outline: none;
}
.modal-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.8rem;
height: 2.8rem;
border-radius: 0.8rem;
background: #fdecee;
color: #b3261e;
}
.modal-title {
margin: 0;
font-size: 1.2rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
}
.modal-text {
margin: 0;
font-size: 0.92rem;
line-height: 1.5;
color: var(--muted);
}
.modal-confirm-field {
display: grid;
gap: 0.4rem;
margin-top: 0.2rem;
}
.modal-confirm-field span {
font-size: 0.82rem;
color: var(--muted);
}
.modal-confirm-field input {
width: 100%;
padding: 0.55rem 0.8rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--panel-soft);
color: var(--text);
font-size: 0.9rem;
letter-spacing: 0.06em;
box-sizing: border-box;
}
.modal-confirm-field input:focus {
outline: none;
border-color: #b3261e;
box-shadow: 0 0 0 3px color-mix(in srgb, #b3261e 18%, transparent);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 0.55rem;
}
.modal-cancel,
.modal-confirm {
padding: 0.55rem 1.1rem;
border-radius: 0.6rem;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: background-color 150ms ease, border-color 150ms ease, opacity 150ms ease;
}
.modal-cancel {
background: var(--panel);
border: 1px solid var(--line);
color: var(--muted);
}
.modal-cancel:hover:not(:disabled) {
color: var(--text);
}
.modal-confirm {
background: #b3261e;
border: 1px solid #b3261e;
color: #fff;
}
.modal-confirm:hover:not(:disabled) {
background: #95201a;
}
.modal-confirm:disabled,
.modal-cancel:disabled {
opacity: 0.55;
cursor: not-allowed;
}
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 720px) {
@@ -632,5 +942,10 @@
.import-body {
grid-template-columns: 1fr;
}
.danger-zone {
flex-direction: column;
align-items: flex-start;
}
}
</style>