refactor: extract reusable UI components and shared utilities

This commit is contained in:
2026-03-12 22:47:06 -05:00
parent 7ffbd98f2e
commit e74611cd96
18 changed files with 2211 additions and 2873 deletions
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
/** Custom message. Defaults to a generic "contact your administrator" message. */
export let message: string =
"You don't have permission to view this page. Contact your administrator to request access.";
/** Optional title override. Defaults to "Access Denied". */
export let title: string = "Access Denied";
</script>
<div class="access-denied-wrap">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
<h3>{title}</h3>
<p>{message}</p>
</div>
<style>
.access-denied-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 12px;
}
.access-denied-wrap svg {
width: 40px;
height: 40px;
color: var(--status-inactive-color, #ef4444);
}
.access-denied-wrap h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.access-denied-wrap p {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
text-align: center;
max-width: 360px;
}
</style>
+315 -501
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { credential } from "$lib/optima-api/modules/credentials"; import { credential } from "$lib/optima-api/modules/credentials";
import ModalShell from "./ModalShell.svelte";
import type { import type {
CredentialType, CredentialType,
CredentialTypeField, CredentialTypeField,
@@ -179,475 +180,335 @@
isOpen = false; isOpen = false;
} }
function handleBackdropClick() {
handleClose();
}
function handleBackdropKeydown(e: KeyboardEvent) {
if (e.key === "Escape") handleClose();
}
$: if (isOpen) { $: if (isOpen) {
loadCredentialTypes(); loadCredentialTypes();
} }
</script> </script>
{#if isOpen} <ModalShell {isOpen} title="Create Credential" onClose={handleClose}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <svelte:fragment slot="icon">
<div <svg
class="modal-backdrop" viewBox="0 0 24 24"
on:click={handleBackdropClick} fill="none"
on:keydown={handleBackdropKeydown} stroke="currentColor"
> stroke-width="2"
<!-- svelte-ignore a11y_no_static_element_interactions --> width="18"
<!-- svelte-ignore a11y_click_events_have_key_events --> height="18"
<div
class="modal"
role="dialog"
aria-modal="true"
aria-label="Create Credential"
tabindex="-1"
on:click|stopPropagation
> >
<div class="modal-header"> <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<div class="modal-title-group"> <path d="M7 11V7a5 5 0 0 1 10 0v4" />
<svg </svg>
viewBox="0 0 24 24" </svelte:fragment>
fill="none"
stroke="currentColor" <div class="modal-body">
stroke-width="2" {#if submitError}
width="18" <div class="error-banner">
height="18" <svg
> viewBox="0 0 24 24"
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /> fill="none"
<path d="M7 11V7a5 5 0 0 1 10 0v4" /> stroke="currentColor"
</svg> stroke-width="2"
<h2>Create Credential</h2> width="14"
</div> height="14"
<button
class="close-btn"
on:click={handleClose}
type="button"
aria-label="Close modal"
> >
<svg <circle cx="12" cy="12" r="10" />
viewBox="0 0 24 24" <line x1="12" y1="8" x2="12" y2="12" />
fill="none" <line x1="12" y1="16" x2="12.01" y2="16" />
stroke="currentColor" </svg>
stroke-width="2" {submitError}
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> </div>
{/if}
<div class="modal-body"> <div class="form-row">
{#if submitError} <div class="form-group">
<div class="error-banner"> <label for="credential-name">
<svg Name <span class="req">*</span>
viewBox="0 0 24 24" </label>
fill="none" <input
stroke="currentColor" id="credential-name"
stroke-width="2" type="text"
width="14" bind:value={credentialName}
height="14" placeholder="e.g. Production AWS Credentials"
>
<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="credential-name">
Name <span class="req">*</span>
</label>
<input
id="credential-name"
type="text"
bind:value={credentialName}
placeholder="e.g. Production AWS Credentials"
disabled={isSubmitting}
/>
</div>
<div class="form-group">
<label for="credential-type">
Credential Type <span class="req">*</span>
</label>
{#if isLoadingTypes}
<div class="loading-wrap">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
class="spin-icon"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
fill="none"
opacity="0.25"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
/>
</svg>
<span>Loading types…</span>
</div>
{:else}
<select
id="credential-type"
bind:value={selectedTypeId}
disabled={isSubmitting}
>
<option value="">Select a type</option>
{#each credentialTypes as type (type.id)}
<option value={type.id}>{type.name}</option>
{/each}
</select>
{/if}
</div>
</div>
<div class="form-group">
<label for="credential-notes">Notes</label>
<textarea
id="credential-notes"
bind:value={credentialNotes}
placeholder="Optional notes about this credential"
disabled={isSubmitting}
rows="2"
class="notes-textarea"
></textarea>
</div>
{#if selectedType && selectedType.fields && selectedType.fields.length > 0}
<div class="fields-section">
<div class="fields-section-header">
<span class="fields-label">Credential Fields</span>
<span class="fields-badge">
{selectedType.fields.length} field{selectedType.fields
.length === 1
? ""
: "s"}
</span>
</div>
<div class="fields-list">
{#each selectedType.fields as field (field.id)}
<div class="field-card">
<div class="field-card-header">
<span class="field-name-label">{field.name}</span>
<div class="field-header-badges">
{#if field.valueType === "multi_credential"}
<span class="field-badge field-badge-multi">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="10"
height="10"
>
<rect x="2" y="3" width="20" height="6" rx="1" />
<rect x="2" y="15" width="20" height="6" rx="1" />
<path d="M12 9v6" />
</svg>
Multi
</span>
{/if}
{#if field.required}
<span class="field-badge field-badge-required"
>Required</span
>
{/if}
{#if field.secure}
<span class="field-badge field-badge-secure">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="10"
height="10"
>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
ry="2"
/>
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
Secure
</span>
{/if}
</div>
</div>
<div class="field-card-body">
{#if field.valueType === "multi_credential" && field.subFields}
<!-- Multi-credential entries -->
<div class="multi-cred-section">
{#if (subCredentials[field.id] ?? []).length === 0}
<p class="multi-cred-empty">
No entries yet. Add an entry to define
sub-credentials for this field.
</p>
{:else}
{#each subCredentials[field.id] as entry, entryIdx}
<div class="multi-cred-entry">
<div class="multi-cred-entry-header">
<span class="multi-cred-entry-num"
>#{entryIdx + 1}</span
>
<button
type="button"
class="multi-cred-entry-remove"
on:click={() =>
removeSubCredentialEntry(
field.id,
entryIdx,
)}
disabled={isSubmitting}
aria-label="Remove entry"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<line x1="18" y1="6" x2="6" y2="18" /><line
x1="6"
y1="6"
x2="18"
y2="18"
/>
</svg>
</button>
</div>
<div class="multi-cred-entry-body">
<div class="multi-cred-entry-field">
<label
for="sub-cred-name-{field.id}-{entryIdx}"
>Entry Name <span class="req">*</span
></label
>
<input
id="sub-cred-name-{field.id}-{entryIdx}"
type="text"
bind:value={entry.name}
placeholder="e.g. Tunnel 1"
disabled={isSubmitting}
/>
</div>
{#each field.subFields as subField (subField.id)}
<div class="multi-cred-entry-field">
<label
for="sub-cred-{field.id}-{entryIdx}-{subField.id}"
>
{subField.name}
{#if subField.required}<span class="req"
>*</span
>{/if}
{#if subField.secure}
<svg
class="sub-cred-lock"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="10"
height="10"
>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
ry="2"
/>
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
{/if}
</label>
<input
id="sub-cred-{field.id}-{entryIdx}-{subField.id}"
type="text"
bind:value={entry.fields[subField.id]}
placeholder="Enter {subField.name.toLowerCase()}"
disabled={isSubmitting}
/>
</div>
{/each}
</div>
</div>
{/each}
{/if}
<button
type="button"
class="multi-cred-add-btn"
on:click={() =>
addSubCredentialEntry(
field.id,
field.subFields ?? [],
)}
disabled={isSubmitting}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<line x1="12" y1="5" x2="12" y2="19" /><line
x1="5"
y1="12"
x2="19"
y2="12"
/>
</svg>
Add Entry
</button>
</div>
{:else}
<input
id="field-{field.id}"
type="text"
bind:value={fieldValues[field.id]}
placeholder="Enter {field.name.toLowerCase()}"
disabled={isSubmitting}
/>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
<div class="modal-footer">
<button
type="button"
class="btn-cancel"
on:click={handleClose}
disabled={isSubmitting} disabled={isSubmitting}
> />
Cancel </div>
</button> <div class="form-group">
<button <label for="credential-type">
type="button" Credential Type <span class="req">*</span>
class="btn-submit" </label>
on:click={handleSubmit} {#if isLoadingTypes}
disabled={!isValid || isSubmitting} <div class="loading-wrap">
> <svg viewBox="0 0 24 24" width="14" height="14" class="spin-icon">
{isSubmitting ? "Creating…" : "Create Credential"} <circle
</button> cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
fill="none"
opacity="0.25"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
/>
</svg>
<span>Loading types…</span>
</div>
{:else}
<select
id="credential-type"
bind:value={selectedTypeId}
disabled={isSubmitting}
>
<option value="">Select a type</option>
{#each credentialTypes as type (type.id)}
<option value={type.id}>{type.name}</option>
{/each}
</select>
{/if}
</div> </div>
</div> </div>
<div class="form-group">
<label for="credential-notes">Notes</label>
<textarea
id="credential-notes"
bind:value={credentialNotes}
placeholder="Optional notes about this credential"
disabled={isSubmitting}
rows="2"
class="notes-textarea"
></textarea>
</div>
{#if selectedType && selectedType.fields && selectedType.fields.length > 0}
<div class="fields-section">
<div class="fields-section-header">
<span class="fields-label">Credential Fields</span>
<span class="fields-badge">
{selectedType.fields.length} field{selectedType.fields.length === 1
? ""
: "s"}
</span>
</div>
<div class="fields-list">
{#each selectedType.fields as field (field.id)}
<div class="field-card">
<div class="field-card-header">
<span class="field-name-label">{field.name}</span>
<div class="field-header-badges">
{#if field.valueType === "multi_credential"}
<span class="field-badge field-badge-multi">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="10"
height="10"
>
<rect x="2" y="3" width="20" height="6" rx="1" />
<rect x="2" y="15" width="20" height="6" rx="1" />
<path d="M12 9v6" />
</svg>
Multi
</span>
{/if}
{#if field.required}
<span class="field-badge field-badge-required"
>Required</span
>
{/if}
{#if field.secure}
<span class="field-badge field-badge-secure">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="10"
height="10"
>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
ry="2"
/>
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
Secure
</span>
{/if}
</div>
</div>
<div class="field-card-body">
{#if field.valueType === "multi_credential" && field.subFields}
<!-- Multi-credential entries -->
<div class="multi-cred-section">
{#if (subCredentials[field.id] ?? []).length === 0}
<p class="multi-cred-empty">
No entries yet. Add an entry to define sub-credentials
for this field.
</p>
{:else}
{#each subCredentials[field.id] as entry, entryIdx}
<div class="multi-cred-entry">
<div class="multi-cred-entry-header">
<span class="multi-cred-entry-num"
>#{entryIdx + 1}</span
>
<button
type="button"
class="multi-cred-entry-remove"
on:click={() =>
removeSubCredentialEntry(field.id, entryIdx)}
disabled={isSubmitting}
aria-label="Remove entry"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<line x1="18" y1="6" x2="6" y2="18" /><line
x1="6"
y1="6"
x2="18"
y2="18"
/>
</svg>
</button>
</div>
<div class="multi-cred-entry-body">
<div class="multi-cred-entry-field">
<label for="sub-cred-name-{field.id}-{entryIdx}"
>Entry Name <span class="req">*</span></label
>
<input
id="sub-cred-name-{field.id}-{entryIdx}"
type="text"
bind:value={entry.name}
placeholder="e.g. Tunnel 1"
disabled={isSubmitting}
/>
</div>
{#each field.subFields as subField (subField.id)}
<div class="multi-cred-entry-field">
<label
for="sub-cred-{field.id}-{entryIdx}-{subField.id}"
>
{subField.name}
{#if subField.required}<span class="req"
>*</span
>{/if}
{#if subField.secure}
<svg
class="sub-cred-lock"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="10"
height="10"
>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
ry="2"
/>
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
{/if}
</label>
<input
id="sub-cred-{field.id}-{entryIdx}-{subField.id}"
type="text"
bind:value={entry.fields[subField.id]}
placeholder="Enter {subField.name.toLowerCase()}"
disabled={isSubmitting}
/>
</div>
{/each}
</div>
</div>
{/each}
{/if}
<button
type="button"
class="multi-cred-add-btn"
on:click={() =>
addSubCredentialEntry(field.id, field.subFields ?? [])}
disabled={isSubmitting}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<line x1="12" y1="5" x2="12" y2="19" /><line
x1="5"
y1="12"
x2="19"
y2="12"
/>
</svg>
Add Entry
</button>
</div>
{:else}
<input
id="field-{field.id}"
type="text"
bind:value={fieldValues[field.id]}
placeholder="Enter {field.name.toLowerCase()}"
disabled={isSubmitting}
/>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div> </div>
{/if}
<div class="modal-footer">
<button
type="button"
class="btn-cancel"
on:click={handleClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
type="button"
class="btn-submit"
on:click={handleSubmit}
disabled={!isValid || isSubmitting}
>
{isSubmitting ? "Creating…" : "Create Credential"}
</button>
</div>
</ModalShell>
<style> <style>
/* ── Backdrop ── */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: backdropIn 0.12s ease;
}
/* ── Modal shell ── */
.modal {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 14px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.28);
width: 92%;
max-width: 540px;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
animation: modalIn 0.15s ease;
}
/* ── Header ── */
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px 14px;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.modal-title-group {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
}
.modal-title-group h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.modal-title-group svg {
color: var(--text-muted);
flex-shrink: 0;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
background: none;
border: 1px solid transparent;
border-radius: 7px;
cursor: pointer;
color: var(--text-muted);
transition:
background 0.12s,
border-color 0.12s,
color 0.12s;
}
.close-btn:hover {
background: var(--card-hover-bg);
border-color: var(--border-subtle);
color: var(--text-primary);
}
/* ── Body ── */ /* ── Body ── */
.modal-body { .modal-body {
padding: 18px 22px; padding: 18px 22px;
@@ -1053,25 +914,6 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* ── Animations ── */
@keyframes backdropIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modalIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes spin { @keyframes spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
@@ -1080,38 +922,10 @@
/* ── Mobile fixes ── */ /* ── Mobile fixes ── */
@media (max-width: 768px) { @media (max-width: 768px) {
.modal-backdrop {
align-items: flex-end;
}
.modal {
width: 100%;
max-width: 100%;
max-height: 92vh;
border-radius: 14px 14px 0 0;
transform: none;
animation: modalSheetIn 0.3s cubic-bezier(0.32, 0.72, 0, 1);
}
@keyframes modalSheetIn {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-body { .modal-body {
padding: 14px 16px; padding: 14px 16px;
} }
.modal-header {
padding: 14px 16px 12px;
}
.modal-footer { .modal-footer {
padding: 12px 16px; padding: 12px 16px;
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+206
View File
@@ -0,0 +1,206 @@
<script lang="ts">
import { enhance } from "$app/forms";
import type { SubmitFunction } from "@sveltejs/kit";
/** Controls visibility. */
export let isOpen = false;
/** Dialog heading, e.g. "Delete User". */
export let title: string;
/** The ID value sent as a hidden input named "id". */
export let idValue: string;
/** The form ?/action target, e.g. "?/deleteUser". */
export let formAction: string;
/** Label for the submit button, e.g. "Delete User". */
export let confirmLabel: string = "Delete";
/** Whether the deletion request is in flight. */
export let isDeleting = false;
/** Error message to display under the body. */
export let error = "";
/** Called when the user cancels or presses Escape. */
export let onCancel: () => void = () => {};
/**
* Called by the SvelteKit enhance action when the form completes.
* If omitted the default SvelteKit behaviour is used.
*/
export let handleEnhance: SubmitFunction | undefined = undefined;
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="confirm-backdrop"
on:click={onCancel}
on:keydown={(e) => e.key === "Escape" && onCancel()}
>
<div
class="confirm-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
tabindex="-1"
on:click|stopPropagation
on:keydown|stopPropagation
>
<div class="confirm-icon-wrap">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
width="22"
height="22"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
</div>
<h3 id="confirm-title" class="confirm-title">{title}</h3>
<!-- Body: use a slot so callers can insert custom warning content -->
<p class="confirm-body">
<slot />
</p>
{#if error}
<p class="confirm-error">{error}</p>
{/if}
<div class="confirm-actions">
<button
type="button"
class="btn-cancel"
on:click={onCancel}
disabled={isDeleting}
>
Cancel
</button>
<form
method="POST"
action={formAction}
use:enhance={handleEnhance ?? (() => {})}
>
<input type="hidden" name="id" value={idValue} />
<button
type="submit"
class="btn-delete-confirm"
disabled={isDeleting}
>
{isDeleting ? "Deleting…" : confirmLabel}
</button>
</form>
</div>
</div>
</div>
{/if}
<style>
.confirm-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(2px);
}
.confirm-dialog {
background: var(--surface-primary, #1e1e2e);
border: 1px solid var(--border-primary, rgba(255, 255, 255, 0.08));
border-radius: 14px;
padding: 28px 28px 24px;
width: 100%;
max-width: 400px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
text-align: center;
}
.confirm-icon-wrap {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(239, 68, 68, 0.12);
display: flex;
align-items: center;
justify-content: center;
color: #ef4444;
margin-bottom: 4px;
}
.confirm-title {
margin: 0;
font-size: 16px;
font-weight: 650;
color: var(--text-primary);
}
.confirm-body {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.confirm-error {
margin: 0;
font-size: 12px;
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 6px;
padding: 6px 10px;
width: 100%;
}
.confirm-actions {
display: flex;
gap: 8px;
margin-top: 8px;
width: 100%;
justify-content: flex-end;
}
.btn-cancel,
.btn-delete-confirm {
padding: 7px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 550;
cursor: pointer;
transition: all 0.15s;
}
.btn-cancel {
background: var(--surface-secondary, rgba(255, 255, 255, 0.06));
border: 1px solid var(--border-primary, rgba(255, 255, 255, 0.08));
color: var(--text-primary);
}
.btn-cancel:hover:not(:disabled) {
background: var(--surface-hover, rgba(255, 255, 255, 0.1));
}
.btn-delete-confirm {
background: #ef4444;
border: 1px solid transparent;
color: #fff;
}
.btn-delete-confirm:hover:not(:disabled) {
background: #dc2626;
}
.btn-cancel:disabled,
.btn-delete-confirm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
+5 -37
View File
@@ -8,6 +8,11 @@
PermissionCategory, PermissionCategory,
PermissionNode, PermissionNode,
} from "$lib/optima-api/modules/permissions"; } from "$lib/optima-api/modules/permissions";
import {
collectAllNodes,
collectAllPermNodes,
allSelectableStrings,
} from "$lib/permissions";
type UserWithRoles = User & { roleDetails: Role[] }; type UserWithRoles = User & { roleDetails: Role[] };
@@ -39,43 +44,6 @@
// Expanded category state for the hierarchical permission view // Expanded category state for the hierarchical permission view
let expandedCategories = new Set<string>(); let expandedCategories = new Set<string>();
/** Recursively collect all permission node strings from a category tree,
* including field-level permission strings attached to each node. */
function collectAllNodes(cat: PermissionCategory): string[] {
const nodes: string[] = [];
for (const p of cat.permissions ?? []) {
nodes.push(p.node);
if (p.fieldLevelPermissions) nodes.push(...p.fieldLevelPermissions);
}
if (cat.subCategories) {
for (const sub of Object.values(cat.subCategories)) {
nodes.push(...collectAllNodes(sub as PermissionCategory));
}
}
return nodes;
}
/** Recursively collect all PermissionNode objects from a category tree. */
function collectAllPermNodes(cat: PermissionCategory): PermissionNode[] {
const nodes = [...(cat.permissions ?? [])];
if (cat.subCategories) {
for (const sub of Object.values(cat.subCategories)) {
nodes.push(...collectAllPermNodes(sub as PermissionCategory));
}
}
return nodes;
}
/** Collect all selectable strings (node + fieldLevelPermissions) from PermissionNode[]. */
function allSelectableStrings(perms: PermissionNode[]): string[] {
const out: string[] = [];
for (const p of perms) {
out.push(p.node);
if (p.fieldLevelPermissions) out.push(...p.fieldLevelPermissions);
}
return out;
}
$: allPermissionNodeStrings = Object.values(permissionNodes ?? {}).flatMap( $: allPermissionNodeStrings = Object.values(permissionNodes ?? {}).flatMap(
(cat) => collectAllNodes(cat as PermissionCategory), (cat) => collectAllNodes(cat as PermissionCategory),
); );
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
/** Controls visibility. */
export let isOpen = false;
/** Modal heading text. */
export let title: string;
/** aria-label for the dialog element. Defaults to `title`. */
export let ariaLabel: string = "";
/** Called when the backdrop is clicked or Escape is pressed. */
export let onClose: () => void = () => {};
function handleBackdropClick() {
onClose();
}
function handleBackdropKeydown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="modal-backdrop"
on:click={handleBackdropClick}
on:keydown={handleBackdropKeydown}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="modal"
role="dialog"
aria-modal="true"
aria-label={ariaLabel || title}
tabindex="-1"
on:click|stopPropagation
>
<div class="modal-header">
<div class="modal-title-group">
<slot name="icon" />
<h2>{title}</h2>
</div>
<button
class="close-btn"
on:click={onClose}
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>
<slot />
{#if $$slots.footer}
<div class="modal-footer">
<slot name="footer" />
</div>
{/if}
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 900;
backdrop-filter: blur(3px);
padding: 20px;
}
.modal {
background: var(--surface-primary, #1e1e2e);
border: 1px solid var(--border-primary, rgba(255, 255, 255, 0.08));
border-radius: 16px;
width: 100%;
max-width: 620px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.55);
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--border-primary, rgba(255, 255, 255, 0.06));
flex-shrink: 0;
}
.modal-title-group {
display: flex;
align-items: center;
gap: 10px;
}
.modal-title-group :global(svg) {
color: var(--text-secondary);
flex-shrink: 0;
}
.modal-title-group h2 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: var(--text-primary);
}
.close-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-tertiary, rgba(255, 255, 255, 0.4));
padding: 4px;
border-radius: 6px;
display: flex;
align-items: center;
transition:
color 0.15s,
background 0.15s;
flex-shrink: 0;
}
.close-btn:hover {
color: var(--text-primary);
background: var(--surface-hover, rgba(255, 255, 255, 0.06));
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-primary, rgba(255, 255, 255, 0.06));
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
}
</style>
+116
View File
@@ -0,0 +1,116 @@
<script lang="ts">
import { getPageNumbers } from "$lib/utils";
export let currentPage: number;
export let totalPages: number;
/** Called with the target page number when the user clicks a page control. */
export let onNavigate: (page: number) => void;
$: pageNumbers = getPageNumbers(currentPage, totalPages);
</script>
{#if totalPages > 1}
<nav class="pagination" aria-label="Pagination">
<button
class="page-btn"
disabled={currentPage <= 1}
on:click={() => onNavigate(currentPage - 1)}
aria-label="Previous page"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
{#each pageNumbers as p}
{#if p === "..."}
<span class="page-ellipsis"></span>
{:else}
<button
class="page-btn"
class:active={p === currentPage}
on:click={() => onNavigate(p as number)}
aria-current={p === currentPage ? "page" : undefined}
>
{p}
</button>
{/if}
{/each}
<button
class="page-btn"
disabled={currentPage >= totalPages}
on:click={() => onNavigate(currentPage + 1)}
aria-label="Next page"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</nav>
{/if}
<style>
.pagination {
display: flex;
align-items: center;
gap: 2px;
}
.page-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 30px;
padding: 0 6px;
border-radius: 7px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
background: none;
border: 1px solid transparent;
color: var(--text-secondary);
transition: all 0.12s;
}
.page-btn:hover:not(:disabled) {
background: var(--surface-hover, rgba(255, 255, 255, 0.06));
color: var(--text-primary);
}
.page-btn.active {
background: var(--accent-primary, #3b82f6);
color: #fff;
border-color: transparent;
font-weight: 600;
}
.page-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.page-ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
color: var(--text-tertiary, rgba(255, 255, 255, 0.3));
font-size: 12px;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
/**
* Svelte action: positions a dropdown element using fixed coordinates so it
* escapes `overflow: hidden` parent containers.
*
* Expects the dropdown's parent to contain a sibling element with class `menu-btn`.
*
* Usage:
* ```svelte
* <div class="my-dropdown" use:positionMenu></div>
* ```
*/
export function positionMenu(node: HTMLElement) {
const btn = node.parentElement?.querySelector(
".menu-btn",
) as HTMLElement | null;
if (!btn) return;
function update() {
const rect = btn!.getBoundingClientRect();
node.style.top = `${rect.bottom + 4}px`;
node.style.left = `${rect.right - node.offsetWidth}px`;
}
update();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return {
destroy() {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
},
};
}
+52
View File
@@ -1,4 +1,8 @@
import { optima } from "$lib"; import { optima } from "$lib";
import type {
PermissionCategory,
PermissionNode,
} from "$lib/optima-api/modules/permissions";
export type PermissionMap = Record<string, boolean> & { export type PermissionMap = Record<string, boolean> & {
/** Set to `true` when the permission check itself failed (API error, timeout, etc.) */ /** Set to `true` when the permission check itself failed (API error, timeout, etc.) */
@@ -73,3 +77,51 @@ export const resolvePermissions = checkPermissions;
export function hasPermission(map: PermissionMap, permission: string): boolean { export function hasPermission(map: PermissionMap, permission: string): boolean {
return map[permission] === true; return map[permission] === true;
} }
// ── Permission tree traversal helpers ────────────────────────────────────────
// Shared by CreateRoleModal, EditUserModal, and any future component that
// needs to walk the categorised permission tree returned by the API.
/**
* Recursively collect all permission node strings from a category tree,
* including field-level permission strings attached to each node.
*/
export function collectAllNodes(cat: PermissionCategory): string[] {
const nodes: string[] = [];
for (const p of cat.permissions ?? []) {
nodes.push(p.node);
if (p.fieldLevelPermissions) nodes.push(...p.fieldLevelPermissions);
}
if (cat.subCategories) {
for (const sub of Object.values(cat.subCategories)) {
nodes.push(...collectAllNodes(sub as PermissionCategory));
}
}
return nodes;
}
/**
* Recursively collect all `PermissionNode` objects from a category tree.
*/
export function collectAllPermNodes(cat: PermissionCategory): PermissionNode[] {
const nodes = [...(cat.permissions ?? [])];
if (cat.subCategories) {
for (const sub of Object.values(cat.subCategories)) {
nodes.push(...collectAllPermNodes(sub as PermissionCategory));
}
}
return nodes;
}
/**
* Collect all selectable strings (node + fieldLevelPermissions) from a flat
* array of `PermissionNode` objects.
*/
export function allSelectableStrings(perms: PermissionNode[]): string[] {
const out: string[] = [];
for (const p of perms) {
out.push(p.node);
if (p.fieldLevelPermissions) out.push(...p.fieldLevelPermissions);
}
return out;
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Format an ISO date string into a human-readable locale date.
* Returns an empty string (or "—" when `dash` is true) for missing/invalid values.
*/
export function formatDate(
dateStr?: string | null,
opts: { dash?: boolean } = { dash: true },
): string {
const fallback = opts.dash ? "—" : "";
if (!dateStr) return fallback;
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return fallback;
}
}
/**
* Compute a list of page numbers (with "..." ellipsis) for a pagination control.
* Always includes page 1 and the last page; collapses distant pages into ellipsis.
*/
export function getPageNumbers(
current: number,
total: number,
): (number | "...")[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const pages: (number | "...")[] = [1];
if (current > 3) pages.push("...");
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (current < total - 2) pages.push("...");
pages.push(total);
return pages;
}
+25 -122
View File
@@ -1,12 +1,15 @@
<script lang="ts"> <script lang="ts">
import { enhance } from "$app/forms";
import type { SubmitFunction } from "@sveltejs/kit"; import type { SubmitFunction } from "@sveltejs/kit";
import type { PermissionMap } from "$lib/permissions"; import type { PermissionMap } from "$lib/permissions";
import type { import type {
CredentialType, CredentialType,
CredentialTypeField, CredentialTypeField,
} from "$lib/optima-api/modules/credentialTypes"; } from "$lib/optima-api/modules/credentialTypes";
import { formatDate } from "$lib/utils";
import { positionMenu } from "$lib/actions";
import CreateCredentialTypeModal from "../../../components/CreateCredentialTypeModal.svelte"; import CreateCredentialTypeModal from "../../../components/CreateCredentialTypeModal.svelte";
import AccessDenied from "../../../components/AccessDenied.svelte";
import DeleteConfirmDialog from "../../../components/DeleteConfirmDialog.svelte";
import "../../../styles/admin/credential-types.css"; import "../../../styles/admin/credential-types.css";
export let data: { export let data: {
@@ -50,31 +53,6 @@
openMenuId = openMenuId === id ? null : id; openMenuId = openMenuId === id ? null : id;
} }
/** Position the dropdown using fixed coordinates so it escapes overflow:hidden parents */
function positionMenu(node: HTMLElement) {
const btn = node.parentElement?.querySelector(
".menu-btn",
) as HTMLElement | null;
if (!btn) return;
function update() {
const rect = btn!.getBoundingClientRect();
node.style.top = `${rect.bottom + 4}px`;
node.style.left = `${rect.right - node.offsetWidth}px`;
}
update();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return {
destroy() {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
},
};
}
// Delete confirmation // Delete confirmation
let typeToDelete: CredentialType | null = null; let typeToDelete: CredentialType | null = null;
let isDeleting = false; let isDeleting = false;
@@ -113,19 +91,6 @@
expandedTypeId = expandedTypeId === id ? null : id; expandedTypeId = expandedTypeId === id ? null : id;
} }
function formatDate(dateStr?: string): string {
if (!dateStr) return "";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "";
}
}
function valueTypeLabel(vt: string): string { function valueTypeLabel(vt: string): string {
return vt return vt
.split("_") .split("_")
@@ -137,17 +102,9 @@
<svelte:window on:click={() => (openMenuId = null)} /> <svelte:window on:click={() => (openMenuId = null)} />
{#if !hasAccess} {#if !hasAccess}
<div class="admin-denied"> <AccessDenied
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> message="You don't have permission to manage credential types. Contact your administrator to request access."
<circle cx="12" cy="12" r="10" /> />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
<h3>Access Denied</h3>
<p>
You don't have permission to manage credential types. Contact your
administrator to request access.
</p>
</div>
{:else if credentialTypes.length === 0} {:else if credentialTypes.length === 0}
<CreateCredentialTypeModal <CreateCredentialTypeModal
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
@@ -208,78 +165,24 @@
}} }}
/> />
{#if typeToDelete} <DeleteConfirmDialog
<!-- svelte-ignore a11y_no_static_element_interactions --> isOpen={!!typeToDelete}
<div title="Delete Credential Type"
class="confirm-backdrop" idValue={typeToDelete?.id ?? ""}
on:click={cancelDelete} formAction="?/deleteCredentialType"
on:keydown={(e) => e.key === "Escape" && cancelDelete()} confirmLabel="Delete"
> {isDeleting}
<div error={deleteError}
class="confirm-dialog" onCancel={cancelDelete}
role="alertdialog" handleEnhance={handleDeleteEnhance}
aria-modal="true" >
aria-labelledby="confirm-title" Are you sure you want to delete <strong>{typeToDelete?.name}</strong>?
tabindex="-1" {#if typeToDelete && typeToDelete.credentialCount > 0}
on:click|stopPropagation This type has <strong>{typeToDelete.credentialCount}</strong>
on:keydown|stopPropagation credential{typeToDelete.credentialCount === 1 ? "" : "s"} associated with it.
> {/if}
<div class="confirm-icon-wrap"> This action cannot be undone.
<svg </DeleteConfirmDialog>
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
width="22"
height="22"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
</div>
<h3 id="confirm-title" class="confirm-title">Delete Credential Type</h3>
<p class="confirm-body">
Are you sure you want to delete
<strong>{typeToDelete.name}</strong>?
{#if typeToDelete.credentialCount > 0}
This type has <strong>{typeToDelete.credentialCount}</strong>
credential{typeToDelete.credentialCount === 1 ? "" : "s"} associated
with it.
{/if}
This action cannot be undone.
</p>
{#if deleteError}
<p class="confirm-error">{deleteError}</p>
{/if}
<div class="confirm-actions">
<button
type="button"
class="btn-cancel"
on:click={cancelDelete}
disabled={isDeleting}
>
Cancel
</button>
<form
method="POST"
action="?/deleteCredentialType"
use:enhance={handleDeleteEnhance}
>
<input type="hidden" name="id" value={typeToDelete.id} />
<button
type="submit"
class="btn-delete-confirm"
disabled={isDeleting}
>
{isDeleting ? "Deleting…" : "Delete"}
</button>
</form>
</div>
</div>
</div>
{/if}
<div class="admin-table-header"> <div class="admin-table-header">
<h3> <h3>
+21 -116
View File
@@ -1,10 +1,13 @@
<script lang="ts"> <script lang="ts">
import { enhance } from "$app/forms";
import type { SubmitFunction } from "@sveltejs/kit"; import type { SubmitFunction } from "@sveltejs/kit";
import type { PermissionMap } from "$lib/permissions"; import type { PermissionMap } from "$lib/permissions";
import type { Role } from "$lib/optima-api/modules/roles"; import type { Role } from "$lib/optima-api/modules/roles";
import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions"; import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions";
import { formatDate } from "$lib/utils";
import { positionMenu } from "$lib/actions";
import CreateRoleModal from "../../../components/CreateRoleModal.svelte"; import CreateRoleModal from "../../../components/CreateRoleModal.svelte";
import AccessDenied from "../../../components/AccessDenied.svelte";
import DeleteConfirmDialog from "../../../components/DeleteConfirmDialog.svelte";
import "../../../styles/admin/roles.css"; import "../../../styles/admin/roles.css";
interface RoleUser { interface RoleUser {
@@ -47,31 +50,6 @@
openMenuId = openMenuId === id ? null : id; openMenuId = openMenuId === id ? null : id;
} }
/** Position the dropdown using fixed coordinates so it escapes overflow:hidden parents */
function positionMenu(node: HTMLElement) {
const btn = node.parentElement?.querySelector(
".menu-btn",
) as HTMLElement | null;
if (!btn) return;
function update() {
const rect = btn!.getBoundingClientRect();
node.style.top = `${rect.bottom + 4}px`;
node.style.left = `${rect.right - node.offsetWidth}px`;
}
update();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return {
destroy() {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
},
};
}
// Delete confirmation // Delete confirmation
let roleToDelete: Role | null = null; let roleToDelete: Role | null = null;
let isDeleting = false; let isDeleting = false;
@@ -109,35 +87,14 @@
function toggleRole(id: string) { function toggleRole(id: string) {
expandedRoleId = expandedRoleId === id ? null : id; expandedRoleId = expandedRoleId === id ? null : id;
} }
function formatDate(dateStr?: string): string {
if (!dateStr) return "";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "";
}
}
</script> </script>
<svelte:window on:click={() => (openMenuId = null)} /> <svelte:window on:click={() => (openMenuId = null)} />
{#if !hasAccess} {#if !hasAccess}
<div class="admin-denied"> <AccessDenied
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> message="You don't have permission to manage roles. Contact your administrator to request access."
<circle cx="12" cy="12" r="10" /> />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
<h3>Access Denied</h3>
<p>
You don't have permission to manage roles. Contact your administrator to
request access.
</p>
</div>
{:else if roles.length === 0} {:else if roles.length === 0}
<CreateRoleModal <CreateRoleModal
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
@@ -196,72 +153,20 @@
}} }}
/> />
{#if roleToDelete} <DeleteConfirmDialog
<!-- svelte-ignore a11y_no_static_element_interactions --> isOpen={!!roleToDelete}
<div title="Delete Role"
class="confirm-backdrop" idValue={roleToDelete?.id ?? ""}
on:click={cancelDelete} formAction="?/deleteRole"
on:keydown={(e) => e.key === "Escape" && cancelDelete()} confirmLabel="Delete Role"
> {isDeleting}
<div error={deleteError}
class="confirm-dialog" onCancel={cancelDelete}
role="alertdialog" handleEnhance={handleDeleteEnhance}
aria-modal="true" >
aria-labelledby="confirm-title" Are you sure you want to delete <strong>{roleToDelete?.title}</strong>? This
tabindex="-1" action cannot be undone.
on:click|stopPropagation </DeleteConfirmDialog>
on:keydown|stopPropagation
>
<div class="confirm-icon-wrap">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
width="22"
height="22"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
</div>
<h3 id="confirm-title" class="confirm-title">Delete Role</h3>
<p class="confirm-body">
Are you sure you want to delete
<strong>{roleToDelete.title}</strong>? This action cannot be undone.
</p>
{#if deleteError}
<p class="confirm-error">{deleteError}</p>
{/if}
<div class="confirm-actions">
<button
type="button"
class="btn-cancel"
on:click={cancelDelete}
disabled={isDeleting}
>
Cancel
</button>
<form
method="POST"
action="?/deleteRole"
use:enhance={handleDeleteEnhance}
>
<input type="hidden" name="id" value={roleToDelete.id} />
<button
type="submit"
class="btn-delete-confirm"
disabled={isDeleting}
>
{isDeleting ? "Deleting…" : "Delete Role"}
</button>
</form>
</div>
</div>
</div>
{/if}
<div class="admin-table-header"> <div class="admin-table-header">
<h3> <h3>
+22 -116
View File
@@ -1,11 +1,14 @@
<script lang="ts"> <script lang="ts">
import { enhance } from "$app/forms";
import type { SubmitFunction } from "@sveltejs/kit"; import type { SubmitFunction } from "@sveltejs/kit";
import type { PermissionMap } from "$lib/permissions"; import type { PermissionMap } from "$lib/permissions";
import type { User } from "$lib/optima-api/modules/users"; import type { User } from "$lib/optima-api/modules/users";
import type { Role } from "$lib/optima-api/modules/roles"; import type { Role } from "$lib/optima-api/modules/roles";
import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions"; import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions";
import { formatDate } from "$lib/utils";
import { positionMenu } from "$lib/actions";
import EditUserModal from "../../../components/EditUserModal.svelte"; import EditUserModal from "../../../components/EditUserModal.svelte";
import AccessDenied from "../../../components/AccessDenied.svelte";
import DeleteConfirmDialog from "../../../components/DeleteConfirmDialog.svelte";
import "../../../styles/admin/users.css"; import "../../../styles/admin/users.css";
type UserWithRoles = User & { roleDetails: Role[] }; type UserWithRoles = User & { roleDetails: Role[] };
@@ -51,30 +54,6 @@
openMenuId = openMenuId === id ? null : id; openMenuId = openMenuId === id ? null : id;
} }
function positionMenu(node: HTMLElement) {
const btn = node.parentElement?.querySelector(
".menu-btn",
) as HTMLElement | null;
if (!btn) return;
function update() {
const rect = btn!.getBoundingClientRect();
node.style.top = `${rect.bottom + 4}px`;
node.style.left = `${rect.right - node.offsetWidth}px`;
}
update();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return {
destroy() {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
},
};
}
// Edit modal state // Edit modal state
let editingUser: UserWithRoles | null = null; let editingUser: UserWithRoles | null = null;
@@ -126,35 +105,14 @@
.slice(0, 2) .slice(0, 2)
.toUpperCase(); .toUpperCase();
} }
function formatDate(dateStr?: string): string {
if (!dateStr) return "";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "";
}
}
</script> </script>
<svelte:window on:click={() => (openMenuId = null)} /> <svelte:window on:click={() => (openMenuId = null)} />
{#if !hasAccess} {#if !hasAccess}
<div class="admin-denied"> <AccessDenied
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> message="You don't have permission to manage users. Contact your administrator to request access."
<circle cx="12" cy="12" r="10" /> />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
<h3>Access Denied</h3>
<p>
You don't have permission to manage users. Contact your administrator to
request access.
</p>
</div>
{:else if users.length === 0} {:else if users.length === 0}
<div class="admin-tab-empty"> <div class="admin-tab-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -180,73 +138,21 @@
/> />
{/if} {/if}
<!-- Delete confirmation modal --> <!-- Delete confirmation dialog -->
{#if userToDelete} <DeleteConfirmDialog
<!-- svelte-ignore a11y_no_static_element_interactions --> isOpen={!!userToDelete}
<div title="Delete User"
class="confirm-backdrop" idValue={userToDelete?.id ?? ""}
on:click={cancelDelete} formAction="?/deleteUser"
on:keydown={(e) => e.key === "Escape" && cancelDelete()} confirmLabel="Delete User"
> {isDeleting}
<div error={deleteError}
class="confirm-dialog" onCancel={cancelDelete}
role="alertdialog" handleEnhance={handleDeleteEnhance}
aria-modal="true" >
aria-labelledby="confirm-title" Are you sure you want to delete <strong>{userToDelete?.name}</strong>? This
tabindex="-1" action cannot be undone.
on:click|stopPropagation </DeleteConfirmDialog>
on:keydown|stopPropagation
>
<div class="confirm-icon-wrap">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
width="22"
height="22"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
</div>
<h3 id="confirm-title" class="confirm-title">Delete User</h3>
<p class="confirm-body">
Are you sure you want to delete
<strong>{userToDelete.name}</strong>? This action cannot be undone.
</p>
{#if deleteError}
<p class="confirm-error">{deleteError}</p>
{/if}
<div class="confirm-actions">
<button
type="button"
class="btn-cancel"
on:click={cancelDelete}
disabled={isDeleting}
>
Cancel
</button>
<form
method="POST"
action="?/deleteUser"
use:enhance={handleDeleteEnhance}
>
<input type="hidden" name="id" value={userToDelete.id} />
<button
type="submit"
class="btn-delete-confirm"
disabled={isDeleting}
>
{isDeleting ? "Deleting…" : "Delete User"}
</button>
</form>
</div>
</div>
</div>
{/if}
<div class="admin-table-header"> <div class="admin-table-header">
<h3> <h3>
+7 -127
View File
@@ -2,7 +2,10 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { afterNavigate } from "$app/navigation"; import { afterNavigate } from "$app/navigation";
import type { PermissionMap } from "$lib/permissions"; import type { PermissionMap } from "$lib/permissions";
import { formatDate } from "$lib/utils";
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte"; import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
import AccessDenied from "../../components/AccessDenied.svelte";
import Pagination from "../../components/Pagination.svelte";
import "../../styles/companies/companylist.css"; import "../../styles/companies/companylist.css";
export let data: { export let data: {
@@ -93,19 +96,6 @@
} }
} }
function formatDate(dateStr?: string): string {
if (!dateStr) return "";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "";
}
}
function statusClass(status?: string): string { function statusClass(status?: string): string {
if (!status) return "neutral"; if (!status) return "neutral";
const s = status.toLowerCase(); const s = status.toLowerCase();
@@ -123,28 +113,6 @@
.join("") .join("")
.toUpperCase(); .toUpperCase();
} }
// Generate visible page numbers with ellipsis
function getPageNumbers(current: number, total: number): (number | "...")[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const pages: (number | "...")[] = [];
pages.push(1);
if (current > 3) pages.push("...");
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (current < total - 2) pages.push("...");
pages.push(total);
return pages;
}
$: pageNumbers = getPageNumbers(currentPage, totalPages);
</script> </script>
<svelte:head> <svelte:head>
@@ -152,17 +120,9 @@
</svelte:head> </svelte:head>
{#if !hasAccess} {#if !hasAccess}
<div class="access-denied"> <AccessDenied
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> message="You don't have permission to view Companies. Contact your administrator to request access."
<circle cx="12" cy="12" r="10" /> />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
<h3>Access Denied</h3>
<p>
You don't have permission to view Companies. Contact your administrator to
request access.
</p>
</div>
{:else} {:else}
<div class="companies-page"> <div class="companies-page">
<div class="companies-pane"> <div class="companies-pane">
@@ -343,58 +303,7 @@
<span class="page-info"> <span class="page-info">
Page {currentPage} of {totalPages} Page {currentPage} of {totalPages}
</span> </span>
<nav class="pagination" aria-label="Pagination"> <Pagination {currentPage} {totalPages} onNavigate={navigateToPage} />
<button
class="page-btn"
disabled={currentPage <= 1}
on:click={() => navigateToPage(currentPage - 1)}
aria-label="Previous page"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
{#each pageNumbers as p}
{#if p === "..."}
<span class="page-ellipsis"></span>
{:else}
<button
class="page-btn"
class:active={p === currentPage}
on:click={() => navigateToPage(p)}
aria-current={p === currentPage ? "page" : undefined}
>
{p}
</button>
{/if}
{/each}
<button
class="page-btn"
disabled={currentPage >= totalPages}
on:click={() => navigateToPage(currentPage + 1)}
aria-label="Next page"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</nav>
</div> </div>
{/if} {/if}
</div> </div>
@@ -402,33 +311,4 @@
{/if} {/if}
<style> <style>
.access-denied {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 12px;
}
.access-denied svg {
width: 40px;
height: 40px;
color: var(--status-inactive-color);
}
.access-denied h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.access-denied p {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
text-align: center;
max-width: 360px;
}
</style> </style>
+9 -126
View File
@@ -4,6 +4,9 @@
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
import type { PermissionMap } from "$lib/permissions"; import type { PermissionMap } from "$lib/permissions";
import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte"; import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte";
import AccessDenied from "../../../components/AccessDenied.svelte";
import Pagination from "../../../components/Pagination.svelte";
import { formatDate } from "$lib/utils";
import "../../../styles/procurement/catalog.css"; import "../../../styles/procurement/catalog.css";
type CatalogItem = { type CatalogItem = {
@@ -416,33 +419,6 @@
}).format(value); }).format(value);
} }
function formatDate(dateStr?: string): string {
if (!dateStr) return "—";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "—";
}
}
function getPageNumbers(current: number, total: number): (number | "...")[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const pages: (number | "...")[] = [1];
if (current > 3) pages.push("...");
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (current < total - 2) pages.push("...");
pages.push(total);
return pages;
}
$: pageNumbers = getPageNumbers(currentPage, totalPages);
$: hasInactiveStaged = (() => { $: hasInactiveStaged = (() => {
for (const id of stagedAdds) { for (const id of stagedAdds) {
const item = linkSearchResults.find((r) => r.id === id); const item = linkSearchResults.find((r) => r.id === id);
@@ -466,17 +442,9 @@
</svelte:head> </svelte:head>
{#if !hasAccess} {#if !hasAccess}
<div class="catalog-access-denied"> <AccessDenied
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> message="You don't have permission to view the Product Catalog. Contact your administrator to request access."
<circle cx="12" cy="12" r="10" /> />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
<h3>Access Denied</h3>
<p>
You don't have permission to view the Product Catalog. Contact your
administrator to request access.
</p>
</div>
{:else} {:else}
<div class="catalog-page" class:panel-open={selectedItem}> <div class="catalog-page" class:panel-open={selectedItem}>
<!-- Toolbar --> <!-- Toolbar -->
@@ -1481,93 +1449,8 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
{#if totalPages > 1} <div class="catalog-footer">
<div class="catalog-footer"> <Pagination {currentPage} {totalPages} onNavigate={navigateToPage} />
<span class="catalog-page-info"> </div>
Page {currentPage} of {totalPages}
</span>
<nav class="catalog-pagination" aria-label="Catalog pagination">
<button
class="catalog-page-btn"
disabled={currentPage <= 1}
on:click={() => navigateToPage(currentPage - 1)}
aria-label="Previous page"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
{#each pageNumbers as p}
{#if p === "..."}
<span class="catalog-page-ellipsis"></span>
{:else}
<button
class="catalog-page-btn"
class:active={p === currentPage}
on:click={() => navigateToPage(p)}
aria-current={p === currentPage ? "page" : undefined}
>
{p}
</button>
{/if}
{/each}
<button
class="catalog-page-btn"
disabled={currentPage >= totalPages}
on:click={() => navigateToPage(currentPage + 1)}
aria-label="Next page"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</nav>
</div>
{/if}
</div> </div>
{/if} {/if}
<style>
.catalog-access-denied {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 12px;
}
.catalog-access-denied svg {
width: 40px;
height: 40px;
color: var(--status-inactive-color);
}
.catalog-access-denied h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.catalog-access-denied p {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
text-align: center;
max-width: 360px;
}
</style>
+7 -120
View File
@@ -3,8 +3,11 @@
import { invalidateAll } from "$app/navigation"; import { invalidateAll } from "$app/navigation";
import type { PermissionMap } from "$lib/permissions"; import type { PermissionMap } from "$lib/permissions";
import type { OpportunityType } from "$lib/optima-api/modules/sales"; import type { OpportunityType } from "$lib/optima-api/modules/sales";
import { formatDate } from "$lib/utils";
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte"; import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
import CreateOpportunityModal from "../../components/CreateOpportunityModal.svelte"; import CreateOpportunityModal from "../../components/CreateOpportunityModal.svelte";
import AccessDenied from "../../components/AccessDenied.svelte";
import Pagination from "../../components/Pagination.svelte";
import "../../styles/sales/sales.css"; import "../../styles/sales/sales.css";
type SalesOpportunity = { type SalesOpportunity = {
@@ -184,19 +187,6 @@
} }
} }
function formatDate(dateStr?: string | null): string {
if (!dateStr) return "—";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "—";
}
}
function statusLabel(op: SalesOpportunity): string { function statusLabel(op: SalesOpportunity): string {
if (op.closedFlag) return "Closed"; if (op.closedFlag) return "Closed";
const statusId = op.status?.id; const statusId = op.status?.id;
@@ -306,20 +296,6 @@
s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);"; s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);";
return s; return s;
} }
function getPageNumbers(current: number, total: number): (number | "...")[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const pages: (number | "...")[] = [1];
if (current > 3) pages.push("...");
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (current < total - 2) pages.push("...");
pages.push(total);
return pages;
}
$: pageNumbers = getPageNumbers(currentPage, totalPages);
</script> </script>
<svelte:window on:click={handleFilterClickOutside} /> <svelte:window on:click={handleFilterClickOutside} />
@@ -329,17 +305,9 @@
</svelte:head> </svelte:head>
{#if !hasAccess} {#if !hasAccess}
<div class="sales-access-denied"> <AccessDenied
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> message="You don't have permission to view Sales opportunities. Contact your administrator to request access."
<circle cx="12" cy="12" r="10" /> />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
<h3>Access Denied</h3>
<p>
You don't have permission to view Sales opportunities. Contact your
administrator to request access.
</p>
</div>
{:else} {:else}
<div class="sales-page"> <div class="sales-page">
<div class="sales-pane"> <div class="sales-pane">
@@ -549,58 +517,7 @@
<span class="sales-page-info"> <span class="sales-page-info">
Page {currentPage} of {totalPages} Page {currentPage} of {totalPages}
</span> </span>
<nav class="sales-pagination" aria-label="Sales pagination"> <Pagination {currentPage} {totalPages} onNavigate={navigateToPage} />
<button
class="sales-page-btn"
disabled={currentPage <= 1}
on:click={() => navigateToPage(currentPage - 1)}
aria-label="Previous page"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
{#each pageNumbers as p}
{#if p === "..."}
<span class="sales-page-ellipsis"></span>
{:else}
<button
class="sales-page-btn"
class:active={p === currentPage}
on:click={() => navigateToPage(p)}
aria-current={p === currentPage ? "page" : undefined}
>
{p}
</button>
{/if}
{/each}
<button
class="sales-page-btn"
disabled={currentPage >= totalPages}
on:click={() => navigateToPage(currentPage + 1)}
aria-label="Next page"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</nav>
</div> </div>
{/if} {/if}
</div> </div>
@@ -613,36 +530,6 @@
/> />
<style> <style>
.sales-access-denied {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 12px;
}
.sales-access-denied svg {
width: 40px;
height: 40px;
color: var(--status-inactive-color);
}
.sales-access-denied h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.sales-access-denied p {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
text-align: center;
max-width: 360px;
}
.sales-create-btn { .sales-create-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+7 -120
View File
@@ -3,8 +3,11 @@
import { invalidateAll } from "$app/navigation"; import { invalidateAll } from "$app/navigation";
import type { PermissionMap } from "$lib/permissions"; import type { PermissionMap } from "$lib/permissions";
import type { OpportunityType } from "$lib/optima-api/modules/sales"; import type { OpportunityType } from "$lib/optima-api/modules/sales";
import { formatDate } from "$lib/utils";
import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte"; import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte";
import CreateOpportunityModal from "../../../components/CreateOpportunityModal.svelte"; import CreateOpportunityModal from "../../../components/CreateOpportunityModal.svelte";
import AccessDenied from "../../../components/AccessDenied.svelte";
import Pagination from "../../../components/Pagination.svelte";
import "../../../styles/sales/sales.css"; import "../../../styles/sales/sales.css";
type SalesOpportunity = { type SalesOpportunity = {
@@ -184,19 +187,6 @@
} }
} }
function formatDate(dateStr?: string | null): string {
if (!dateStr) return "—";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "—";
}
}
function statusLabel(op: SalesOpportunity): string { function statusLabel(op: SalesOpportunity): string {
if (op.closedFlag) return "Closed"; if (op.closedFlag) return "Closed";
const statusId = op.status?.id; const statusId = op.status?.id;
@@ -307,20 +297,6 @@
s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);"; s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);";
return s; return s;
} }
function getPageNumbers(current: number, total: number): (number | "...")[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const pages: (number | "...")[] = [1];
if (current > 3) pages.push("...");
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (current < total - 2) pages.push("...");
pages.push(total);
return pages;
}
$: pageNumbers = getPageNumbers(currentPage, totalPages);
</script> </script>
<svelte:window on:click={handleFilterClickOutside} /> <svelte:window on:click={handleFilterClickOutside} />
@@ -330,17 +306,9 @@
</svelte:head> </svelte:head>
{#if !hasAccess} {#if !hasAccess}
<div class="sales-access-denied"> <AccessDenied
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> message="You don't have permission to view Sales opportunities. Contact your administrator to request access."
<circle cx="12" cy="12" r="10" /> />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
<h3>Access Denied</h3>
<p>
You don't have permission to view Sales opportunities. Contact your
administrator to request access.
</p>
</div>
{:else} {:else}
<div class="sales-page"> <div class="sales-page">
<div class="sales-pane"> <div class="sales-pane">
@@ -550,58 +518,7 @@
<span class="sales-page-info"> <span class="sales-page-info">
Page {currentPage} of {totalPages} Page {currentPage} of {totalPages}
</span> </span>
<nav class="sales-pagination" aria-label="Sales pagination"> <Pagination {currentPage} {totalPages} onNavigate={navigateToPage} />
<button
class="sales-page-btn"
disabled={currentPage <= 1}
on:click={() => navigateToPage(currentPage - 1)}
aria-label="Previous page"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
{#each pageNumbers as p}
{#if p === "..."}
<span class="sales-page-ellipsis"></span>
{:else}
<button
class="sales-page-btn"
class:active={p === currentPage}
on:click={() => navigateToPage(p)}
aria-current={p === currentPage ? "page" : undefined}
>
{p}
</button>
{/if}
{/each}
<button
class="sales-page-btn"
disabled={currentPage >= totalPages}
on:click={() => navigateToPage(currentPage + 1)}
aria-label="Next page"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</nav>
</div> </div>
{/if} {/if}
</div> </div>
@@ -614,36 +531,6 @@
/> />
<style> <style>
.sales-access-denied {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 12px;
}
.sales-access-denied svg {
width: 40px;
height: 40px;
color: var(--status-inactive-color);
}
.sales-access-denied h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.sales-access-denied p {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
text-align: center;
max-width: 360px;
}
.sales-create-btn { .sales-create-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;