So many things

This commit is contained in:
2026-02-17 21:52:59 -06:00
parent 8e225aa254
commit a99c9f5102
27 changed files with 5398 additions and 123 deletions
+953
View File
@@ -0,0 +1,953 @@
<script lang="ts">
import { enhance } from "$app/forms";
import type {
PermissionsCategorized,
PermissionCategory,
PermissionNode,
} from "$lib/optima-api/modules/permissions";
import type { Role } from "$lib/optima-api/modules/roles";
export let isOpen = false;
export let permissionNodes: PermissionsCategorized = {};
export let roleToEdit: Role | null = null;
export let onClose: () => void = () => {};
export let onSuccess: () => void = () => {};
$: isEditMode = roleToEdit !== null;
$: if (isOpen && roleToEdit) {
title = roleToEdit.title;
moniker = roleToEdit.moniker;
monikerEdited = true;
selectedPermissions = new Set(roleToEdit.permissions);
// Auto-expand categories that contain selected permissions
const sel = new Set(roleToEdit.permissions);
const expanded = new Set<string>();
for (const [key, cat] of Object.entries(permissionNodes) as [
string,
PermissionCategory,
][]) {
if (cat.permissions.some((p) => sel.has(p.node))) {
expanded.add(key);
}
}
expandedCategories = expanded;
}
let title = "";
let moniker = "";
let monikerEdited = false;
let selectedPermissions = new Set<string>();
let permissionSearch = "";
let expandedCategories = new Set<string>();
let isSubmitting = false;
let submitError = "";
$: if (!monikerEdited) {
moniker = title
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
}
$: filteredEntries = (() => {
const entries = Object.entries(permissionNodes) as [
string,
PermissionCategory,
][];
if (!permissionSearch.trim()) return entries;
const q = permissionSearch.toLowerCase();
return entries
.map(([key, cat]): [string, PermissionCategory] => [
key,
{
...cat,
permissions: cat.permissions.filter(
(p: PermissionNode) =>
p.node.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q),
),
},
])
.filter(([, cat]) => cat.permissions.length > 0);
})();
$: isValid = title.trim().length > 0 && moniker.trim().length > 0;
$: selectedCount = selectedPermissions.size;
function togglePermission(node: string) {
const next = new Set(selectedPermissions);
next.has(node) ? next.delete(node) : next.add(node);
selectedPermissions = next;
}
function toggleCategory(key: string) {
const next = new Set(expandedCategories);
next.has(key) ? next.delete(key) : next.add(key);
expandedCategories = next;
}
function toggleAllInCategory(perms: PermissionNode[]) {
const next = new Set(selectedPermissions);
const allSelected = perms.every((p) => next.has(p.node));
perms.forEach((p) =>
allSelected ? next.delete(p.node) : next.add(p.node),
);
selectedPermissions = next;
}
function reset() {
title = "";
moniker = "";
monikerEdited = false;
selectedPermissions = new Set();
permissionSearch = "";
expandedCategories = new Set();
isSubmitting = false;
submitError = "";
}
function handleClose() {
reset();
onClose();
}
function handleBackdropClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains("modal-backdrop"))
handleClose();
}
function handleBackdropKeydown(e: KeyboardEvent) {
if (e.key === "Escape") handleClose();
}
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="modal-backdrop"
on:click={handleBackdropClick}
on:keydown={handleBackdropKeydown}
>
<div
class="modal"
role="dialog"
aria-modal="true"
aria-label={isEditMode ? "Edit Role" : "Create Role"}
tabindex="-1"
>
<div class="modal-header">
<div class="modal-title-group">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
<h2>{isEditMode ? "Edit Role" : "Create Role"}</h2>
</div>
<button
class="close-btn"
on:click={handleClose}
type="button"
aria-label="Close modal"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<form
method="POST"
action={isEditMode ? "?/updateRole" : "?/createRole"}
use:enhance={() => {
isSubmitting = true;
submitError = "";
return async ({ result, update }) => {
isSubmitting = false;
if (result.type === "success") {
reset();
onSuccess();
} else if (result.type === "failure") {
submitError = String(
(result.data as Record<string, unknown>)?.message ??
(isEditMode
? "Failed to update role."
: "Failed to create role."),
);
} else if (result.type === "error") {
submitError =
result.error?.message ?? "An unexpected error occurred.";
}
await update({ reset: false });
};
}}
>
{#each [...selectedPermissions] as node (node)}
<input type="hidden" name="permissions" value={node} />
{/each}
{#if isEditMode && roleToEdit}
<input type="hidden" name="id" value={roleToEdit.id} />
{/if}
<div class="modal-body">
{#if submitError}
<div class="error-banner">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
{submitError}
</div>
{/if}
<div class="form-row">
<div class="form-group">
<label for="role-title">Title <span class="req">*</span></label>
<input
id="role-title"
name="title"
type="text"
placeholder="e.g. Administrator"
bind:value={title}
required
/>
</div>
<div class="form-group">
<label for="role-moniker">
Moniker <span class="req">*</span>
{#if !isEditMode}<span class="label-hint">Auto-generated</span
>{/if}
</label>
<input
id="role-moniker"
name="moniker"
type="text"
placeholder="e.g. administrator"
bind:value={moniker}
on:input={() => (monikerEdited = true)}
required
/>
</div>
</div>
<div class="perm-section">
<div class="perm-section-header">
<span class="perm-label">Permissions</span>
{#if selectedCount > 0}
<span class="selected-badge">{selectedCount} selected</span>
{/if}
</div>
<div class="search-wrap">
<svg
class="search-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
class="search-input"
placeholder="Search permissions…"
bind:value={permissionSearch}
/>
{#if permissionSearch}
<button
type="button"
class="search-clear"
on:click={() => (permissionSearch = "")}
aria-label="Clear search"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
width="11"
height="11"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/if}
</div>
<div class="perm-list">
{#if Object.keys(permissionNodes).length === 0}
<p class="perm-empty">No permission data available.</p>
{:else if filteredEntries.length === 0}
<p class="perm-empty">No permissions match your search.</p>
{:else}
{#each filteredEntries as [catKey, category] (catKey)}
{@const catPerms = category.permissions}
{@const allSel =
catPerms.length > 0 &&
catPerms.every((p) => selectedPermissions.has(p.node))}
{@const someSel =
!allSel &&
catPerms.some((p) => selectedPermissions.has(p.node))}
{@const isExpanded =
permissionSearch.trim().length > 0 ||
expandedCategories.has(catKey)}
<div class="cat-group">
<div class="cat-header">
<button
type="button"
class="cat-toggle"
on:click={() => toggleCategory(catKey)}
aria-expanded={isExpanded}
>
<svg
class="chevron"
class:rotated={isExpanded}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
width="11"
height="11"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="cat-name">{category.name}</span>
<span class="cat-count">{catPerms.length}</span>
</button>
<button
type="button"
class="cat-all-btn"
on:click={() => toggleAllInCategory(catPerms)}
title={allSel
? "Deselect all in category"
: "Select all in category"}
>
<div
class="cb"
class:cb-checked={allSel}
class:cb-indeterminate={someSel}
>
{#if allSel}
<svg
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
stroke-width="2"
width="8"
height="8"
>
<polyline points="1.5 5 4 8 8.5 2" />
</svg>
{:else if someSel}
<svg
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
stroke-width="2.5"
width="8"
height="8"
>
<line x1="1.5" y1="5" x2="8.5" y2="5" />
</svg>
{/if}
</div>
All
</button>
</div>
{#if isExpanded}
<div class="cat-perms">
{#each catPerms as perm (perm.node)}
{@const sel = selectedPermissions.has(perm.node)}
<label class="perm-row" class:perm-sel={sel}>
<input
type="checkbox"
class="sr-only"
checked={sel}
on:change={() => togglePermission(perm.node)}
/>
<div class="cb" class:cb-checked={sel}>
{#if sel}
<svg
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
stroke-width="2"
width="8"
height="8"
>
<polyline points="1.5 5 4 8 8.5 2" />
</svg>
{/if}
</div>
<div class="perm-text">
<code class="perm-node">{perm.node}</code>
<span class="perm-desc">{perm.description}</span>
</div>
</label>
{/each}
</div>
{/if}
</div>
{/each}
{/if}
</div>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn-cancel"
on:click={handleClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
type="submit"
class="btn-create"
disabled={!isValid || isSubmitting}
>
{isSubmitting
? isEditMode
? "Saving…"
: "Creating…"
: isEditMode
? "Save Changes"
: "Create Role"}
</button>
</div>
</form>
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
width: 90%;
max-width: 600px;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
animation: modalIn 0.15s ease;
}
@keyframes modalIn {
from {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* ── Header ── */
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px 16px;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.modal-title-group {
display: flex;
align-items: center;
gap: 8px;
}
.modal-title-group svg {
color: var(--text-secondary);
flex-shrink: 0;
}
.modal-title-group h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.close-btn {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: var(--text-muted);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition:
background 0.15s,
color 0.15s;
}
.close-btn:hover {
background: var(--card-hover-bg);
color: var(--text-primary);
}
/* ── Form wraps body + footer so the submit btn works ── */
form {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* ── Body ── */
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
}
/* ── Error banner ── */
.error-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: rgba(220, 38, 38, 0.08);
border: 1px solid rgba(220, 38, 38, 0.2);
border-radius: 7px;
font-size: 13px;
color: #dc2626;
flex-shrink: 0;
}
/* ── Form fields ── */
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 11.5px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.req {
color: var(--accent-color, #0066cc);
}
.label-hint {
font-size: 11px;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
color: var(--text-muted);
}
.form-group input {
padding: 8px 10px;
background: var(--bg-inset);
border: 1px solid var(--border-subtle);
border-radius: 7px;
font-size: 13px;
color: var(--text-primary);
outline: none;
width: 100%;
box-sizing: border-box;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.form-group input::placeholder {
color: var(--text-muted);
}
.form-group input:focus {
border-color: var(--accent-color, #0066cc);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.12);
}
/* ── Permissions section ── */
.perm-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.perm-section-header {
display: flex;
align-items: center;
gap: 8px;
}
.perm-label {
font-size: 11.5px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.selected-badge {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 10px;
background: var(--accent-color, #0066cc);
color: #fff;
}
/* ── Search ── */
.search-wrap {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 9px;
color: var(--text-muted);
pointer-events: none;
}
.search-input {
width: 100%;
padding: 7px 10px 7px 29px;
background: var(--bg-inset);
border: 1px solid var(--border-subtle);
border-radius: 7px;
font-size: 13px;
color: var(--text-primary);
outline: none;
box-sizing: border-box;
transition: border-color 0.15s;
}
.search-input::placeholder {
color: var(--text-muted);
}
.search-input:focus {
border-color: var(--accent-color, #0066cc);
}
.search-clear {
position: absolute;
right: 8px;
background: none;
border: none;
padding: 3px;
cursor: pointer;
color: var(--text-muted);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.1s;
}
.search-clear:hover {
color: var(--text-primary);
}
/* ── Permission list ── */
.perm-list {
border: 1px solid var(--border-subtle);
border-radius: 8px;
overflow-y: auto;
max-height: 240px;
background: var(--bg-inset);
}
.perm-empty {
margin: 0;
padding: 20px;
font-size: 13px;
color: var(--text-muted);
text-align: center;
}
/* ── Category group ── */
.cat-group {
border-bottom: 1px solid var(--border-subtle);
}
.cat-group:last-child {
border-bottom: none;
}
.cat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-right: 10px;
}
.cat-toggle {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
padding: 7px 10px;
background: none;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.cat-toggle:hover {
background: var(--card-hover-bg);
}
.chevron {
color: var(--text-muted);
flex-shrink: 0;
transition: transform 0.15s ease;
}
.chevron.rotated {
transform: rotate(90deg);
}
.cat-name {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.cat-count {
font-size: 11px;
color: var(--text-muted);
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
padding: 1px 6px;
border-radius: 8px;
}
.cat-all-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
background: none;
border: 1px solid var(--border-subtle);
border-radius: 5px;
cursor: pointer;
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
flex-shrink: 0;
transition:
background 0.1s,
border-color 0.1s;
}
.cat-all-btn:hover {
background: var(--card-hover-bg);
border-color: var(--border-default);
}
/* ── Custom checkbox ── */
.cb {
width: 13px;
height: 13px;
border: 1.5px solid var(--border-default);
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: var(--bg-surface);
color: #fff;
transition:
background 0.1s,
border-color 0.1s;
}
.cb.cb-checked {
background: var(--accent-color, #0066cc);
border-color: var(--accent-color, #0066cc);
}
.cb.cb-indeterminate {
background: var(--bg-surface);
border-color: var(--accent-color, #0066cc);
color: var(--accent-color, #0066cc);
}
/* ── Permission rows ── */
.cat-perms {
padding: 2px 0 4px;
border-top: 1px solid var(--border-subtle);
}
.perm-row {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 6px 12px 6px 26px;
cursor: pointer;
transition: background 0.1s;
}
.perm-row:hover {
background: var(--card-hover-bg);
}
.perm-row.perm-sel {
background: rgba(0, 102, 204, 0.05);
}
.perm-row .cb {
margin-top: 1px;
}
.perm-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.perm-node {
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 11.5px;
font-weight: 500;
color: var(--text-primary);
background: none;
padding: 0;
}
.perm-desc {
font-size: 11px;
color: var(--text-muted);
line-height: 1.4;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* ── Footer ── */
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 14px 20px;
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.btn-cancel,
.btn-create {
padding: 7px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.btn-cancel {
background: var(--bg-inset);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
}
.btn-cancel:hover:not(:disabled) {
background: var(--card-hover-bg);
border-color: var(--border-default);
color: var(--text-primary);
}
.btn-create {
background: var(--accent-color, #0066cc);
border: 1px solid transparent;
color: #fff;
}
.btn-create:hover:not(:disabled) {
filter: brightness(1.1);
}
.btn-cancel:disabled,
.btn-create:disabled {
opacity: 0.45;
cursor: not-allowed;
}
</style>