v0.1.27
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:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hunter-app",
|
||||
"version": "0.1.26",
|
||||
"version": "0.1.27",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hunter-app",
|
||||
"version": "0.1.26",
|
||||
"version": "0.1.27",
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"lucide-svelte": "^1.0.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hunter-app",
|
||||
"version": "0.1.26",
|
||||
"version": "0.1.27",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user