Setup unifi wlans
This commit is contained in:
+4
-1
@@ -3,7 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||||
|
/>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,14 @@
|
|||||||
monikerEdited = true;
|
monikerEdited = true;
|
||||||
selectedPermissions = new Set(roleToEdit.permissions);
|
selectedPermissions = new Set(roleToEdit.permissions);
|
||||||
|
|
||||||
|
// Identify permissions not in the categorized list → show as custom
|
||||||
|
const catalogued = new Set(
|
||||||
|
Object.values(permissionNodes).flatMap((cat) =>
|
||||||
|
(cat as PermissionCategory).permissions.map((p) => p.node),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
customNodes = roleToEdit.permissions.filter((p) => !catalogued.has(p));
|
||||||
|
|
||||||
// Auto-expand categories that contain selected permissions
|
// Auto-expand categories that contain selected permissions
|
||||||
const sel = new Set(roleToEdit.permissions);
|
const sel = new Set(roleToEdit.permissions);
|
||||||
const expanded = new Set<string>();
|
const expanded = new Set<string>();
|
||||||
@@ -39,10 +47,12 @@
|
|||||||
let moniker = "";
|
let moniker = "";
|
||||||
let monikerEdited = false;
|
let monikerEdited = false;
|
||||||
let selectedPermissions = new Set<string>();
|
let selectedPermissions = new Set<string>();
|
||||||
let permissionSearch = "";
|
|
||||||
let expandedCategories = new Set<string>();
|
let expandedCategories = new Set<string>();
|
||||||
let isSubmitting = false;
|
let isSubmitting = false;
|
||||||
let submitError = "";
|
let submitError = "";
|
||||||
|
let customPermNode = "";
|
||||||
|
let customPermError = "";
|
||||||
|
let customNodes: string[] = [];
|
||||||
|
|
||||||
$: if (!monikerEdited) {
|
$: if (!monikerEdited) {
|
||||||
moniker = title
|
moniker = title
|
||||||
@@ -51,27 +61,10 @@
|
|||||||
.replace(/[^a-z0-9-]/g, "");
|
.replace(/[^a-z0-9-]/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
$: filteredEntries = (() => {
|
$: permissionEntries = Object.entries(permissionNodes ?? {}) as [
|
||||||
const entries = Object.entries(permissionNodes) as [
|
|
||||||
string,
|
string,
|
||||||
PermissionCategory,
|
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;
|
$: isValid = title.trim().length > 0 && moniker.trim().length > 0;
|
||||||
$: selectedCount = selectedPermissions.size;
|
$: selectedCount = selectedPermissions.size;
|
||||||
@@ -97,15 +90,52 @@
|
|||||||
selectedPermissions = next;
|
selectedPermissions = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addCustomPermission() {
|
||||||
|
const node = customPermNode.trim();
|
||||||
|
customPermError = "";
|
||||||
|
if (!node) return;
|
||||||
|
// Validate permission node format per PERMISSIONS.md:
|
||||||
|
// - dot-separated tokens of lowercase alphanumeric + underscores
|
||||||
|
// - special tokens: * (wildcard), ? (single-char wildcard)
|
||||||
|
// - inclusive list [a,b,c] or exclusive list <a,b,c>
|
||||||
|
// - standalone * for full access
|
||||||
|
if (
|
||||||
|
!/^(?:\*|(?:(?:[a-z0-9_]+|\*|\?|\[[a-z0-9_]+(?:,[a-z0-9_]+)*\]|<[a-z0-9_]+(?:,[a-z0-9_]+)*>)(?:\.(?:[a-z0-9_]+|\*|\?|\[[a-z0-9_]+(?:,[a-z0-9_]+)*\]|<[a-z0-9_]+(?:,[a-z0-9_]+)*>))*))$/.test(
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
customPermError =
|
||||||
|
"Use dot-separated tokens (e.g. resource.action). Supports: a-z, 0-9, underscores, * ? wildcards, [a,b] or <a,b> lists.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedPermissions.has(node)) {
|
||||||
|
customPermError = "This permission is already selected.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Add to custom list if not from the categorized set
|
||||||
|
const catalogued = Object.values(permissionNodes).flatMap((cat) =>
|
||||||
|
(cat as PermissionCategory).permissions.map((p) => p.node),
|
||||||
|
);
|
||||||
|
if (!catalogued.includes(node)) {
|
||||||
|
customNodes = [...customNodes, node];
|
||||||
|
}
|
||||||
|
const next = new Set(selectedPermissions);
|
||||||
|
next.add(node);
|
||||||
|
selectedPermissions = next;
|
||||||
|
customPermNode = "";
|
||||||
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
title = "";
|
title = "";
|
||||||
moniker = "";
|
moniker = "";
|
||||||
monikerEdited = false;
|
monikerEdited = false;
|
||||||
selectedPermissions = new Set();
|
selectedPermissions = new Set();
|
||||||
permissionSearch = "";
|
|
||||||
expandedCategories = new Set();
|
expandedCategories = new Set();
|
||||||
isSubmitting = false;
|
isSubmitting = false;
|
||||||
submitError = "";
|
submitError = "";
|
||||||
|
customPermNode = "";
|
||||||
|
customPermError = "";
|
||||||
|
customNodes = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
@@ -113,8 +143,7 @@
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBackdropClick(e: MouseEvent) {
|
function handleBackdropClick() {
|
||||||
if ((e.target as HTMLElement).classList.contains("modal-backdrop"))
|
|
||||||
handleClose();
|
handleClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,12 +159,15 @@
|
|||||||
on:click={handleBackdropClick}
|
on:click={handleBackdropClick}
|
||||||
on:keydown={handleBackdropKeydown}
|
on:keydown={handleBackdropKeydown}
|
||||||
>
|
>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
class="modal"
|
class="modal"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={isEditMode ? "Edit Role" : "Create Role"}
|
aria-label={isEditMode ? "Edit Role" : "Create Role"}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
|
on:click|stopPropagation
|
||||||
>
|
>
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="modal-title-group">
|
<div class="modal-title-group">
|
||||||
@@ -174,7 +206,11 @@
|
|||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action={isEditMode ? "?/updateRole" : "?/createRole"}
|
action={isEditMode ? "?/updateRole" : "?/createRole"}
|
||||||
use:enhance={() => {
|
use:enhance={({ formData }) => {
|
||||||
|
// Append permissions at submit time instead of via reactive hidden inputs
|
||||||
|
for (const node of selectedPermissions) {
|
||||||
|
formData.append("permissions", node);
|
||||||
|
}
|
||||||
isSubmitting = true;
|
isSubmitting = true;
|
||||||
submitError = "";
|
submitError = "";
|
||||||
return async ({ result, update }) => {
|
return async ({ result, update }) => {
|
||||||
@@ -197,9 +233,6 @@
|
|||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#each [...selectedPermissions] as node (node)}
|
|
||||||
<input type="hidden" name="permissions" value={node} />
|
|
||||||
{/each}
|
|
||||||
{#if isEditMode && roleToEdit}
|
{#if isEditMode && roleToEdit}
|
||||||
<input type="hidden" name="id" value={roleToEdit.id} />
|
<input type="hidden" name="id" value={roleToEdit.id} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -261,64 +294,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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">
|
<div class="perm-list">
|
||||||
{#if Object.keys(permissionNodes).length === 0}
|
{#if Object.keys(permissionNodes).length === 0}
|
||||||
<p class="perm-empty">No permission data available.</p>
|
<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}
|
{:else}
|
||||||
{#each filteredEntries as [catKey, category] (catKey)}
|
{#each permissionEntries as [catKey, category] (catKey)}
|
||||||
{@const catPerms = category.permissions}
|
{@const catPerms = category.permissions ?? []}
|
||||||
{@const allSel =
|
{@const allSel =
|
||||||
catPerms.length > 0 &&
|
catPerms.length > 0 &&
|
||||||
catPerms.every((p) => selectedPermissions.has(p.node))}
|
catPerms.every((p) => selectedPermissions.has(p.node))}
|
||||||
{@const someSel =
|
{@const someSel =
|
||||||
!allSel &&
|
!allSel &&
|
||||||
catPerms.some((p) => selectedPermissions.has(p.node))}
|
catPerms.some((p) => selectedPermissions.has(p.node))}
|
||||||
{@const isExpanded =
|
{@const isExpanded = expandedCategories.has(catKey)}
|
||||||
permissionSearch.trim().length > 0 ||
|
|
||||||
expandedCategories.has(catKey)}
|
|
||||||
<div class="cat-group">
|
<div class="cat-group">
|
||||||
<div class="cat-header">
|
<div class="cat-header">
|
||||||
<button
|
<button
|
||||||
@@ -419,8 +407,97 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Custom permission nodes -->
|
||||||
|
{#if customNodes.length > 0}
|
||||||
|
<div class="cat-group">
|
||||||
|
<div class="cat-header">
|
||||||
|
<div class="cat-toggle custom-cat-label">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="11"
|
||||||
|
height="11"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
<span class="cat-name">Custom</span>
|
||||||
|
<span class="cat-count">{customNodes.length}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cat-perms">
|
||||||
|
{#each customNodes as node (node)}
|
||||||
|
{@const sel = selectedPermissions.has(node)}
|
||||||
|
<label class="perm-row" class:perm-sel={sel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="sr-only"
|
||||||
|
checked={sel}
|
||||||
|
on:change={() => togglePermission(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">{node}</code>
|
||||||
|
<span class="perm-desc">Custom permission</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom permission input -->
|
||||||
|
<div class="custom-perm-wrap">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="custom-perm-input"
|
||||||
|
placeholder="Add custom node… (e.g. my.custom.node)"
|
||||||
|
bind:value={customPermNode}
|
||||||
|
on:keydown={(e) =>
|
||||||
|
e.key === "Enter" &&
|
||||||
|
(e.preventDefault(), addCustomPermission())}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="custom-perm-btn"
|
||||||
|
on:click={addCustomPermission}
|
||||||
|
disabled={!customPermNode.trim()}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if customPermError}
|
||||||
|
<p class="custom-perm-error">{customPermError}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -659,58 +736,81 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Search ── */
|
/* ── Custom permission input ── */
|
||||||
.search-wrap {
|
.custom-perm-wrap {
|
||||||
position: relative;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-icon {
|
.custom-perm-input {
|
||||||
position: absolute;
|
flex: 1;
|
||||||
left: 9px;
|
padding: 7px 10px;
|
||||||
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: 1px solid var(--border-subtle);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
font-size: 13px;
|
background: var(--bg-inset);
|
||||||
|
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: border-color 0.15s;
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input::placeholder {
|
.custom-perm-input::placeholder {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input:focus {
|
.custom-perm-input:focus {
|
||||||
border-color: var(--accent-color, #0066cc);
|
border-color: var(--accent-color, #0066cc);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-clear {
|
.custom-perm-btn {
|
||||||
position: absolute;
|
display: inline-flex;
|
||||||
right: 8px;
|
align-items: center;
|
||||||
background: none;
|
gap: 4px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
background: var(--accent-color, #0066cc);
|
||||||
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 3px;
|
border-radius: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text-muted);
|
white-space: nowrap;
|
||||||
border-radius: 4px;
|
transition: filter 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-perm-btn:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-perm-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-perm-error {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-cat-label {
|
||||||
|
cursor: default;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 6px;
|
||||||
transition: color 0.1s;
|
padding: 7px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-clear:hover {
|
.custom-cat-label svg {
|
||||||
color: var(--text-primary);
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Permission list ── */
|
/* ── Permission list ── */
|
||||||
|
|||||||
@@ -0,0 +1,724 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
import type { SubmitFunction } from "@sveltejs/kit";
|
||||||
|
import type { User } from "$lib/optima-api/modules/users";
|
||||||
|
import type { Role } from "$lib/optima-api/modules/roles";
|
||||||
|
import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions";
|
||||||
|
|
||||||
|
type UserWithRoles = User & { roleDetails: Role[] };
|
||||||
|
|
||||||
|
export let user: UserWithRoles;
|
||||||
|
export let allRoles: Role[] = [];
|
||||||
|
export let permissionNodes: PermissionsCategorized = {};
|
||||||
|
export let canEditRoles = false;
|
||||||
|
export let canEditPermissions = false;
|
||||||
|
export let onClose: () => void = () => {};
|
||||||
|
export let onSuccess: () => void = () => {};
|
||||||
|
|
||||||
|
let editName = user.name;
|
||||||
|
let editImage = user.image ?? "";
|
||||||
|
let editError = "";
|
||||||
|
let isEditing = false;
|
||||||
|
|
||||||
|
// Role editing
|
||||||
|
let editSelectedRoles: string[] = user.roleDetails.map((r) => r.id);
|
||||||
|
|
||||||
|
// Permission editing
|
||||||
|
let editSelectedPermissions: string[] = [...(user.permissions ?? [])];
|
||||||
|
let permSearchQuery = "";
|
||||||
|
let customPermNode = "";
|
||||||
|
let customPermError = "";
|
||||||
|
|
||||||
|
// Track custom-added nodes so they appear in the list
|
||||||
|
let customNodes: string[] = [];
|
||||||
|
|
||||||
|
$: allPermissionNodes = [
|
||||||
|
...Object.values(permissionNodes ?? {}).flatMap((cat) =>
|
||||||
|
cat.permissions.map((p) => p.node),
|
||||||
|
),
|
||||||
|
...customNodes.filter(
|
||||||
|
(n) =>
|
||||||
|
!Object.values(permissionNodes ?? {})
|
||||||
|
.flatMap((cat) => cat.permissions.map((p) => p.node))
|
||||||
|
.includes(n),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
$: filteredPermNodes = permSearchQuery.trim()
|
||||||
|
? allPermissionNodes.filter((n) =>
|
||||||
|
n.toLowerCase().includes(permSearchQuery.toLowerCase()),
|
||||||
|
)
|
||||||
|
: allPermissionNodes;
|
||||||
|
|
||||||
|
function toggleEditRole(id: string) {
|
||||||
|
if (editSelectedRoles.includes(id)) {
|
||||||
|
editSelectedRoles = editSelectedRoles.filter((r) => r !== id);
|
||||||
|
} else {
|
||||||
|
editSelectedRoles = [...editSelectedRoles, id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEditPermission(node: string) {
|
||||||
|
if (editSelectedPermissions.includes(node)) {
|
||||||
|
editSelectedPermissions = editSelectedPermissions.filter(
|
||||||
|
(p) => p !== node,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
editSelectedPermissions = [...editSelectedPermissions, node];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCustomPermission() {
|
||||||
|
const node = customPermNode.trim();
|
||||||
|
customPermError = "";
|
||||||
|
if (!node) return;
|
||||||
|
// Validate permission node format per PERMISSIONS.md:
|
||||||
|
// - dot-separated tokens of lowercase alphanumeric + underscores
|
||||||
|
// - special tokens: * (wildcard), ? (single-char wildcard)
|
||||||
|
// - inclusive list [a,b,c] or exclusive list <a,b,c>
|
||||||
|
// - standalone * for full access
|
||||||
|
if (
|
||||||
|
!/^(?:\*|(?:(?:[a-z0-9_]+|\*|\?|\[[a-z0-9_]+(?:,[a-z0-9_]+)*\]|<[a-z0-9_]+(?:,[a-z0-9_]+)*>)(?:\.(?:[a-z0-9_]+|\*|\?|\[[a-z0-9_]+(?:,[a-z0-9_]+)*\]|<[a-z0-9_]+(?:,[a-z0-9_]+)*>))*))$/.test(
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
customPermError =
|
||||||
|
"Use dot-separated tokens (e.g. resource.action). Supports: a-z, 0-9, underscores, * ? wildcards, [a,b] or <a,b> lists.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editSelectedPermissions.includes(node)) {
|
||||||
|
customPermError = "This permission is already selected.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!allPermissionNodes.includes(node)) {
|
||||||
|
customNodes = [...customNodes, node];
|
||||||
|
}
|
||||||
|
editSelectedPermissions = [...editSelectedPermissions, node];
|
||||||
|
customPermNode = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditEnhance: SubmitFunction = () => {
|
||||||
|
isEditing = true;
|
||||||
|
editError = "";
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
isEditing = false;
|
||||||
|
if (result.type === "success") {
|
||||||
|
onSuccess();
|
||||||
|
} else if (result.type === "failure") {
|
||||||
|
editError =
|
||||||
|
(result.data as { message?: string })?.message ??
|
||||||
|
"Failed to update user.";
|
||||||
|
}
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="confirm-backdrop"
|
||||||
|
on:click={onClose}
|
||||||
|
on:keydown={(e) => e.key === "Escape" && onClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="edit-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="edit-title"
|
||||||
|
tabindex="-1"
|
||||||
|
on:click|stopPropagation
|
||||||
|
on:keydown|stopPropagation
|
||||||
|
>
|
||||||
|
<h3 id="edit-title" class="edit-dialog-title">Edit User</h3>
|
||||||
|
<p class="edit-dialog-sub">
|
||||||
|
Update information for <strong>{user.name}</strong>
|
||||||
|
</p>
|
||||||
|
{#if editError}
|
||||||
|
<p class="confirm-error">{editError}</p>
|
||||||
|
{/if}
|
||||||
|
<form method="POST" action="?/updateUser" use:enhance={handleEditEnhance}>
|
||||||
|
<input type="hidden" name="id" value={user.id} />
|
||||||
|
{#if canEditRoles}
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="roles"
|
||||||
|
value={JSON.stringify(editSelectedRoles)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if canEditPermissions}
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="permissions"
|
||||||
|
value={JSON.stringify(editSelectedPermissions)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="edit-field">
|
||||||
|
<label for="edit-name">Name</label>
|
||||||
|
<input
|
||||||
|
id="edit-name"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
bind:value={editName}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="edit-field">
|
||||||
|
<label for="edit-image">Avatar URL</label>
|
||||||
|
<input
|
||||||
|
id="edit-image"
|
||||||
|
type="text"
|
||||||
|
name="image"
|
||||||
|
bind:value={editImage}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Role assignment (gated by user.roles.other) -->
|
||||||
|
{#if canEditRoles}
|
||||||
|
<div class="edit-section">
|
||||||
|
<div class="edit-section-label">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
>
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||||
|
</svg>
|
||||||
|
Roles
|
||||||
|
<span class="edit-section-count"
|
||||||
|
>{editSelectedRoles.length} selected</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="edit-role-list">
|
||||||
|
{#each allRoles as role (role.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="edit-role-chip"
|
||||||
|
class:selected={editSelectedRoles.includes(role.id)}
|
||||||
|
on:click={() => toggleEditRole(role.id)}
|
||||||
|
>
|
||||||
|
<span class="edit-role-check">
|
||||||
|
{#if editSelectedRoles.includes(role.id)}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="edit-role-chip-title">{role.title}</span>
|
||||||
|
<span class="edit-role-chip-moniker">{role.moniker}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Direct permission assignment (gated by user.permissions.other) -->
|
||||||
|
{#if canEditPermissions}
|
||||||
|
<div class="edit-section">
|
||||||
|
<div class="edit-section-label">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"
|
||||||
|
/>
|
||||||
|
<rect x="9" y="3" width="6" height="4" rx="1" />
|
||||||
|
</svg>
|
||||||
|
Direct Permissions
|
||||||
|
<span class="edit-section-count"
|
||||||
|
>{editSelectedPermissions.length} selected</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="edit-perm-search-wrap">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="edit-perm-search"
|
||||||
|
placeholder="Search permissions…"
|
||||||
|
bind:value={permSearchQuery}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="edit-custom-perm-wrap">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="edit-custom-perm-input"
|
||||||
|
placeholder="Add custom node… (e.g. my.custom.node)"
|
||||||
|
bind:value={customPermNode}
|
||||||
|
on:keydown={(e) =>
|
||||||
|
e.key === "Enter" &&
|
||||||
|
(e.preventDefault(), addCustomPermission())}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="edit-custom-perm-btn"
|
||||||
|
on:click={addCustomPermission}
|
||||||
|
disabled={!customPermNode.trim()}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if customPermError}
|
||||||
|
<p class="edit-custom-perm-error">{customPermError}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="edit-perm-list">
|
||||||
|
{#each filteredPermNodes as node}
|
||||||
|
<label class="edit-perm-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editSelectedPermissions.includes(node)}
|
||||||
|
on:change={() => toggleEditPermission(node)}
|
||||||
|
/>
|
||||||
|
<span class="edit-perm-node">{node}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
{#if filteredPermNodes.length === 0}
|
||||||
|
<p class="edit-perm-empty">No permissions match your search.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-cancel"
|
||||||
|
on:click={onClose}
|
||||||
|
disabled={isEditing}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-save"
|
||||||
|
disabled={isEditing || !editName.trim()}
|
||||||
|
>
|
||||||
|
{isEditing ? "Saving…" : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ── Backdrop & dialog ── */
|
||||||
|
.confirm-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-dialog {
|
||||||
|
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: 520px;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 28px 24px 22px;
|
||||||
|
animation: modalIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.97);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-dialog-title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-dialog-sub {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-error {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form fields ── */
|
||||||
|
.edit-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-field label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-field input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-field input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--input-focus-border);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Actions ── */
|
||||||
|
.confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover:not(:disabled) {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
border-color: var(--border-default);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--accent-color, #0066cc);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: #fff;
|
||||||
|
transition: filter 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Edit sections (roles & permissions) ── */
|
||||||
|
.edit-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-section-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-section-label svg {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-section-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Role chips ── */
|
||||||
|
.edit-role-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
background 0.12s,
|
||||||
|
border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-chip:hover {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-chip.selected {
|
||||||
|
background: rgba(0, 102, 204, 0.08);
|
||||||
|
border-color: var(--accent-color, #0066cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1.5px solid var(--border-subtle);
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--accent-color, #0066cc);
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-chip.selected .edit-role-check {
|
||||||
|
border-color: var(--accent-color, #0066cc);
|
||||||
|
background: var(--accent-color, #0066cc);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-chip-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-chip-moniker {
|
||||||
|
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Custom permission input ── */
|
||||||
|
.edit-custom-perm-wrap {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-custom-perm-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-custom-perm-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-custom-perm-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--input-focus-border);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-custom-perm-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--accent-color, #0066cc);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: filter 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-custom-perm-btn:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-custom-perm-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-custom-perm-error {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Permission list ── */
|
||||||
|
.edit-perm-search-wrap {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-perm-search {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-perm-search::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-perm-search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--input-focus-border);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-perm-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-perm-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-perm-item:hover {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-perm-item input[type="checkbox"] {
|
||||||
|
accent-color: var(--accent-color, #0066cc);
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-perm-node {
|
||||||
|
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-perm-empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbars */
|
||||||
|
.edit-role-list::-webkit-scrollbar,
|
||||||
|
.edit-perm-list::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-list::-webkit-scrollbar-track,
|
||||||
|
.edit-perm-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-list::-webkit-scrollbar-thumb,
|
||||||
|
.edit-perm-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar-thumb);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,999 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { unifi, type UnifiSite } from "$lib/optima-api/modules/unifi";
|
||||||
|
|
||||||
|
export let isOpen = false;
|
||||||
|
export let accessToken: string;
|
||||||
|
export let companyId: string;
|
||||||
|
export let linkedSiteIds: string[] = [];
|
||||||
|
export let onSuccess: () => void = () => {};
|
||||||
|
|
||||||
|
type ModalStep = "choose" | "existing" | "new";
|
||||||
|
let step: ModalStep = "choose";
|
||||||
|
|
||||||
|
// Existing site linking
|
||||||
|
let allSites: UnifiSite[] = [];
|
||||||
|
let isLoadingSites = false;
|
||||||
|
let selectedSiteId = "";
|
||||||
|
let isLinking = false;
|
||||||
|
let linkError = "";
|
||||||
|
let siteSearch = "";
|
||||||
|
|
||||||
|
// New site creation
|
||||||
|
let newSiteName = "";
|
||||||
|
let isCreating = false;
|
||||||
|
let createError = "";
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
step = "choose";
|
||||||
|
allSites = [];
|
||||||
|
isLoadingSites = false;
|
||||||
|
selectedSiteId = "";
|
||||||
|
isLinking = false;
|
||||||
|
linkError = "";
|
||||||
|
siteSearch = "";
|
||||||
|
newSiteName = "";
|
||||||
|
isCreating = false;
|
||||||
|
createError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
isOpen = false;
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToExisting() {
|
||||||
|
step = "existing";
|
||||||
|
linkError = "";
|
||||||
|
isLoadingSites = true;
|
||||||
|
try {
|
||||||
|
const result = await unifi.fetchSites(accessToken);
|
||||||
|
// Filter out sites already linked to this company
|
||||||
|
allSites = (result?.data ?? []).filter(
|
||||||
|
(s: UnifiSite) => !linkedSiteIds.includes(s.id),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch UniFi sites:", err);
|
||||||
|
linkError = err instanceof Error ? err.message : "Failed to load sites";
|
||||||
|
allSites = [];
|
||||||
|
} finally {
|
||||||
|
isLoadingSites = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToNew() {
|
||||||
|
step = "new";
|
||||||
|
createError = "";
|
||||||
|
newSiteName = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
step = "choose";
|
||||||
|
linkError = "";
|
||||||
|
createError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkExistingSite() {
|
||||||
|
if (!selectedSiteId || !accessToken) return;
|
||||||
|
isLinking = true;
|
||||||
|
linkError = "";
|
||||||
|
try {
|
||||||
|
await unifi.linkSite(accessToken, selectedSiteId, companyId);
|
||||||
|
close();
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
linkError = err instanceof Error ? err.message : "Failed to link site";
|
||||||
|
console.error("Failed to link UniFi site:", err);
|
||||||
|
} finally {
|
||||||
|
isLinking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAndLinkSite() {
|
||||||
|
if (!newSiteName.trim() || !accessToken) return;
|
||||||
|
isCreating = true;
|
||||||
|
createError = "";
|
||||||
|
try {
|
||||||
|
const result = await unifi.createSite(accessToken, newSiteName.trim());
|
||||||
|
const newSiteId = result?.data?.id;
|
||||||
|
if (newSiteId && companyId) {
|
||||||
|
await unifi.linkSite(accessToken, newSiteId, companyId);
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
createError =
|
||||||
|
err instanceof Error ? err.message : "Failed to create site";
|
||||||
|
console.error("Failed to create UniFi site:", err);
|
||||||
|
} finally {
|
||||||
|
isCreating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter sites that are unlinked (no company) or linked to a different company
|
||||||
|
$: availableSites = allSites.filter(
|
||||||
|
(s) => !s.companyId || s.companyId !== companyId,
|
||||||
|
);
|
||||||
|
$: unlinkedSites = availableSites.filter((s) => !s.companyId);
|
||||||
|
$: otherCompanySites = availableSites.filter(
|
||||||
|
(s) => s.companyId && s.companyId !== companyId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Search-filtered lists
|
||||||
|
$: filteredUnlinked = siteSearch.trim()
|
||||||
|
? unlinkedSites.filter((s) => {
|
||||||
|
const q = siteSearch.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
s.name.toLowerCase().includes(q) || s.siteId.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: unlinkedSites;
|
||||||
|
$: filteredOtherCompany = siteSearch.trim()
|
||||||
|
? otherCompanySites.filter((s) => {
|
||||||
|
const q = siteSearch.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
s.name.toLowerCase().includes(q) ||
|
||||||
|
s.siteId.toLowerCase().includes(q) ||
|
||||||
|
(s.company?.name ?? "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: otherCompanySites;
|
||||||
|
$: totalFiltered = filteredUnlinked.length + filteredOtherCompany.length;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div class="modal-overlay" on:click={close}>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div class="modal-container" on:click|stopPropagation>
|
||||||
|
<div class="modal-header">
|
||||||
|
{#if step !== "choose"}
|
||||||
|
<button
|
||||||
|
class="modal-back"
|
||||||
|
on:click={goBack}
|
||||||
|
type="button"
|
||||||
|
aria-label="Back"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<h3 class="modal-title">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
>
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||||
|
<path d="M2 17l10 5 10-5" />
|
||||||
|
<path d="M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
{#if step === "choose"}
|
||||||
|
Link UniFi Site
|
||||||
|
{:else if step === "existing"}
|
||||||
|
Link Existing Site
|
||||||
|
{:else}
|
||||||
|
Create New Site
|
||||||
|
{/if}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
class="modal-close"
|
||||||
|
on:click={close}
|
||||||
|
type="button"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
{#if step === "choose"}
|
||||||
|
<p class="modal-description">
|
||||||
|
How would you like to add a UniFi site?
|
||||||
|
</p>
|
||||||
|
<div class="choice-grid">
|
||||||
|
<button class="choice-card" on:click={goToNew} type="button">
|
||||||
|
<div class="choice-icon">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
width="28"
|
||||||
|
height="28"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="8" x2="12" y2="16" />
|
||||||
|
<line x1="8" y1="12" x2="16" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="choice-label">New Site</span>
|
||||||
|
<span class="choice-desc"
|
||||||
|
>Create a new site on the UniFi controller</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<button class="choice-card" on:click={goToExisting} type="button">
|
||||||
|
<div class="choice-icon">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
width="28"
|
||||||
|
height="28"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="choice-label">Existing Site</span>
|
||||||
|
<span class="choice-desc"
|
||||||
|
>Link an existing UniFi site to this company</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if step === "existing"}
|
||||||
|
{#if isLoadingSites}
|
||||||
|
<div class="modal-loading">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" 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 sites…</span>
|
||||||
|
</div>
|
||||||
|
{:else if availableSites.length === 0}
|
||||||
|
<div class="modal-empty">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
>
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||||
|
<path d="M2 17l10 5 10-5" />
|
||||||
|
<path d="M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
<p>No available sites to link</p>
|
||||||
|
<span class="modal-empty-hint"
|
||||||
|
>All sites are already linked to this company, or no sites
|
||||||
|
exist.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Search input -->
|
||||||
|
<div class="site-search-bar">
|
||||||
|
<svg
|
||||||
|
class="site-search-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
class="site-search-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search sites…"
|
||||||
|
bind:value={siteSearch}
|
||||||
|
disabled={isLinking}
|
||||||
|
/>
|
||||||
|
{#if siteSearch}
|
||||||
|
<button
|
||||||
|
class="site-search-clear"
|
||||||
|
type="button"
|
||||||
|
on:click={() => (siteSearch = "")}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
>
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scrollable site list -->
|
||||||
|
<div class="site-list">
|
||||||
|
{#if totalFiltered === 0}
|
||||||
|
<div class="site-list-empty">
|
||||||
|
<p>No sites matching "{siteSearch}"</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if filteredUnlinked.length > 0}
|
||||||
|
<div class="site-list-group-label">Unlinked Sites</div>
|
||||||
|
{#each filteredUnlinked as site (site.id)}
|
||||||
|
<button
|
||||||
|
class="site-list-item"
|
||||||
|
class:selected={selectedSiteId === site.id}
|
||||||
|
type="button"
|
||||||
|
on:click={() => (selectedSiteId = site.id)}
|
||||||
|
disabled={isLinking}
|
||||||
|
>
|
||||||
|
<div class="site-list-item-icon">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
>
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||||
|
<path d="M2 17l10 5 10-5" />
|
||||||
|
<path d="M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="site-list-item-info">
|
||||||
|
<span class="site-list-item-name">{site.name}</span>
|
||||||
|
<span class="site-list-item-id">{site.siteId}</span>
|
||||||
|
</div>
|
||||||
|
{#if selectedSiteId === site.id}
|
||||||
|
<svg
|
||||||
|
class="site-list-item-check"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{#if filteredOtherCompany.length > 0}
|
||||||
|
<div class="site-list-group-label">
|
||||||
|
Linked to Other Companies
|
||||||
|
</div>
|
||||||
|
{#each filteredOtherCompany as site (site.id)}
|
||||||
|
<button
|
||||||
|
class="site-list-item"
|
||||||
|
class:selected={selectedSiteId === site.id}
|
||||||
|
type="button"
|
||||||
|
on:click={() => (selectedSiteId = site.id)}
|
||||||
|
disabled={isLinking}
|
||||||
|
>
|
||||||
|
<div class="site-list-item-icon">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
>
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||||
|
<path d="M2 17l10 5 10-5" />
|
||||||
|
<path d="M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="site-list-item-info">
|
||||||
|
<span class="site-list-item-name">{site.name}</span>
|
||||||
|
<span class="site-list-item-id"
|
||||||
|
>{site.company?.name ?? "Unknown"}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if selectedSiteId === site.id}
|
||||||
|
<svg
|
||||||
|
class="site-list-item-check"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if linkError}
|
||||||
|
<div class="modal-error">
|
||||||
|
<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>
|
||||||
|
{linkError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{:else if step === "new"}
|
||||||
|
<div class="modal-field">
|
||||||
|
<label class="modal-label" for="new-unifi-site-name"
|
||||||
|
>Site Name</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="new-unifi-site-name"
|
||||||
|
class="modal-input"
|
||||||
|
type="text"
|
||||||
|
bind:value={newSiteName}
|
||||||
|
placeholder="e.g. Main Office"
|
||||||
|
disabled={isCreating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if createError}
|
||||||
|
<div class="modal-error">
|
||||||
|
<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>
|
||||||
|
{createError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if step !== "choose"}
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
class="modal-btn modal-btn-cancel"
|
||||||
|
on:click={close}
|
||||||
|
type="button"
|
||||||
|
disabled={isLinking || isCreating}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{#if step === "existing"}
|
||||||
|
<button
|
||||||
|
class="modal-btn modal-btn-primary"
|
||||||
|
on:click={linkExistingSite}
|
||||||
|
type="button"
|
||||||
|
disabled={isLinking || !selectedSiteId}
|
||||||
|
>
|
||||||
|
{isLinking ? "Linking…" : "Link Site"}
|
||||||
|
</button>
|
||||||
|
{:else if step === "new"}
|
||||||
|
<button
|
||||||
|
class="modal-btn modal-btn-primary"
|
||||||
|
on:click={createAndLinkSite}
|
||||||
|
type="button"
|
||||||
|
disabled={isCreating || !newSiteName.trim()}
|
||||||
|
>
|
||||||
|
{isCreating ? "Creating…" : "Create & Link"}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
width: 480px;
|
||||||
|
max-width: 90vw;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-back:hover {
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Choice cards */
|
||||||
|
.choice-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 24px 16px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-base);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-card:hover {
|
||||||
|
border-color: var(--input-focus-border);
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--input-focus-border) 5%,
|
||||||
|
var(--bg-base)
|
||||||
|
);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--input-focus-border) 10%, transparent);
|
||||||
|
color: var(--input-focus-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form fields */
|
||||||
|
.modal-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-input:focus {
|
||||||
|
border-color: var(--input-focus-border);
|
||||||
|
box-shadow: 0 0 0 2px
|
||||||
|
color-mix(in srgb, var(--input-focus-border) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--status-error) 10%, transparent);
|
||||||
|
color: var(--status-error);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 32px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-empty p {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-empty-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn {
|
||||||
|
padding: 7px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-cancel {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-cancel:hover:not(:disabled) {
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-primary {
|
||||||
|
background: var(--accent-color, #0066cc);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-primary:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Site search bar */
|
||||||
|
.site-search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: var(--bg-base);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-search-bar:focus-within {
|
||||||
|
border-color: var(--input-focus-border);
|
||||||
|
box-shadow: 0 0 0 2px
|
||||||
|
color-mix(in srgb, var(--input-focus-border) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-search-icon {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-search-bar:focus-within .site-search-icon {
|
||||||
|
color: var(--input-focus-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-search-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-search-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-search-clear {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-search-clear:hover {
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollable site list */
|
||||||
|
.site-list {
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-list-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-list-empty p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-list-group-label {
|
||||||
|
padding: 8px 12px 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg-base);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.12s;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-list-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-list-item:hover:not(:disabled) {
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-list-item.selected {
|
||||||
|
background: color-mix(in srgb, var(--input-focus-border) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-list-item:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-list-item-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--input-focus-border) 8%, transparent);
|
||||||
|
color: var(--input-focus-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-list-item.selected .site-list-item-icon {
|
||||||
|
background: color-mix(in srgb, var(--input-focus-border) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-list-item-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-list-item-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-list-item-id {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-list-item-check {
|
||||||
|
color: var(--input-focus-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner animation */
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.spin-icon) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { unifi } from "$lib/optima-api/modules/unifi";
|
||||||
|
|
||||||
|
export let isOpen = false;
|
||||||
|
export let accessToken: string;
|
||||||
|
export let companyId: string;
|
||||||
|
export let onSuccess: () => void = () => {};
|
||||||
|
|
||||||
|
let siteName = "";
|
||||||
|
let isSubmitting = false;
|
||||||
|
let submitError = "";
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
siteName = "";
|
||||||
|
isSubmitting = false;
|
||||||
|
submitError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
isOpen = false;
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!siteName.trim() || !accessToken) return;
|
||||||
|
isSubmitting = true;
|
||||||
|
submitError = "";
|
||||||
|
try {
|
||||||
|
// Create the site on the UniFi controller
|
||||||
|
const result = await unifi.createSite(accessToken, siteName.trim());
|
||||||
|
const newSiteId = result?.data?.id;
|
||||||
|
if (newSiteId && companyId) {
|
||||||
|
// Link it to this company
|
||||||
|
await unifi.linkSite(accessToken, newSiteId, companyId);
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
submitError =
|
||||||
|
err instanceof Error ? err.message : "Failed to create UniFi site";
|
||||||
|
console.error("Failed to create UniFi site:", err);
|
||||||
|
} finally {
|
||||||
|
isSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div class="modal-overlay" on:click={close}>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div class="modal-container" on:click|stopPropagation>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
>
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||||
|
<path d="M2 17l10 5 10-5" />
|
||||||
|
<path d="M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
New UniFi Site
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
class="modal-close"
|
||||||
|
on:click={close}
|
||||||
|
type="button"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="modal-field">
|
||||||
|
<label class="modal-label" for="unifi-site-name">Site Name</label>
|
||||||
|
<input
|
||||||
|
id="unifi-site-name"
|
||||||
|
class="modal-input"
|
||||||
|
type="text"
|
||||||
|
bind:value={siteName}
|
||||||
|
placeholder="e.g. Main Office"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if submitError}
|
||||||
|
<div class="modal-error">
|
||||||
|
<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>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
class="modal-btn modal-btn-cancel"
|
||||||
|
on:click={close}
|
||||||
|
type="button"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="modal-btn modal-btn-primary"
|
||||||
|
on:click={handleSubmit}
|
||||||
|
type="button"
|
||||||
|
disabled={isSubmitting || !siteName.trim()}
|
||||||
|
>
|
||||||
|
{#if isSubmitting}
|
||||||
|
Creating…
|
||||||
|
{:else}
|
||||||
|
Create Site
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
width: 420px;
|
||||||
|
max-width: 90vw;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-input:focus {
|
||||||
|
border-color: var(--input-focus-border);
|
||||||
|
box-shadow: 0 0 0 2px
|
||||||
|
color-mix(in srgb, var(--input-focus-border) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--status-error) 10%, transparent);
|
||||||
|
color: var(--status-error);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn {
|
||||||
|
padding: 7px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-cancel {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-cancel:hover:not(:disabled) {
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-primary {
|
||||||
|
background: var(--accent-color, #0066cc);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-primary:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.12);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,6 +11,8 @@ export const optima = {
|
|||||||
role: (await import("./optima-api/modules/roles")).role,
|
role: (await import("./optima-api/modules/roles")).role,
|
||||||
permission: (await import("./optima-api/modules/permissions")).permission,
|
permission: (await import("./optima-api/modules/permissions")).permission,
|
||||||
user,
|
user,
|
||||||
|
users: (await import("./optima-api/modules/users")).users,
|
||||||
|
unifi: (await import("./optima-api/modules/unifi")).unifi,
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* @TODO
|
* @TODO
|
||||||
|
|||||||
@@ -4,10 +4,16 @@ export const company = {
|
|||||||
async fetch(
|
async fetch(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
id: string,
|
id: string,
|
||||||
options?: { includeAddress?: boolean },
|
options?: {
|
||||||
|
includeAddress?: boolean;
|
||||||
|
includePrimaryContact?: boolean;
|
||||||
|
includeAllContacts?: boolean;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (options?.includeAddress) params.includeAddress = "true";
|
if (options?.includeAddress) params.includeAddress = "true";
|
||||||
|
if (options?.includePrimaryContact) params.includePrimaryContact = "true";
|
||||||
|
if (options?.includeAllContacts) params.includeAllContacts = "true";
|
||||||
|
|
||||||
const company = await api.get(`/v1/company/companies/${id}`, {
|
const company = await api.get(`/v1/company/companies/${id}`, {
|
||||||
params,
|
params,
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ export interface CredentialTypeField {
|
|||||||
name: string;
|
name: string;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
valueType: "plain_text" | "password" | "number" | "email" | "url";
|
valueType: string;
|
||||||
|
subFields?: CredentialTypeField[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CredentialType {
|
export interface CredentialType {
|
||||||
|
|||||||
@@ -2,15 +2,20 @@ import api from "../axios";
|
|||||||
|
|
||||||
export interface CredentialField {
|
export interface CredentialField {
|
||||||
id: string;
|
id: string;
|
||||||
fieldId: string;
|
name: string;
|
||||||
|
secure: boolean;
|
||||||
|
required: boolean;
|
||||||
|
valueType: string;
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Credential {
|
export interface Credential {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
notes?: string;
|
||||||
typeId: string;
|
typeId: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
|
subCredentialOfId?: string;
|
||||||
fields: CredentialField[];
|
fields: CredentialField[];
|
||||||
type?: {
|
type?: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -45,6 +50,7 @@ export const credential = {
|
|||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -62,7 +68,11 @@ export const credential = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async update(accessToken: string, id: string, data: { name: string }) {
|
async update(
|
||||||
|
accessToken: string,
|
||||||
|
id: string,
|
||||||
|
data: { name?: string; notes?: string },
|
||||||
|
) {
|
||||||
const response = await api.patch(`/v1/credential/credentials/${id}`, data, {
|
const response = await api.patch(`/v1/credential/credentials/${id}`, data, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
@@ -96,4 +106,79 @@ export const credential = {
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async fetchSecureValue(
|
||||||
|
accessToken: string,
|
||||||
|
credentialId: string,
|
||||||
|
fieldId: string,
|
||||||
|
) {
|
||||||
|
const response = await api.get(
|
||||||
|
`/v1/credential/credentials/${credentialId}/secure-values/${fieldId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchValueTypes(accessToken: string) {
|
||||||
|
const response = await api.get("/v1/credential/valuetypes", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchSubCredentials(accessToken: string, credentialId: string) {
|
||||||
|
const response = await api.get(
|
||||||
|
`/v1/credential/credentials/${credentialId}/sub-credentials`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async addSubCredential(
|
||||||
|
accessToken: string,
|
||||||
|
credentialId: string,
|
||||||
|
data: {
|
||||||
|
fieldId: string;
|
||||||
|
name: string;
|
||||||
|
fields: Array<{ fieldId: string; value: string }>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const response = await api.post(
|
||||||
|
`/v1/credential/credentials/${credentialId}/sub-credentials`,
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeSubCredential(
|
||||||
|
accessToken: string,
|
||||||
|
credentialId: string,
|
||||||
|
subId: string,
|
||||||
|
) {
|
||||||
|
const response = await api.delete(
|
||||||
|
`/v1/credential/credentials/${credentialId}/sub-credentials/${subId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import api from "../axios";
|
||||||
|
|
||||||
|
export interface UnifiSite {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
siteId: string;
|
||||||
|
companyId: string | null;
|
||||||
|
company?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiSiteOverview {
|
||||||
|
health: Array<{
|
||||||
|
subsystem: string;
|
||||||
|
status: string;
|
||||||
|
numAdopted?: number;
|
||||||
|
numGateway?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}>;
|
||||||
|
sysInfo: {
|
||||||
|
timezone?: string;
|
||||||
|
hostname?: string;
|
||||||
|
version?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
siteInfo: {
|
||||||
|
description?: string;
|
||||||
|
name?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiDevice {
|
||||||
|
id: string;
|
||||||
|
mac: string;
|
||||||
|
model: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
state: string | number;
|
||||||
|
ip: string;
|
||||||
|
version: string;
|
||||||
|
uptime: number;
|
||||||
|
radios?: unknown[];
|
||||||
|
uplink?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiWifiNetwork {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
siteId?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
security?: string;
|
||||||
|
wpaMode?: string;
|
||||||
|
wpaEnc?: string;
|
||||||
|
wpa3Support?: boolean;
|
||||||
|
wpa3Transition?: boolean;
|
||||||
|
wpa3FastRoaming?: boolean;
|
||||||
|
wpa3Enhanced192?: boolean;
|
||||||
|
passphrase?: string;
|
||||||
|
passphraseAutogenerated?: boolean;
|
||||||
|
hideSSID?: boolean;
|
||||||
|
isGuest?: boolean;
|
||||||
|
band?: string;
|
||||||
|
bands?: string[];
|
||||||
|
networkconfId?: string;
|
||||||
|
usergroupId?: string;
|
||||||
|
apGroupIds?: string[];
|
||||||
|
apGroupMode?: string;
|
||||||
|
pmfMode?: string;
|
||||||
|
groupRekey?: number;
|
||||||
|
dtimMode?: string;
|
||||||
|
dtimNg?: number;
|
||||||
|
dtimNa?: number;
|
||||||
|
dtim6e?: number;
|
||||||
|
l2Isolation?: boolean;
|
||||||
|
fastRoamingEnabled?: boolean;
|
||||||
|
bssTransition?: boolean;
|
||||||
|
uapsdEnabled?: boolean;
|
||||||
|
iappEnabled?: boolean;
|
||||||
|
proxyArp?: boolean;
|
||||||
|
mcastenhanceEnabled?: boolean;
|
||||||
|
macFilterEnabled?: boolean;
|
||||||
|
macFilterPolicy?: string;
|
||||||
|
macFilterList?: string[];
|
||||||
|
radiusDasEnabled?: boolean;
|
||||||
|
radiusMacAuthEnabled?: boolean;
|
||||||
|
radiusMacaclFormat?: string;
|
||||||
|
minrateSettingPreference?: string;
|
||||||
|
minrateNgEnabled?: boolean;
|
||||||
|
minrateNgDataRateKbps?: number;
|
||||||
|
minrateNgAdvertisingRates?: boolean;
|
||||||
|
minrateNaEnabled?: boolean;
|
||||||
|
minrateNaDataRateKbps?: number;
|
||||||
|
minrateNaAdvertisingRates?: boolean;
|
||||||
|
settingPreference?: string;
|
||||||
|
no2ghzOui?: boolean;
|
||||||
|
privatePreSharedKeysEnabled?: boolean;
|
||||||
|
privatePreSharedKeys?: unknown[];
|
||||||
|
saeGroups?: unknown[];
|
||||||
|
saePsk?: unknown[];
|
||||||
|
schedule?: unknown[];
|
||||||
|
scheduleWithDuration?: unknown[];
|
||||||
|
bcFilterList?: unknown[];
|
||||||
|
externalId?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiNetwork {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
purpose: string;
|
||||||
|
subnet: string;
|
||||||
|
vlanId: number | null;
|
||||||
|
dhcpEnabled: boolean;
|
||||||
|
dhcpStart: string;
|
||||||
|
dhcpStop: string;
|
||||||
|
domainName: string;
|
||||||
|
isNat: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiWlanGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
siteId: string;
|
||||||
|
noDelete: boolean;
|
||||||
|
noEdit: boolean;
|
||||||
|
hidden: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiApGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
deviceMacs: string[];
|
||||||
|
noDelete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiAccessPoint {
|
||||||
|
id: string;
|
||||||
|
mac: string;
|
||||||
|
model: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
state: number;
|
||||||
|
adopted: boolean;
|
||||||
|
ip: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiSpeedProfile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
siteId: string;
|
||||||
|
noDelete: boolean;
|
||||||
|
downloadLimitKbps: number;
|
||||||
|
uploadLimitKbps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiPPSK {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
mac: string | null;
|
||||||
|
vlanId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const unifi = {
|
||||||
|
/** Fetch all UniFi sites */
|
||||||
|
async fetchSites(accessToken: string) {
|
||||||
|
const response = await api.get("/v1/unifi/sites", {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Sync sites from UniFi controller */
|
||||||
|
async syncSites(accessToken: string) {
|
||||||
|
const response = await api.post(
|
||||||
|
"/v1/unifi/sites/sync",
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Create a new UniFi site */
|
||||||
|
async createSite(accessToken: string, description: string) {
|
||||||
|
const response = await api.post(
|
||||||
|
"/v1/unifi/sites/create",
|
||||||
|
{ description },
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Fetch a single UniFi site */
|
||||||
|
async fetchSite(accessToken: string, id: string) {
|
||||||
|
const response = await api.get(`/v1/unifi/site/${id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Fetch UniFi sites linked to a company */
|
||||||
|
async fetchCompanySites(accessToken: string, companyId: string) {
|
||||||
|
const response = await api.get(
|
||||||
|
`/v1/company/companies/${companyId}/unifi/sites`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Link a site to a company */
|
||||||
|
async linkSite(accessToken: string, siteId: string, companyId: string) {
|
||||||
|
const response = await api.post(
|
||||||
|
`/v1/unifi/site/${siteId}/link`,
|
||||||
|
{ companyId },
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Unlink a site from its company */
|
||||||
|
async unlinkSite(accessToken: string, siteId: string) {
|
||||||
|
const response = await api.post(
|
||||||
|
`/v1/unifi/site/${siteId}/unlink`,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get site overview (health, sysInfo, siteInfo) */
|
||||||
|
async fetchSiteOverview(accessToken: string, siteId: string) {
|
||||||
|
const response = await api.get(`/v1/unifi/site/${siteId}/overview`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get site devices */
|
||||||
|
async fetchSiteDevices(accessToken: string, siteId: string) {
|
||||||
|
const response = await api.get(`/v1/unifi/site/${siteId}/devices`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get site WiFi networks */
|
||||||
|
async fetchSiteWifi(accessToken: string, siteId: string) {
|
||||||
|
const response = await api.get(`/v1/unifi/site/${siteId}/wifi`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Update a WiFi network */
|
||||||
|
async updateWifi(
|
||||||
|
accessToken: string,
|
||||||
|
siteId: string,
|
||||||
|
wlanId: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const response = await api.patch(
|
||||||
|
`/v1/unifi/site/${siteId}/wifi/${wlanId}`,
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get site networks */
|
||||||
|
async fetchSiteNetworks(accessToken: string, siteId: string) {
|
||||||
|
const response = await api.get(`/v1/unifi/site/${siteId}/networks`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get WLAN groups */
|
||||||
|
async fetchWlanGroups(accessToken: string, siteId: string) {
|
||||||
|
const response = await api.get(`/v1/unifi/site/${siteId}/wlan-groups`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get AP groups (collections of access points for broadcasting) */
|
||||||
|
async fetchApGroups(accessToken: string, siteId: string) {
|
||||||
|
const response = await api.get(`/v1/unifi/site/${siteId}/ap-groups`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get access points */
|
||||||
|
async fetchAccessPoints(accessToken: string, siteId: string) {
|
||||||
|
const response = await api.get(`/v1/unifi/site/${siteId}/access-points`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get speed profiles (user groups) */
|
||||||
|
async fetchSpeedProfiles(accessToken: string, siteId: string) {
|
||||||
|
const response = await api.get(`/v1/unifi/site/${siteId}/speed-profiles`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Create a speed profile */
|
||||||
|
async createSpeedProfile(
|
||||||
|
accessToken: string,
|
||||||
|
siteId: string,
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
downloadLimitKbps?: number;
|
||||||
|
uploadLimitKbps?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const response = await api.post(
|
||||||
|
`/v1/unifi/site/${siteId}/speed-profiles`,
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get private PSKs for a WLAN */
|
||||||
|
async fetchPPSKs(accessToken: string, siteId: string, wlanId: string) {
|
||||||
|
const response = await api.get(
|
||||||
|
`/v1/unifi/site/${siteId}/wifi/${wlanId}/ppsk`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Create a private PSK on a WLAN */
|
||||||
|
async createPPSK(
|
||||||
|
accessToken: string,
|
||||||
|
siteId: string,
|
||||||
|
wlanId: string,
|
||||||
|
data: { key: string; name: string; mac?: string; vlanId?: number },
|
||||||
|
) {
|
||||||
|
const response = await api.post(
|
||||||
|
`/v1/unifi/site/${siteId}/wifi/${wlanId}/ppsk`,
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get WiFi limits per AP per radio */
|
||||||
|
async fetchWifiLimits(accessToken: string, siteId: string) {
|
||||||
|
const response = await api.get(`/v1/unifi/site/${siteId}/wifi-limits`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -89,8 +89,8 @@ export const user = {
|
|||||||
} catch {}
|
} catch {}
|
||||||
reject(new Error("Timed out waiting for auth callback"));
|
reject(new Error("Timed out waiting for auth callback"));
|
||||||
},
|
},
|
||||||
2 * 60 * 1000,
|
5 * 60 * 1000,
|
||||||
); // 2 minutes
|
); // 5 minutes
|
||||||
|
|
||||||
const handlePayload = (payload: any) => {
|
const handlePayload = (payload: any) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import api from "../axios";
|
||||||
|
import type { Role } from "./roles";
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
login: string;
|
||||||
|
image?: string;
|
||||||
|
roles: string[];
|
||||||
|
permissions?: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionCheckResult {
|
||||||
|
permission: string;
|
||||||
|
hasPermission: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const users = {
|
||||||
|
/**
|
||||||
|
* Fetch all users.
|
||||||
|
* Requires: user.read.other, user.list.other
|
||||||
|
*/
|
||||||
|
async fetchAll(accessToken: string): Promise<{ data: User[] }> {
|
||||||
|
const response = await api.get("/v1/user/users", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a specific user by their ID.
|
||||||
|
* Requires: user.read.other
|
||||||
|
*/
|
||||||
|
async fetch(
|
||||||
|
accessToken: string,
|
||||||
|
identifier: string,
|
||||||
|
): Promise<{ data: User }> {
|
||||||
|
const response = await api.get(`/v1/user/users/${identifier}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a specific user's information.
|
||||||
|
* Requires: user.write.other
|
||||||
|
* Conditional: user.roles.other (if roles included), user.permissions.other (if permissions included)
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
accessToken: string,
|
||||||
|
identifier: string,
|
||||||
|
updates: {
|
||||||
|
name?: string;
|
||||||
|
image?: string;
|
||||||
|
roles?: string[];
|
||||||
|
permissions?: string[];
|
||||||
|
},
|
||||||
|
): Promise<{ data: User }> {
|
||||||
|
const response = await api.patch(`/v1/user/users/${identifier}`, updates, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a specific user.
|
||||||
|
* Requires: user.delete.other
|
||||||
|
*/
|
||||||
|
async delete(
|
||||||
|
accessToken: string,
|
||||||
|
identifier: string,
|
||||||
|
): Promise<{ data: User }> {
|
||||||
|
const response = await api.delete(`/v1/user/users/${identifier}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all roles assigned to a specific user.
|
||||||
|
* Requires: user.read.other, role.read
|
||||||
|
*/
|
||||||
|
async fetchRoles(
|
||||||
|
accessToken: string,
|
||||||
|
identifier: string,
|
||||||
|
): Promise<{ data: Role[] }> {
|
||||||
|
const response = await api.get(`/v1/user/users/${identifier}/roles`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific user has certain permissions.
|
||||||
|
* Requires: user.read.other
|
||||||
|
*/
|
||||||
|
async checkPermissions(
|
||||||
|
accessToken: string,
|
||||||
|
identifier: string,
|
||||||
|
permissions: string[],
|
||||||
|
): Promise<{ data: { results: PermissionCheckResult[] } }> {
|
||||||
|
const response = await api.post(
|
||||||
|
`/v1/user/users/${identifier}/check-permission`,
|
||||||
|
{ permissions },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { optima } from "$lib";
|
||||||
|
import { handleApiError } from "$lib/optima-api/errorHandler";
|
||||||
|
import { checkPermissions } from "$lib/permissions";
|
||||||
|
import { fail } from "@sveltejs/kit";
|
||||||
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
return { credentialTypes: [], permissions: {}, valueTypes: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [typesResult, permissions, valueTypesResult] = await Promise.all([
|
||||||
|
optima.credentialType.fetchMany(accessToken),
|
||||||
|
checkPermissions(accessToken, [
|
||||||
|
"admin.credential-types.view",
|
||||||
|
"admin.credential-types.create",
|
||||||
|
"admin.credential-types.edit",
|
||||||
|
"admin.credential-types.delete",
|
||||||
|
]),
|
||||||
|
optima.credential.fetchValueTypes(accessToken).catch((err) => {
|
||||||
|
console.error(
|
||||||
|
"Failed to fetch value types:",
|
||||||
|
err?.response?.data ?? err?.message ?? err,
|
||||||
|
);
|
||||||
|
return { data: [] };
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const credentialTypes = typesResult?.data ?? [];
|
||||||
|
const valueTypes: string[] = valueTypesResult?.data ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
credentialTypes,
|
||||||
|
permissions,
|
||||||
|
valueTypes,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
handleApiError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
createCredentialType: async ({ locals, request }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
return fail(401, { message: "Not authenticated." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const name = (formData.get("name") as string)?.trim();
|
||||||
|
const permissionScope = (formData.get("permissionScope") as string)?.trim();
|
||||||
|
const icon = (formData.get("icon") as string)?.trim() || undefined;
|
||||||
|
const fieldsJson = (formData.get("fields") as string)?.trim();
|
||||||
|
|
||||||
|
if (!name || !permissionScope) {
|
||||||
|
return fail(400, { message: "Name and permission scope are required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
let fields: Array<
|
||||||
|
import("$lib/optima-api/modules/credentialTypes").CredentialTypeField
|
||||||
|
> = [];
|
||||||
|
if (fieldsJson) {
|
||||||
|
try {
|
||||||
|
fields = JSON.parse(fieldsJson);
|
||||||
|
} catch {
|
||||||
|
return fail(400, { message: "Invalid fields data." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await optima.credentialType.create(accessToken, {
|
||||||
|
name,
|
||||||
|
permissionScope,
|
||||||
|
icon,
|
||||||
|
fields,
|
||||||
|
});
|
||||||
|
return {};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.log(
|
||||||
|
"Error creating credential type:",
|
||||||
|
(err as AxiosError<{ error?: string }>)?.response?.data?.error,
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = (err as AxiosError)?.response?.data as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
const message =
|
||||||
|
(data?.message as string) ??
|
||||||
|
(err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to create credential type.");
|
||||||
|
const status = (data?.status as number) ?? 500;
|
||||||
|
return fail(status, { message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCredentialType: async ({ locals, request }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
return fail(401, { message: "Not authenticated." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = (formData.get("id") as string)?.trim();
|
||||||
|
const name = (formData.get("name") as string)?.trim();
|
||||||
|
const permissionScope = (formData.get("permissionScope") as string)?.trim();
|
||||||
|
const icon = (formData.get("icon") as string)?.trim() || undefined;
|
||||||
|
const fieldsJson = (formData.get("fields") as string)?.trim();
|
||||||
|
|
||||||
|
if (!id || !name || !permissionScope) {
|
||||||
|
return fail(400, { message: "Required fields are missing." });
|
||||||
|
}
|
||||||
|
|
||||||
|
let fields:
|
||||||
|
| Array<
|
||||||
|
Omit<
|
||||||
|
import("$lib/optima-api/modules/credentialTypes").CredentialTypeField,
|
||||||
|
"id"
|
||||||
|
>
|
||||||
|
>
|
||||||
|
| undefined;
|
||||||
|
if (fieldsJson) {
|
||||||
|
try {
|
||||||
|
fields = JSON.parse(fieldsJson);
|
||||||
|
} catch {
|
||||||
|
return fail(400, { message: "Invalid fields data." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await optima.credentialType.update(accessToken, id, {
|
||||||
|
name,
|
||||||
|
permissionScope,
|
||||||
|
icon,
|
||||||
|
fields: fields as any,
|
||||||
|
});
|
||||||
|
return {};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const data = (err as AxiosError)?.response?.data as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
const message =
|
||||||
|
(data?.message as string) ??
|
||||||
|
(err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to update credential type.");
|
||||||
|
const status = (data?.status as number) ?? 500;
|
||||||
|
return fail(status, { message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteCredentialType: async ({ locals, request }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
return fail(401, { message: "Not authenticated." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = (formData.get("id") as string)?.trim();
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { message: "Credential type ID is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await optima.credentialType.delete(accessToken, id);
|
||||||
|
return {};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const data = (err as AxiosError)?.response?.data as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
const message =
|
||||||
|
(data?.message as string) ??
|
||||||
|
(err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to delete credential type.");
|
||||||
|
const status = (data?.status as number) ?? 500;
|
||||||
|
return fail(status, { message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,11 +1,141 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
import type { SubmitFunction } from "@sveltejs/kit";
|
||||||
import type { PermissionMap } from "$lib/permissions";
|
import type { PermissionMap } from "$lib/permissions";
|
||||||
|
import type {
|
||||||
|
CredentialType,
|
||||||
|
CredentialTypeField,
|
||||||
|
} from "$lib/optima-api/modules/credentialTypes";
|
||||||
|
import CreateCredentialTypeModal from "../../../components/CreateCredentialTypeModal.svelte";
|
||||||
|
import "../../../styles/admin/credential-types.css";
|
||||||
|
|
||||||
export let data: { permissions: PermissionMap };
|
export let data: {
|
||||||
|
permissions: PermissionMap;
|
||||||
|
credentialTypes: CredentialType[];
|
||||||
|
valueTypes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
$: hasAccess = data.permissions["admin.credential-types.view"] === true;
|
$: hasAccess = data.permissions["admin.credential-types.view"] === true;
|
||||||
|
$: canCreate = data.permissions["admin.credential-types.create"] === true;
|
||||||
|
$: canEdit = data.permissions["admin.credential-types.edit"] === true;
|
||||||
|
$: canDelete = data.permissions["admin.credential-types.delete"] === true;
|
||||||
|
$: credentialTypes = data.credentialTypes;
|
||||||
|
$: valueTypes = data.valueTypes ?? [];
|
||||||
|
|
||||||
|
// Search / filter
|
||||||
|
let searchQuery = "";
|
||||||
|
$: filteredTypes = credentialTypes.filter((ct) => {
|
||||||
|
if (!searchQuery.trim()) return true;
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
ct.name.toLowerCase().includes(q) ||
|
||||||
|
ct.permissionScope.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create / edit modal state
|
||||||
|
let isCreateModalOpen = false;
|
||||||
|
let typeToEdit: CredentialType | null = null;
|
||||||
|
|
||||||
|
function openEdit(ct: CredentialType) {
|
||||||
|
typeToEdit = ct;
|
||||||
|
isCreateModalOpen = true;
|
||||||
|
openMenuId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three-dot menu
|
||||||
|
let openMenuId: string | null = null;
|
||||||
|
|
||||||
|
function toggleMenu(id: string) {
|
||||||
|
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
|
||||||
|
let typeToDelete: CredentialType | null = null;
|
||||||
|
let isDeleting = false;
|
||||||
|
let deleteError = "";
|
||||||
|
|
||||||
|
function openDeleteConfirm(ct: CredentialType) {
|
||||||
|
typeToDelete = ct;
|
||||||
|
openMenuId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDelete() {
|
||||||
|
typeToDelete = null;
|
||||||
|
deleteError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteEnhance: SubmitFunction = () => {
|
||||||
|
isDeleting = true;
|
||||||
|
deleteError = "";
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
isDeleting = false;
|
||||||
|
if (result.type === "success") {
|
||||||
|
typeToDelete = null;
|
||||||
|
} else if (result.type === "failure") {
|
||||||
|
deleteError =
|
||||||
|
(result.data as { message?: string })?.message ??
|
||||||
|
"Failed to delete credential type.";
|
||||||
|
}
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expanded row state
|
||||||
|
let expandedTypeId: string | null = null;
|
||||||
|
|
||||||
|
function toggleType(id: string) {
|
||||||
|
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 {
|
||||||
|
return vt
|
||||||
|
.split("_")
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:click={() => (openMenuId = null)} />
|
||||||
|
|
||||||
{#if !hasAccess}
|
{#if !hasAccess}
|
||||||
<div class="admin-denied">
|
<div class="admin-denied">
|
||||||
<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">
|
||||||
@@ -18,16 +148,632 @@
|
|||||||
administrator to request access.
|
administrator to request access.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if credentialTypes.length === 0}
|
||||||
|
<CreateCredentialTypeModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
{typeToEdit}
|
||||||
|
{valueTypes}
|
||||||
|
onClose={() => {
|
||||||
|
isCreateModalOpen = false;
|
||||||
|
typeToEdit = null;
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
isCreateModalOpen = false;
|
||||||
|
typeToEdit = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<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">
|
||||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
</svg>
|
</svg>
|
||||||
<h3>Credential Type Management</h3>
|
<h3>No Credential Types Found</h3>
|
||||||
<p>
|
<p>
|
||||||
Credential type definitions and configuration will be wired up here.
|
There are no credential types configured yet. Create your first credential
|
||||||
Connect an API module to populate this view.
|
type to get started.
|
||||||
|
</p>
|
||||||
|
{#if canCreate}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="create-ct-btn"
|
||||||
|
on:click={() => (isCreateModalOpen = true)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Create Credential Type
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<CreateCredentialTypeModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
{typeToEdit}
|
||||||
|
{valueTypes}
|
||||||
|
onClose={() => {
|
||||||
|
isCreateModalOpen = false;
|
||||||
|
typeToEdit = null;
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
isCreateModalOpen = false;
|
||||||
|
typeToEdit = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if typeToDelete}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="confirm-backdrop"
|
||||||
|
on:click={cancelDelete}
|
||||||
|
on:keydown={(e) => e.key === "Escape" && cancelDelete()}
|
||||||
|
>
|
||||||
|
<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">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">
|
||||||
|
<h3>
|
||||||
|
Credential Types
|
||||||
|
<span class="result-count"
|
||||||
|
>{filteredTypes.length} type{filteredTypes.length === 1
|
||||||
|
? ""
|
||||||
|
: "s"}{#if searchQuery.trim()} (filtered){/if}</span
|
||||||
|
>
|
||||||
|
</h3>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div class="ct-search-wrap">
|
||||||
|
<svg
|
||||||
|
class="ct-search-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="ct-search-input"
|
||||||
|
placeholder="Search types…"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if canCreate}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="create-ct-btn"
|
||||||
|
on:click={() => (isCreateModalOpen = true)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Create Type
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Permission Scope</th>
|
||||||
|
<th>Fields</th>
|
||||||
|
<th>Credentials</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredTypes as ct (ct.id)}
|
||||||
|
<tr
|
||||||
|
class="ct-row"
|
||||||
|
class:expanded={expandedTypeId === ct.id}
|
||||||
|
on:click={() => toggleType(ct.id)}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<div class="ct-name-cell">
|
||||||
|
<svg
|
||||||
|
class="ct-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
<span class="ct-name">{ct.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="ct-scope">{ct.permissionScope}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="ct-field-count">
|
||||||
|
{ct.fields.length} field{ct.fields.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="ct-cred-count">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"
|
||||||
|
/>
|
||||||
|
<line x1="8" y1="12" x2="16" y2="12" />
|
||||||
|
</svg>
|
||||||
|
{ct.credentialCount} credential{ct.credentialCount === 1
|
||||||
|
? ""
|
||||||
|
: "s"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{formatDate(ct.createdAt)}</td>
|
||||||
|
<td>{formatDate(ct.updatedAt)}</td>
|
||||||
|
<td class="row-end-cell">
|
||||||
|
<div class="row-end-content">
|
||||||
|
{#if canEdit || canDelete}
|
||||||
|
<div class="menu-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="menu-btn"
|
||||||
|
aria-label="Credential type actions"
|
||||||
|
on:click|stopPropagation={() => toggleMenu(ct.id)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="2.5" r="1.5" />
|
||||||
|
<circle cx="8" cy="8" r="1.5" />
|
||||||
|
<circle cx="8" cy="13.5" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{#if openMenuId === ct.id}
|
||||||
|
<div class="ct-menu" use:positionMenu>
|
||||||
|
{#if canEdit}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ct-menu-item"
|
||||||
|
on:click|stopPropagation={() => openEdit(ct)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if canEdit && canDelete}
|
||||||
|
<div class="ct-menu-sep"></div>
|
||||||
|
{/if}
|
||||||
|
{#if canDelete}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ct-menu-item ct-menu-item-danger"
|
||||||
|
on:click|stopPropagation={() =>
|
||||||
|
openDeleteConfirm(ct)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<svg
|
||||||
|
class="row-chevron"
|
||||||
|
class:open={expandedTypeId === ct.id}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{#if expandedTypeId === ct.id}
|
||||||
|
<tr class="ct-detail-row">
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="ct-detail-content">
|
||||||
|
<div class="ct-detail-grid">
|
||||||
|
<!-- Info section -->
|
||||||
|
<div class="ct-detail-section">
|
||||||
|
<h4 class="ct-detail-heading">
|
||||||
|
<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="16" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||||
|
</svg>
|
||||||
|
Details
|
||||||
|
</h4>
|
||||||
|
<div class="ct-detail-fields">
|
||||||
|
<div class="ct-detail-field">
|
||||||
|
<span class="detail-label">ID</span>
|
||||||
|
<span class="detail-value detail-mono">{ct.id}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ct-detail-field">
|
||||||
|
<span class="detail-label">Permission Scope</span>
|
||||||
|
<span class="detail-value detail-mono"
|
||||||
|
>{ct.permissionScope}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if ct.icon}
|
||||||
|
<div class="ct-detail-field">
|
||||||
|
<span class="detail-label">Icon</span>
|
||||||
|
<span class="detail-value">{ct.icon}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="ct-detail-field">
|
||||||
|
<span class="detail-label">Credentials</span>
|
||||||
|
<span class="detail-value"
|
||||||
|
>{ct.credentialCount} credential{ct.credentialCount ===
|
||||||
|
1
|
||||||
|
? ""
|
||||||
|
: "s"}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="ct-detail-field">
|
||||||
|
<span class="detail-label">Created</span>
|
||||||
|
<span class="detail-value"
|
||||||
|
>{formatDate(ct.createdAt)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="ct-detail-field">
|
||||||
|
<span class="detail-label">Updated</span>
|
||||||
|
<span class="detail-value"
|
||||||
|
>{formatDate(ct.updatedAt)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fields section -->
|
||||||
|
<div class="ct-detail-section">
|
||||||
|
<h4 class="ct-detail-heading">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"
|
||||||
|
/>
|
||||||
|
<rect x="9" y="3" width="6" height="4" rx="1" />
|
||||||
|
</svg>
|
||||||
|
Fields
|
||||||
|
<span class="detail-count">{ct.fields.length}</span>
|
||||||
|
</h4>
|
||||||
|
{#if ct.fields.length === 0}
|
||||||
|
<p class="ct-detail-empty">No fields defined</p>
|
||||||
|
{:else}
|
||||||
|
<div class="ct-field-list">
|
||||||
|
{#each ct.fields as field (field.id)}
|
||||||
|
<div class="ct-field-card">
|
||||||
|
<div class="ct-field-icon">
|
||||||
|
{#if field.valueType === "multi_credential"}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
{:else if field.secure}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="3"
|
||||||
|
y="11"
|
||||||
|
width="18"
|
||||||
|
height="11"
|
||||||
|
rx="2"
|
||||||
|
ry="2"
|
||||||
|
/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<polyline points="4 7 4 4 20 4 20 7" />
|
||||||
|
<line x1="9" y1="20" x2="15" y2="20" />
|
||||||
|
<line x1="12" y1="4" x2="12" y2="20" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="ct-field-info">
|
||||||
|
<span class="ct-field-name">{field.name}</span>
|
||||||
|
<div class="ct-field-meta">
|
||||||
|
<span>{valueTypeLabel(field.valueType)}</span>
|
||||||
|
{#if field.required}
|
||||||
|
<span class="ct-field-badge required"
|
||||||
|
>Required</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if field.secure}
|
||||||
|
<span class="ct-field-badge secure"
|
||||||
|
>Secure</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if field.valueType === "multi_credential" && field.subFields && field.subFields.length > 0}
|
||||||
|
<div class="ct-subfield-group">
|
||||||
|
<div class="ct-subfield-connector"></div>
|
||||||
|
<div class="ct-subfield-list">
|
||||||
|
{#each field.subFields as subField (subField.id)}
|
||||||
|
<div class="ct-field-card ct-subfield-card">
|
||||||
|
<div
|
||||||
|
class="ct-field-icon ct-subfield-icon"
|
||||||
|
>
|
||||||
|
{#if subField.secure}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="3"
|
||||||
|
y="11"
|
||||||
|
width="18"
|
||||||
|
height="11"
|
||||||
|
rx="2"
|
||||||
|
ry="2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7 11V7a5 5 0 0 1 10 0v4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
points="4 7 4 4 20 4 20 7"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="9"
|
||||||
|
y1="20"
|
||||||
|
x2="15"
|
||||||
|
y2="20"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="12"
|
||||||
|
y1="4"
|
||||||
|
x2="12"
|
||||||
|
y2="20"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="ct-field-info">
|
||||||
|
<span class="ct-field-name"
|
||||||
|
>{subField.name}</span
|
||||||
|
>
|
||||||
|
<div class="ct-field-meta">
|
||||||
|
<span
|
||||||
|
>{valueTypeLabel(
|
||||||
|
subField.valueType,
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
{#if subField.required}
|
||||||
|
<span
|
||||||
|
class="ct-field-badge required"
|
||||||
|
>Required</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if subField.secure}
|
||||||
|
<span class="ct-field-badge secure"
|
||||||
|
>Secure</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if filteredTypes.length === 0 && searchQuery.trim()}
|
||||||
|
<div class="admin-tab-empty">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
<h3>No Results</h3>
|
||||||
|
<p>
|
||||||
|
No credential types match “{searchQuery}”. Try a different
|
||||||
|
search.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
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 CreateRoleModal from "../../../components/CreateRoleModal.svelte";
|
import CreateRoleModal from "../../../components/CreateRoleModal.svelte";
|
||||||
|
import "../../../styles/admin/roles.css";
|
||||||
|
|
||||||
interface RoleUser {
|
interface RoleUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -555,473 +556,3 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
|
||||||
/* ── Table header layout ── */
|
|
||||||
:global(.admin-table-header) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-role-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 7px 14px;
|
|
||||||
background: var(--accent-color, #0066cc);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 7px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: filter 0.15s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-role-btn:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Role-specific styles ── */
|
|
||||||
.role-row {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-row.expanded {
|
|
||||||
background: var(--card-hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-title-cell {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-icon {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-title {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-moniker {
|
|
||||||
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
background: var(--bg-inset);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-perm-count {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-user-count {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-user-count svg {
|
|
||||||
color: var(--text-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-chevron {
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-chevron.open {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Expanded detail row */
|
|
||||||
.role-detail-row td {
|
|
||||||
padding: 0 !important;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-detail-content {
|
|
||||||
padding: 16px 24px 20px;
|
|
||||||
background: var(--bg-inset);
|
|
||||||
animation: roleDetailFadeIn 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes roleDetailFadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-detail-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-detail-section {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-count {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent-color, #0066cc);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 10.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-name {
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-login {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-detail-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin: 0 0 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-detail-empty {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Permission tags */
|
|
||||||
.permission-tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.permission-tag {
|
|
||||||
display: inline-block;
|
|
||||||
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: var(--status-neutral-bg);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Row end cell (actions + chevron) ── */
|
|
||||||
.row-end-cell {
|
|
||||||
width: 1%;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-end-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── System badge ── */
|
|
||||||
.system-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: var(--bg-inset);
|
|
||||||
color: var(--text-muted);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Three-dot menu ── */
|
|
||||||
.menu-wrap {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
padding: 0;
|
|
||||||
background: none;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-muted);
|
|
||||||
transition:
|
|
||||||
background 0.12s,
|
|
||||||
border-color 0.12s,
|
|
||||||
color 0.12s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-btn:hover {
|
|
||||||
background: var(--card-hover-bg);
|
|
||||||
border-color: var(--border-subtle);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-menu {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 200;
|
|
||||||
min-width: 130px;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
|
||||||
padding: 4px;
|
|
||||||
animation: menuIn 0.1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes menuIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-4px) scale(0.97);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-menu-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 6px 10px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-menu-item:hover {
|
|
||||||
background: var(--card-hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-menu-item svg {
|
|
||||||
color: var(--text-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-menu-item-danger {
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-menu-item-danger svg {
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-menu-item-danger:hover {
|
|
||||||
background: rgba(220, 38, 38, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-menu-sep {
|
|
||||||
height: 1px;
|
|
||||||
background: var(--border-subtle);
|
|
||||||
margin: 3px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Delete confirmation overlay ── */
|
|
||||||
.confirm-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.45);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-dialog {
|
|
||||||
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: 380px;
|
|
||||||
padding: 28px 24px 22px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
animation: modalIn 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-icon-wrap {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(220, 38, 38, 0.1);
|
|
||||||
border: 1px solid rgba(220, 38, 38, 0.2);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #dc2626;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-title {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-body {
|
|
||||||
margin: 0 0 16px;
|
|
||||||
font-size: 13.5px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-error {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-actions form {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel {
|
|
||||||
padding: 7px 16px;
|
|
||||||
border-radius: 7px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
background: var(--bg-inset);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
transition:
|
|
||||||
background 0.15s,
|
|
||||||
border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel:hover:not(:disabled) {
|
|
||||||
background: var(--card-hover-bg);
|
|
||||||
border-color: var(--border-default);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete-confirm {
|
|
||||||
padding: 7px 16px;
|
|
||||||
border-radius: 7px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
background: #dc2626;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
color: #fff;
|
|
||||||
transition: filter 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete-confirm:hover:not(:disabled) {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete-confirm:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { optima } from "$lib";
|
||||||
|
import { handleApiError } from "$lib/optima-api/errorHandler";
|
||||||
|
import { checkPermissions } from "$lib/permissions";
|
||||||
|
import { fail } from "@sveltejs/kit";
|
||||||
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
return { users: [], roles: [], permissions: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [usersResult, rolesResult, permissions, permNodesResult] =
|
||||||
|
await Promise.all([
|
||||||
|
optima.users.fetchAll(accessToken),
|
||||||
|
optima.role.fetchMany(accessToken),
|
||||||
|
checkPermissions(accessToken, [
|
||||||
|
"admin.users.view",
|
||||||
|
"admin.users.edit",
|
||||||
|
"admin.users.delete",
|
||||||
|
"user.roles.other",
|
||||||
|
"user.permissions.other",
|
||||||
|
]),
|
||||||
|
optima.permission
|
||||||
|
.fetchCategorized(accessToken)
|
||||||
|
.catch(() => ({ data: {} })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allUsers = usersResult?.data ?? [];
|
||||||
|
const allRoles = rolesResult?.data ?? [];
|
||||||
|
|
||||||
|
// Fetch roles for each user in parallel
|
||||||
|
const usersWithRoles = await Promise.all(
|
||||||
|
allUsers.map(async (user) => {
|
||||||
|
try {
|
||||||
|
const rolesResult = await optima.users.fetchRoles(
|
||||||
|
accessToken,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
return { ...user, roleDetails: rolesResult?.data ?? [] };
|
||||||
|
} catch {
|
||||||
|
return { ...user, roleDetails: [] };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: usersWithRoles,
|
||||||
|
roles: allRoles,
|
||||||
|
permissions,
|
||||||
|
permissionNodes: permNodesResult?.data ?? {},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
handleApiError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
updateUser: async ({ locals, request }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
return fail(401, { message: "Not authenticated." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = (formData.get("id") as string)?.trim();
|
||||||
|
const name = (formData.get("name") as string)?.trim();
|
||||||
|
const image = (formData.get("image") as string)?.trim() || undefined;
|
||||||
|
const rolesJson = (formData.get("roles") as string)?.trim();
|
||||||
|
const permissionsJson = (formData.get("permissions") as string)?.trim();
|
||||||
|
|
||||||
|
if (!id || !name) {
|
||||||
|
return fail(400, { message: "User ID and name are required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: {
|
||||||
|
name: string;
|
||||||
|
image?: string;
|
||||||
|
roles?: string[];
|
||||||
|
permissions?: string[];
|
||||||
|
} = { name, image };
|
||||||
|
|
||||||
|
if (rolesJson) {
|
||||||
|
try {
|
||||||
|
updates.roles = JSON.parse(rolesJson);
|
||||||
|
} catch {
|
||||||
|
return fail(400, { message: "Invalid roles data." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissionsJson) {
|
||||||
|
try {
|
||||||
|
updates.permissions = JSON.parse(permissionsJson);
|
||||||
|
} catch {
|
||||||
|
return fail(400, { message: "Invalid permissions data." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await optima.users.update(accessToken, id, updates);
|
||||||
|
return {};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const data = (err as AxiosError)?.response?.data as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
const message =
|
||||||
|
(data?.message as string) ??
|
||||||
|
(err instanceof Error ? err.message : "Failed to update user.");
|
||||||
|
const status = (data?.status as number) ?? 500;
|
||||||
|
return fail(status, { message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteUser: async ({ locals, request }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
return fail(401, { message: "Not authenticated." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = (formData.get("id") as string)?.trim();
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { message: "User ID is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await optima.users.delete(accessToken, id);
|
||||||
|
return {};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const data = (err as AxiosError)?.response?.data as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
const message =
|
||||||
|
(data?.message as string) ??
|
||||||
|
(err instanceof Error ? err.message : "Failed to delete user.");
|
||||||
|
const status = (data?.status as number) ?? 500;
|
||||||
|
return fail(status, { message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,11 +1,148 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
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 { Role } from "$lib/optima-api/modules/roles";
|
||||||
|
import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions";
|
||||||
|
import EditUserModal from "../../../components/EditUserModal.svelte";
|
||||||
|
import "../../../styles/admin/users.css";
|
||||||
|
|
||||||
export let data: { permissions: PermissionMap };
|
type UserWithRoles = User & { roleDetails: Role[] };
|
||||||
|
|
||||||
|
export let data: {
|
||||||
|
permissions: PermissionMap;
|
||||||
|
users: UserWithRoles[];
|
||||||
|
roles: Role[];
|
||||||
|
permissionNodes: PermissionsCategorized;
|
||||||
|
};
|
||||||
|
|
||||||
$: hasAccess = data.permissions["admin.users.view"] === true;
|
$: hasAccess = data.permissions["admin.users.view"] === true;
|
||||||
|
$: canEdit = data.permissions["admin.users.edit"] === true;
|
||||||
|
$: canDelete = data.permissions["admin.users.delete"] === true;
|
||||||
|
$: canEditRoles = data.permissions["user.roles.other"] === true;
|
||||||
|
$: canEditPermissions = data.permissions["user.permissions.other"] === true;
|
||||||
|
$: users = data.users;
|
||||||
|
$: allRoles = data.roles;
|
||||||
|
|
||||||
|
// Search / filter
|
||||||
|
let searchQuery = "";
|
||||||
|
$: filteredUsers = users.filter((u) => {
|
||||||
|
if (!searchQuery.trim()) return true;
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
u.name.toLowerCase().includes(q) ||
|
||||||
|
u.email.toLowerCase().includes(q) ||
|
||||||
|
u.login.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expanded row state
|
||||||
|
let expandedUserId: string | null = null;
|
||||||
|
|
||||||
|
function toggleUser(id: string) {
|
||||||
|
expandedUserId = expandedUserId === id ? null : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three-dot menu
|
||||||
|
let openMenuId: string | null = null;
|
||||||
|
|
||||||
|
function toggleMenu(id: string) {
|
||||||
|
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
|
||||||
|
let editingUser: UserWithRoles | null = null;
|
||||||
|
|
||||||
|
function openEdit(u: UserWithRoles) {
|
||||||
|
editingUser = u;
|
||||||
|
openMenuId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingUser = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete confirmation
|
||||||
|
let userToDelete: UserWithRoles | null = null;
|
||||||
|
let isDeleting = false;
|
||||||
|
let deleteError = "";
|
||||||
|
|
||||||
|
function openDeleteConfirm(u: UserWithRoles) {
|
||||||
|
userToDelete = u;
|
||||||
|
openMenuId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDelete() {
|
||||||
|
userToDelete = null;
|
||||||
|
deleteError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteEnhance: SubmitFunction = () => {
|
||||||
|
isDeleting = true;
|
||||||
|
deleteError = "";
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
isDeleting = false;
|
||||||
|
if (result.type === "success") {
|
||||||
|
userToDelete = null;
|
||||||
|
} else if (result.type === "failure") {
|
||||||
|
deleteError =
|
||||||
|
(result.data as { message?: string })?.message ??
|
||||||
|
"Failed to delete user.";
|
||||||
|
}
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function initials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.slice(0, 2)
|
||||||
|
.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)} />
|
||||||
|
|
||||||
{#if !hasAccess}
|
{#if !hasAccess}
|
||||||
<div class="admin-denied">
|
<div class="admin-denied">
|
||||||
<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">
|
||||||
@@ -18,7 +155,7 @@
|
|||||||
request access.
|
request access.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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">
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
@@ -26,10 +163,441 @@
|
|||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
</svg>
|
</svg>
|
||||||
<h3>User Management</h3>
|
<h3>No Users Found</h3>
|
||||||
<p>
|
<p>There are no users in the system yet.</p>
|
||||||
User listing and editing will be wired up here. Connect an API module to
|
</div>
|
||||||
populate this view.
|
{:else}
|
||||||
|
<!-- Edit user modal -->
|
||||||
|
{#if editingUser}
|
||||||
|
<EditUserModal
|
||||||
|
user={editingUser}
|
||||||
|
{allRoles}
|
||||||
|
permissionNodes={data.permissionNodes}
|
||||||
|
{canEditRoles}
|
||||||
|
{canEditPermissions}
|
||||||
|
onClose={cancelEdit}
|
||||||
|
onSuccess={cancelEdit}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete confirmation modal -->
|
||||||
|
{#if userToDelete}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="confirm-backdrop"
|
||||||
|
on:click={cancelDelete}
|
||||||
|
on:keydown={(e) => e.key === "Escape" && cancelDelete()}
|
||||||
|
>
|
||||||
|
<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">Delete User</h3>
|
||||||
|
<p class="confirm-body">
|
||||||
|
Are you sure you want to delete
|
||||||
|
<strong>{userToDelete.name}</strong>? This action cannot be undone.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="admin-table-header">
|
||||||
|
<h3>
|
||||||
|
Users
|
||||||
|
<span class="result-count"
|
||||||
|
>{filteredUsers.length} user{filteredUsers.length === 1
|
||||||
|
? ""
|
||||||
|
: "s"}{#if searchQuery.trim()}
|
||||||
|
(filtered){/if}</span
|
||||||
|
>
|
||||||
|
</h3>
|
||||||
|
<div class="user-search-wrap">
|
||||||
|
<svg
|
||||||
|
class="user-search-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="user-search-input"
|
||||||
|
placeholder="Search users…"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Login</th>
|
||||||
|
<th>Roles</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredUsers as user (user.id)}
|
||||||
|
<tr
|
||||||
|
class="user-row"
|
||||||
|
class:expanded={expandedUserId === user.id}
|
||||||
|
on:click={() => toggleUser(user.id)}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<div class="user-name-cell">
|
||||||
|
{#if user.image}
|
||||||
|
<img
|
||||||
|
src={user.image}
|
||||||
|
alt={user.name}
|
||||||
|
class="user-table-avatar"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="user-table-avatar user-table-avatar-initials">
|
||||||
|
{initials(user.name)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<span class="user-table-name">{user.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="user-email">{user.email}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="user-login-mono">{user.login}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="user-role-count">
|
||||||
|
{user.roleDetails.length} role{user.roleDetails.length === 1
|
||||||
|
? ""
|
||||||
|
: "s"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{formatDate(user.createdAt)}</td>
|
||||||
|
<td class="row-end-cell">
|
||||||
|
<div class="row-end-content">
|
||||||
|
{#if canEdit || canDelete}
|
||||||
|
<div class="menu-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="menu-btn"
|
||||||
|
aria-label="User actions"
|
||||||
|
on:click|stopPropagation={() => toggleMenu(user.id)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="2.5" r="1.5" />
|
||||||
|
<circle cx="8" cy="8" r="1.5" />
|
||||||
|
<circle cx="8" cy="13.5" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{#if openMenuId === user.id}
|
||||||
|
<div class="user-menu" use:positionMenu>
|
||||||
|
{#if canEdit}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="user-menu-item"
|
||||||
|
on:click|stopPropagation={() => openEdit(user)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if canEdit && canDelete}
|
||||||
|
<div class="user-menu-sep"></div>
|
||||||
|
{/if}
|
||||||
|
{#if canDelete}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="user-menu-item user-menu-item-danger"
|
||||||
|
on:click|stopPropagation={() =>
|
||||||
|
openDeleteConfirm(user)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<svg
|
||||||
|
class="row-chevron"
|
||||||
|
class:open={expandedUserId === user.id}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{#if expandedUserId === user.id}
|
||||||
|
<tr class="user-detail-row">
|
||||||
|
<td colspan="6">
|
||||||
|
<div class="user-detail-content">
|
||||||
|
<div class="user-detail-grid">
|
||||||
|
<!-- User info section -->
|
||||||
|
<div class="user-detail-section">
|
||||||
|
<h4 class="user-detail-heading">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
Details
|
||||||
|
</h4>
|
||||||
|
<div class="user-detail-fields">
|
||||||
|
<div class="user-detail-field">
|
||||||
|
<span class="detail-label">ID</span>
|
||||||
|
<span class="detail-value detail-mono">{user.id}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="user-detail-field">
|
||||||
|
<span class="detail-label">Email</span>
|
||||||
|
<span class="detail-value">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
<div class="user-detail-field">
|
||||||
|
<span class="detail-label">Login</span>
|
||||||
|
<span class="detail-value detail-mono"
|
||||||
|
>{user.login}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="user-detail-field">
|
||||||
|
<span class="detail-label">Created</span>
|
||||||
|
<span class="detail-value"
|
||||||
|
>{formatDate(user.createdAt)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="user-detail-field">
|
||||||
|
<span class="detail-label">Updated</span>
|
||||||
|
<span class="detail-value"
|
||||||
|
>{formatDate(user.updatedAt)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Roles section -->
|
||||||
|
<div class="user-detail-section">
|
||||||
|
<h4 class="user-detail-heading">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Roles
|
||||||
|
<span class="detail-count"
|
||||||
|
>{user.roleDetails.length}</span
|
||||||
|
>
|
||||||
|
</h4>
|
||||||
|
{#if user.roleDetails.length === 0}
|
||||||
|
<p class="user-detail-empty">No roles assigned</p>
|
||||||
|
{:else}
|
||||||
|
<div class="user-role-list">
|
||||||
|
{#each user.roleDetails as role (role.id)}
|
||||||
|
<div class="user-role-card">
|
||||||
|
<div class="user-role-card-header">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="user-role-title">{role.title}</span
|
||||||
|
>
|
||||||
|
<span class="user-role-moniker"
|
||||||
|
>{role.moniker}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if role.permissions.length > 0}
|
||||||
|
<div class="permission-tags">
|
||||||
|
{#each role.permissions.slice(0, 8) as perm}
|
||||||
|
<span class="permission-tag">{perm}</span>
|
||||||
|
{/each}
|
||||||
|
{#if role.permissions.length > 8}
|
||||||
|
<span
|
||||||
|
class="permission-tag permission-tag-more"
|
||||||
|
>+{role.permissions.length - 8} more</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Permissions section -->
|
||||||
|
<div class="user-detail-section">
|
||||||
|
<h4 class="user-detail-heading">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<path d="M9 12l2 2 4-4" />
|
||||||
|
<path
|
||||||
|
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Additional Permissions
|
||||||
|
{#if user.permissions?.length}
|
||||||
|
<span class="detail-count"
|
||||||
|
>{user.permissions.length}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</h4>
|
||||||
|
{#if !user.permissions?.length}
|
||||||
|
<p class="user-detail-empty">
|
||||||
|
No additional permissions assigned
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="permission-tags">
|
||||||
|
{#each user.permissions as perm}
|
||||||
|
<span class="permission-tag">{perm}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if filteredUsers.length === 0 && searchQuery.trim()}
|
||||||
|
<div class="admin-tab-empty">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
<h3>No Results</h3>
|
||||||
|
<p>No users match “{searchQuery}”. Try a different search.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { optima } from "$lib";
|
import { optima } from "$lib";
|
||||||
import { handleApiError } from "$lib/optima-api/errorHandler";
|
import { handleApiError } from "$lib/optima-api/errorHandler";
|
||||||
|
import { checkPermissions } from "$lib/permissions";
|
||||||
import type { PageServerLoad } from "./$types";
|
import type { PageServerLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
@@ -11,6 +12,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
|||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
totalRecords: 0,
|
totalRecords: 0,
|
||||||
search: "",
|
search: "",
|
||||||
|
permissions: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,7 +20,21 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
|||||||
const search = url.searchParams.get("search") || "";
|
const search = url.searchParams.get("search") || "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await optima.company.fetchMany(accessToken, page, search);
|
const [result, permissions] = await Promise.all([
|
||||||
|
optima.company.fetchMany(accessToken, page, search).catch((err) => {
|
||||||
|
console.error(
|
||||||
|
"Failed to fetch companies:",
|
||||||
|
err?.response?.data ?? err?.message ?? err,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: [],
|
||||||
|
meta: {
|
||||||
|
pagination: { totalPages: 1, currentPage: 1, totalRecords: 0 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
checkPermissions(accessToken, ["companies.view"]),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
companies: result?.data ?? [],
|
companies: result?.data ?? [],
|
||||||
@@ -27,6 +43,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
|||||||
totalRecords:
|
totalRecords:
|
||||||
result?.meta?.pagination?.totalRecords ?? result?.data?.length ?? 0,
|
result?.meta?.pagination?.totalRecords ?? result?.data?.length ?? 0,
|
||||||
search,
|
search,
|
||||||
|
permissions,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleApiError(err);
|
handleApiError(err);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
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 NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
|
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
|
||||||
import "../../styles/companies/companylist.css";
|
import "../../styles/companies/companylist.css";
|
||||||
|
|
||||||
export let data: {
|
export let data: {
|
||||||
|
permissions: PermissionMap;
|
||||||
companies: Array<{
|
companies: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -21,6 +23,8 @@
|
|||||||
search: string;
|
search: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$: hasAccess = data.permissions["companies.view"] === true;
|
||||||
|
|
||||||
let searchInput = data.search;
|
let searchInput = data.search;
|
||||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
let isSearching = false;
|
let isSearching = false;
|
||||||
@@ -134,6 +138,19 @@
|
|||||||
<title>Companies — Project Optima</title>
|
<title>Companies — Project Optima</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if !hasAccess}
|
||||||
|
<div class="access-denied">
|
||||||
|
<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>Access Denied</h3>
|
||||||
|
<p>
|
||||||
|
You don't have permission to view Companies. Contact your administrator to
|
||||||
|
request access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<div class="companies-page">
|
<div class="companies-page">
|
||||||
<div class="companies-pane">
|
<div class="companies-pane">
|
||||||
<!-- Pane header -->
|
<!-- Pane header -->
|
||||||
@@ -246,7 +263,14 @@
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
class="meta-icon"
|
class="meta-icon"
|
||||||
>
|
>
|
||||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
|
<rect
|
||||||
|
x="2"
|
||||||
|
y="7"
|
||||||
|
width="20"
|
||||||
|
height="14"
|
||||||
|
rx="2"
|
||||||
|
ry="2"
|
||||||
|
/>
|
||||||
<path d="M16 3h-8l-2 4h12z" />
|
<path d="M16 3h-8l-2 4h12z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{company.type}</span>
|
<span>{company.type}</span>
|
||||||
@@ -278,7 +302,9 @@
|
|||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if formatDate(company.createdAt)}
|
{#if formatDate(company.createdAt)}
|
||||||
<span class="card-date">{formatDate(company.createdAt)}</span>
|
<span class="card-date"
|
||||||
|
>{formatDate(company.createdAt)}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -360,3 +386,36 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
|||||||
return {
|
return {
|
||||||
company: null,
|
company: null,
|
||||||
configurations: [],
|
configurations: [],
|
||||||
|
credentials: [],
|
||||||
|
credentialTypes: [],
|
||||||
|
unifiSites: [],
|
||||||
|
accessToken: null,
|
||||||
permissions: {} as PermissionMap,
|
permissions: {} as PermissionMap,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -16,19 +20,45 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
|||||||
try {
|
try {
|
||||||
// Run permission checks in parallel with other data fetches.
|
// Run permission checks in parallel with other data fetches.
|
||||||
// Add any new permissions the company page needs to this array.
|
// Add any new permissions the company page needs to this array.
|
||||||
const [permissions, configsResult] = await Promise.all([
|
const [
|
||||||
checkPermissions(accessToken, ["company.fetch.address"]),
|
permissions,
|
||||||
|
configsResult,
|
||||||
|
credentialsResult,
|
||||||
|
credentialTypesResult,
|
||||||
|
unifiSitesResult,
|
||||||
|
] = await Promise.all([
|
||||||
|
checkPermissions(accessToken, [
|
||||||
|
"company.fetch.address",
|
||||||
|
"company.fetch.contacts",
|
||||||
|
"credential.secure_values.read",
|
||||||
|
"unifi.site.wifi",
|
||||||
|
"unifi.site.wifi.read.name",
|
||||||
|
"unifi.site.wifi.update",
|
||||||
|
]),
|
||||||
optima.company.fetchConfigurations(accessToken, params.id),
|
optima.company.fetchConfigurations(accessToken, params.id),
|
||||||
|
optima.credential
|
||||||
|
.fetchByCompany(accessToken, params.id)
|
||||||
|
.catch(() => ({ data: [] })),
|
||||||
|
optima.credentialType.fetchMany(accessToken).catch(() => ({ data: [] })),
|
||||||
|
optima.unifi
|
||||||
|
.fetchCompanySites(accessToken, params.id)
|
||||||
|
.catch(() => ({ data: [] })),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Fetch company with or without address based on permission
|
// Fetch company with or without address based on permission
|
||||||
const companyResult = await optima.company.fetch(accessToken, params.id, {
|
const companyResult = await optima.company.fetch(accessToken, params.id, {
|
||||||
includeAddress: permissions["company.fetch.address"] === true,
|
includeAddress: permissions["company.fetch.address"] === true,
|
||||||
|
includePrimaryContact: true,
|
||||||
|
includeAllContacts: permissions["company.fetch.contacts"] === true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
company: companyResult?.data ?? null,
|
company: companyResult?.data ?? null,
|
||||||
configurations: configsResult?.data ?? [],
|
configurations: configsResult?.data ?? [],
|
||||||
|
credentials: credentialsResult?.data ?? [],
|
||||||
|
credentialTypes: credentialTypesResult?.data ?? [],
|
||||||
|
unifiSites: unifiSitesResult?.data ?? [],
|
||||||
|
accessToken,
|
||||||
permissions,
|
permissions,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,168 +1,65 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import "../../../styles/companies/companydetail.css";
|
import "../../../styles/companies/companydetail.css";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import type { PageData } from "./types";
|
||||||
|
|
||||||
import type { PermissionMap } from "$lib/permissions";
|
// Tab components
|
||||||
|
import CompanySidebar from "./components/CompanySidebar.svelte";
|
||||||
|
import OverviewTab from "./components/OverviewTab.svelte";
|
||||||
|
import CredentialsTab from "./components/CredentialsTab.svelte";
|
||||||
|
import ConfigurationsTab from "./components/ConfigurationsTab.svelte";
|
||||||
|
import ContactsTab from "./components/ContactsTab.svelte";
|
||||||
|
import UniFiTab from "./components/UniFiTab.svelte";
|
||||||
|
import ActivityTab from "./components/ActivityTab.svelte";
|
||||||
|
|
||||||
export let data: {
|
export let data: PageData;
|
||||||
company: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
status?: string;
|
|
||||||
type?: string;
|
|
||||||
createdAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
identifier?: string;
|
|
||||||
contactEmail?: string;
|
|
||||||
contactPhone?: string;
|
|
||||||
address?: string;
|
|
||||||
city?: string;
|
|
||||||
state?: string;
|
|
||||||
zip?: string;
|
|
||||||
country?: string;
|
|
||||||
cw_Data?: {
|
|
||||||
address?: {
|
|
||||||
line1?: string;
|
|
||||||
line2?: string | null;
|
|
||||||
city?: string;
|
|
||||||
state?: string;
|
|
||||||
zip?: string;
|
|
||||||
country?: string;
|
|
||||||
};
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
[key: string]: unknown;
|
|
||||||
} | null;
|
|
||||||
configurations: Array<{
|
|
||||||
id: string | number;
|
|
||||||
name: string;
|
|
||||||
active?: boolean;
|
|
||||||
serialNumber?: string;
|
|
||||||
status?: {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
type?: {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
_info?: { type_href?: string };
|
|
||||||
};
|
|
||||||
notes?: string;
|
|
||||||
questions?: Array<{
|
|
||||||
id: number;
|
|
||||||
question: string;
|
|
||||||
answer?: string;
|
|
||||||
fieldType: string;
|
|
||||||
}> | null;
|
|
||||||
info?: {
|
|
||||||
lastUpdated?: string;
|
|
||||||
updatedBy?: string;
|
|
||||||
dateEntered?: string;
|
|
||||||
enteredBy?: string;
|
|
||||||
};
|
|
||||||
key?: string;
|
|
||||||
value?: string;
|
|
||||||
description?: string;
|
|
||||||
createdAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}>;
|
|
||||||
permissions: PermissionMap;
|
|
||||||
};
|
|
||||||
|
|
||||||
$: company = data.company;
|
$: company = data.company;
|
||||||
$: configurations = data.configurations;
|
$: configurations = data.configurations;
|
||||||
|
$: credentials = data.credentials;
|
||||||
|
$: credentialTypes = data.credentialTypes;
|
||||||
|
$: unifiSites = data.unifiSites;
|
||||||
|
$: accessToken = data.accessToken;
|
||||||
$: permissions = data.permissions;
|
$: permissions = data.permissions;
|
||||||
|
|
||||||
const tabs = ["Credentials", "Configurations", "Users", "Activity"] as const;
|
// Mobile detection
|
||||||
type Tab = (typeof tabs)[number];
|
let isMobile = false;
|
||||||
let activeTab: Tab = "Credentials";
|
function checkMobile() {
|
||||||
|
isMobile = typeof window !== "undefined" && window.innerWidth <= 768;
|
||||||
// Configurations split-view state
|
|
||||||
let selectedConfig: (typeof configurations)[number] | null = null;
|
|
||||||
let configFadeKey = 0;
|
|
||||||
|
|
||||||
// Track which password fields are revealed (by question id)
|
|
||||||
let revealedPasswords: Record<number, boolean> = {};
|
|
||||||
|
|
||||||
function togglePassword(questionId: number) {
|
|
||||||
revealedPasswords[questionId] = !revealedPasswords[questionId];
|
|
||||||
revealedPasswords = revealedPasswords; // trigger reactivity
|
|
||||||
}
|
}
|
||||||
|
onMount(() => {
|
||||||
function selectConfig(config: (typeof configurations)[number]) {
|
checkMobile();
|
||||||
if (selectedConfig?.id === config.id) {
|
window.addEventListener("resize", checkMobile);
|
||||||
// Clicking the active config collapses back to full list
|
return () => window.removeEventListener("resize", checkMobile);
|
||||||
selectedConfig = null;
|
|
||||||
} else {
|
|
||||||
selectedConfig = config;
|
|
||||||
configFadeKey++; // bump key to trigger fade animation
|
|
||||||
revealedPasswords = {}; // reset password visibility
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function companyInitials(name: string): string {
|
|
||||||
return name
|
|
||||||
.split(/\s+/)
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((w) => w[0])
|
|
||||||
.join("")
|
|
||||||
.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusClass(status?: string): string {
|
|
||||||
if (!status) return "neutral";
|
|
||||||
const s = status.toLowerCase();
|
|
||||||
if (s === "active") return "active";
|
|
||||||
if (s === "inactive" || s === "disabled") return "inactive";
|
|
||||||
if (s === "pending") return "pending";
|
|
||||||
return "neutral";
|
|
||||||
}
|
|
||||||
|
|
||||||
function configStatusClass(statusName?: string): string {
|
|
||||||
if (!statusName) return "neutral";
|
|
||||||
const s = statusName.toLowerCase();
|
|
||||||
if (s === "active") return "active";
|
|
||||||
if (s === "inactive" || s === "automate inactive") return "inactive";
|
|
||||||
if (s === "reserved") return "reserved";
|
|
||||||
if (s === "provisioning" || s === "pending approval") return "pending";
|
|
||||||
return "neutral";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr?: string): string {
|
|
||||||
if (!dateStr) return "";
|
|
||||||
try {
|
|
||||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
});
|
||||||
} catch {
|
|
||||||
return "";
|
// Create credential modal state (bound to CredentialsTab)
|
||||||
}
|
let isCreateCredentialOpen = false;
|
||||||
|
// Link UniFi site modal state (bound to UniFiTab)
|
||||||
|
let isLinkUnifiOpen = false;
|
||||||
|
|
||||||
|
// Tab navigation
|
||||||
|
const tabs = [
|
||||||
|
"Overview",
|
||||||
|
"Credentials",
|
||||||
|
"Configurations",
|
||||||
|
"UniFi",
|
||||||
|
"Contacts",
|
||||||
|
"Activity",
|
||||||
|
] as const;
|
||||||
|
type Tab = (typeof tabs)[number];
|
||||||
|
let activeTab: Tab = "Overview";
|
||||||
|
|
||||||
|
// Mobile nav state: null = show vertical nav menu; set = show tab content
|
||||||
|
let mobileActiveTab: Tab | null = null;
|
||||||
|
|
||||||
|
function selectMobileTab(tab: Tab) {
|
||||||
|
activeTab = tab;
|
||||||
|
mobileActiveTab = tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAddress(c: NonNullable<typeof company>): string[] {
|
function mobileBack() {
|
||||||
// Prefer the nested cw_Data.address structure returned when includeAddress=true
|
mobileActiveTab = null;
|
||||||
const addr = c.cw_Data?.address;
|
|
||||||
if (addr) {
|
|
||||||
const lines: string[] = [];
|
|
||||||
if (addr.line1) lines.push(addr.line1);
|
|
||||||
if (addr.line2) lines.push(addr.line2);
|
|
||||||
const cityStateZip = [addr.city, addr.state, addr.zip]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ");
|
|
||||||
if (cityStateZip) lines.push(cityStateZip);
|
|
||||||
if (addr.country) lines.push(addr.country);
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
// Fallback to flat fields
|
|
||||||
const lines: string[] = [];
|
|
||||||
if (c.address) lines.push(c.address);
|
|
||||||
const cityStateZip = [c.city, c.state, c.zip].filter(Boolean).join(", ");
|
|
||||||
if (cityStateZip) lines.push(cityStateZip);
|
|
||||||
if (c.country) lines.push(c.country);
|
|
||||||
return lines;
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -172,192 +69,195 @@
|
|||||||
|
|
||||||
<div class="company-detail-page">
|
<div class="company-detail-page">
|
||||||
<!-- Left pane (1/4) — Company overview -->
|
<!-- Left pane (1/4) — Company overview -->
|
||||||
<div class="company-detail-left">
|
<CompanySidebar {company} {permissions} {isMobile} {mobileActiveTab} />
|
||||||
<div class="detail-pane-body">
|
|
||||||
|
<!-- Mobile vertical nav menu (only visible on mobile when no tab selected) -->
|
||||||
|
{#if isMobile && mobileActiveTab === null}
|
||||||
|
<div class="mobile-nav-menu">
|
||||||
|
{#each tabs as tab}
|
||||||
<button
|
<button
|
||||||
class="back-btn"
|
class="mobile-nav-item"
|
||||||
on:click={() => goto("/companies")}
|
on:click={() => selectMobileTab(tab)}
|
||||||
aria-label="Back to companies"
|
type="button"
|
||||||
>
|
>
|
||||||
|
<span class="mobile-nav-icon">
|
||||||
|
{#if tab === "Credentials"}
|
||||||
<svg
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path
|
||||||
|
d="M7 11V7a5 5 0 0 1 10 0v4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else if tab === "Configurations"}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="3" /><path
|
||||||
|
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else if tab === "UniFi"}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z" /><path
|
||||||
|
d="M2 17l10 5 10-5"
|
||||||
|
/><path d="M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
{:else if tab === "Contacts"}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" /><circle
|
||||||
|
cx="9"
|
||||||
|
cy="7"
|
||||||
|
r="4"
|
||||||
|
/><path d="M23 21v-2a4 4 0 00-3-3.87" /><path
|
||||||
|
d="M16 3.13a4 4 0 010 7.75"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="mobile-nav-label">{tab}</span>
|
||||||
|
{#if tab === "Credentials" && credentials.length > 0}
|
||||||
|
<span class="mobile-nav-badge">{credentials.length}</span>
|
||||||
|
{/if}
|
||||||
|
{#if tab === "Configurations" && configurations.length > 0}
|
||||||
|
<span class="mobile-nav-badge">{configurations.length}</span>
|
||||||
|
{/if}
|
||||||
|
{#if tab === "UniFi" && unifiSites.length > 0}
|
||||||
|
<span class="mobile-nav-badge">{unifiSites.length}</span>
|
||||||
|
{/if}
|
||||||
|
{#if tab === "Contacts" && (company?.cw_Data?.allContacts?.length ?? 0) > 0}
|
||||||
|
<span class="mobile-nav-badge"
|
||||||
|
>{company?.cw_Data?.allContacts?.length}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<svg
|
||||||
|
class="mobile-nav-chevron"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
width="16"
|
width="16"
|
||||||
height="16"
|
height="16"
|
||||||
|
>
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Right pane (3/4) -->
|
||||||
|
<div
|
||||||
|
class="company-detail-right"
|
||||||
|
class:mobile-hidden={isMobile && mobileActiveTab === null}
|
||||||
|
>
|
||||||
|
<!-- Mobile content header with back button -->
|
||||||
|
{#if isMobile && mobileActiveTab !== null}
|
||||||
|
<div class="mobile-content-header">
|
||||||
|
<button
|
||||||
|
class="mobile-back-btn"
|
||||||
|
on:click={mobileBack}
|
||||||
|
type="button"
|
||||||
|
aria-label="Back to menu"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
>
|
>
|
||||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{#if company}
|
<h3 class="mobile-content-title">{mobileActiveTab}</h3>
|
||||||
<!-- Avatar + name + status -->
|
{#if mobileActiveTab === "Credentials"}
|
||||||
<div class="profile-header">
|
<button
|
||||||
<div class="profile-avatar">
|
type="button"
|
||||||
<span class="profile-initials">{companyInitials(company.name)}</span
|
class="create-credential-btn mobile-create-btn"
|
||||||
|
on:click={() => (isCreateCredentialOpen = true)}
|
||||||
>
|
>
|
||||||
</div>
|
|
||||||
<h3 class="profile-name">{company.name}</h3>
|
|
||||||
{#if company.status}
|
|
||||||
<span class="profile-status {statusClass(company.status)}"
|
|
||||||
>{company.status}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Info rows -->
|
|
||||||
<div class="profile-info">
|
|
||||||
{#if company.type}
|
|
||||||
<div class="info-row">
|
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
class="info-icon"
|
width="14"
|
||||||
|
height="14"
|
||||||
>
|
>
|
||||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
|
<line x1="12" y1="5" x2="12" y2="19" /><line
|
||||||
<path d="M16 3h-8l-2 4h12z" />
|
x1="5"
|
||||||
</svg>
|
y1="12"
|
||||||
<div class="info-content">
|
x2="19"
|
||||||
<span class="info-label">Type</span>
|
y2="12"
|
||||||
<span class="info-value">{company.type}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if company.identifier || company.id}
|
|
||||||
<div class="info-row">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
class="info-icon"
|
|
||||||
>
|
|
||||||
<path d="M4 9h16M4 15h16M10 3L8 21M16 3l-2 18" />
|
|
||||||
</svg>
|
|
||||||
<div class="info-content">
|
|
||||||
<span class="info-label">Identifier</span>
|
|
||||||
<span class="info-value mono"
|
|
||||||
>{company.identifier || company.id}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if company.contactEmail}
|
|
||||||
<div class="info-row">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
class="info-icon"
|
|
||||||
>
|
|
||||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
|
||||||
<path d="M22 7l-10 7L2 7" />
|
|
||||||
</svg>
|
|
||||||
<div class="info-content">
|
|
||||||
<span class="info-label">Email</span>
|
|
||||||
<span class="info-value">{company.contactEmail}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if company.contactPhone}
|
|
||||||
<div class="info-row">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
class="info-icon"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="info-content">
|
New
|
||||||
<span class="info-label">Phone</span>
|
</button>
|
||||||
<span class="info-value">{company.contactPhone}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if mobileActiveTab === "UniFi"}
|
||||||
{#if permissions["company.fetch.address"] && formatAddress(company).length > 0}
|
<button
|
||||||
<div class="info-row">
|
type="button"
|
||||||
|
class="create-credential-btn mobile-create-btn"
|
||||||
|
on:click={() => (isLinkUnifiOpen = true)}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
class="info-icon"
|
width="14"
|
||||||
|
height="14"
|
||||||
>
|
>
|
||||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
|
<path
|
||||||
<circle cx="12" cy="10" r="3" />
|
d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="info-content">
|
Link
|
||||||
<span class="info-label">Address</span>
|
</button>
|
||||||
<span class="info-value address-multiline">
|
{/if}
|
||||||
{#each formatAddress(company) as line}
|
|
||||||
{line}<br />
|
|
||||||
{/each}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if formatDate(company.createdAt)}
|
|
||||||
<div class="info-row">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
class="info-icon"
|
|
||||||
>
|
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
|
||||||
<line x1="16" y1="2" x2="16" y2="6" />
|
|
||||||
<line x1="8" y1="2" x2="8" y2="6" />
|
|
||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
|
||||||
</svg>
|
|
||||||
<div class="info-content">
|
|
||||||
<span class="info-label">Created</span>
|
|
||||||
<span class="info-value">{formatDate(company.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if formatDate(company.updatedAt)}
|
|
||||||
<div class="info-row">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
class="info-icon"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<polyline points="12 6 12 12 16 14" />
|
|
||||||
</svg>
|
|
||||||
<div class="info-content">
|
|
||||||
<span class="info-label">Updated</span>
|
|
||||||
<span class="info-value">{formatDate(company.updatedAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="profile-empty">
|
|
||||||
<p>Company not found.</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right pane (3/4) -->
|
|
||||||
<div class="company-detail-right">
|
|
||||||
<div class="tab-bar" role="tablist">
|
<div class="tab-bar" role="tablist">
|
||||||
{#each tabs as tab}
|
{#each tabs as tab}
|
||||||
<button
|
<button
|
||||||
@@ -368,344 +268,91 @@
|
|||||||
on:click={() => (activeTab = tab)}
|
on:click={() => (activeTab = tab)}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
|
{#if tab === "Credentials" && credentials.length > 0}
|
||||||
|
<span class="tab-count-badge">{credentials.length}</span>
|
||||||
|
{/if}
|
||||||
{#if tab === "Configurations" && configurations.length > 0}
|
{#if tab === "Configurations" && configurations.length > 0}
|
||||||
<span class="tab-count-badge">{configurations.length}</span>
|
<span class="tab-count-badge">{configurations.length}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if tab === "UniFi" && unifiSites.length > 0}
|
||||||
|
<span class="tab-count-badge">{unifiSites.length}</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if activeTab === "Credentials"}
|
||||||
|
<div class="tab-bar-spacer"></div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="create-credential-btn"
|
||||||
|
on:click={() => (isCreateCredentialOpen = true)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Create Credential
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if activeTab === "UniFi"}
|
||||||
|
<div class="tab-bar-spacer"></div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="create-credential-btn"
|
||||||
|
on:click={() => (isLinkUnifiOpen = true)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Link Site
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-pane-body">
|
<div class="detail-pane-body">
|
||||||
{#if activeTab === "Credentials"}
|
{#if activeTab === "Overview"}
|
||||||
<p class="tab-placeholder">Credentials content</p>
|
<OverviewTab {company} {credentials} {configurations} {unifiSites} />
|
||||||
|
{:else if activeTab === "Credentials"}
|
||||||
|
<CredentialsTab
|
||||||
|
companyId={company?.id ?? ""}
|
||||||
|
{credentials}
|
||||||
|
{credentialTypes}
|
||||||
|
{accessToken}
|
||||||
|
{permissions}
|
||||||
|
{isMobile}
|
||||||
|
bind:isCreateCredentialOpen
|
||||||
|
/>
|
||||||
{:else if activeTab === "Configurations"}
|
{:else if activeTab === "Configurations"}
|
||||||
{#if configurations.length === 0}
|
<ConfigurationsTab {configurations} {isMobile} />
|
||||||
<div class="tab-empty">
|
{:else if activeTab === "UniFi"}
|
||||||
<svg
|
<UniFiTab
|
||||||
viewBox="0 0 24 24"
|
companyId={company?.id ?? ""}
|
||||||
fill="none"
|
{unifiSites}
|
||||||
stroke="currentColor"
|
{accessToken}
|
||||||
stroke-width="1.5"
|
{permissions}
|
||||||
class="tab-empty-icon"
|
bind:isLinkUnifiOpen
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
{:else if activeTab === "Contacts"}
|
||||||
<p>No configurations found</p>
|
<ContactsTab {company} />
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="config-split" class:expanded={selectedConfig !== null}>
|
|
||||||
<!-- Left side: config buttons -->
|
|
||||||
<div class="config-list" class:collapsed={selectedConfig !== null}>
|
|
||||||
{#each configurations as config (config.id)}
|
|
||||||
<button
|
|
||||||
class="config-item"
|
|
||||||
class:selected={selectedConfig?.id === config.id}
|
|
||||||
class:config-inactive={config.status?.name === "Inactive" ||
|
|
||||||
config.status?.name === "Automate Inactive"}
|
|
||||||
on:click={() => selectConfig(config)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<div class="config-item-header">
|
|
||||||
<div class="config-name-group">
|
|
||||||
<span
|
|
||||||
class="config-status-dot dot-{configStatusClass(
|
|
||||||
config.status?.name,
|
|
||||||
)}"
|
|
||||||
title={config.status?.name ?? "Unknown"}
|
|
||||||
></span>
|
|
||||||
<span class="config-name">{config.name}</span>
|
|
||||||
</div>
|
|
||||||
<div class="config-header-badges">
|
|
||||||
{#if config.status?.name && !selectedConfig}
|
|
||||||
<span
|
|
||||||
class="config-status-badge status-{configStatusClass(
|
|
||||||
config.status.name,
|
|
||||||
)}">{config.status.name}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{#if config.type?.name && !selectedConfig}
|
|
||||||
<span class="config-type-badge">{config.type.name}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if !selectedConfig}
|
|
||||||
{#if config.description}
|
|
||||||
<p class="config-description">{config.description}</p>
|
|
||||||
{/if}
|
|
||||||
{#if config.key}
|
|
||||||
<div class="config-kv">
|
|
||||||
<span class="config-key">{config.key}</span>
|
|
||||||
{#if config.value}
|
|
||||||
<span class="config-value">{config.value}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if formatDate(config.updatedAt) || formatDate(config.createdAt) || formatDate(config.info?.lastUpdated) || formatDate(config.info?.dateEntered)}
|
|
||||||
<span class="config-date">
|
|
||||||
{#if formatDate(config.updatedAt)}
|
|
||||||
Updated {formatDate(config.updatedAt)}
|
|
||||||
{:else if formatDate(config.info?.lastUpdated)}
|
|
||||||
Updated {formatDate(config.info?.lastUpdated)}
|
|
||||||
{:else if formatDate(config.createdAt)}
|
|
||||||
Created {formatDate(config.createdAt)}
|
|
||||||
{:else if formatDate(config.info?.dateEntered)}
|
|
||||||
Created {formatDate(config.info?.dateEntered)}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right side: config detail panel -->
|
|
||||||
{#if selectedConfig}
|
|
||||||
<div class="config-detail-panel">
|
|
||||||
{#key configFadeKey}
|
|
||||||
<div class="config-detail-content">
|
|
||||||
<div class="config-detail-header">
|
|
||||||
<div class="config-detail-header-left">
|
|
||||||
<h3 class="config-detail-title">
|
|
||||||
{selectedConfig.name}
|
|
||||||
</h3>
|
|
||||||
<div class="config-detail-meta-badges">
|
|
||||||
{#if selectedConfig.type?.name}
|
|
||||||
<span class="config-badge type"
|
|
||||||
>{selectedConfig.type.name}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{#if selectedConfig.status?.name}
|
|
||||||
<span
|
|
||||||
class="config-badge status-{configStatusClass(
|
|
||||||
selectedConfig.status.name,
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
{selectedConfig.status.name}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="config-detail-close"
|
|
||||||
on:click={() => (selectedConfig = null)}
|
|
||||||
aria-label="Close detail view"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
>
|
|
||||||
<path d="M18 6L6 18M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if selectedConfig.serialNumber}
|
|
||||||
<div class="config-serial">
|
|
||||||
<span class="config-serial-label">Serial #</span>
|
|
||||||
<span class="config-serial-value"
|
|
||||||
>{selectedConfig.serialNumber}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Notes -->
|
|
||||||
{#if selectedConfig.notes}
|
|
||||||
<div class="config-notes">
|
|
||||||
<h4 class="config-section-title">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
|
|
||||||
/>
|
|
||||||
<polyline points="14 2 14 8 20 8" />
|
|
||||||
<line x1="16" y1="13" x2="8" y2="13" />
|
|
||||||
<line x1="16" y1="17" x2="8" y2="17" />
|
|
||||||
<polyline points="10 9 9 9 8 9" />
|
|
||||||
</svg>
|
|
||||||
Notes
|
|
||||||
</h4>
|
|
||||||
<p class="config-notes-text">{selectedConfig.notes}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Questions / Fields -->
|
|
||||||
{#if selectedConfig.questions && selectedConfig.questions.length > 0}
|
|
||||||
<div class="config-questions">
|
|
||||||
<h4 class="config-section-title">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"
|
|
||||||
/>
|
|
||||||
<rect x="9" y="3" width="6" height="4" rx="1" />
|
|
||||||
</svg>
|
|
||||||
Configuration Details
|
|
||||||
</h4>
|
|
||||||
<div class="questions-grid">
|
|
||||||
{#each selectedConfig.questions as q (q.id)}
|
|
||||||
<div
|
|
||||||
class="question-row"
|
|
||||||
class:has-answer={!!q.answer}
|
|
||||||
>
|
|
||||||
<span class="question-label">{q.question}</span>
|
|
||||||
<div class="question-value-wrap">
|
|
||||||
{#if q.fieldType === "Password"}
|
|
||||||
<span class="question-value password-value">
|
|
||||||
{#if revealedPasswords[q.id]}
|
|
||||||
{q.answer || "—"}
|
|
||||||
{:else}
|
|
||||||
{q.answer ? "••••••••" : "—"}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{#if q.answer}
|
|
||||||
<button
|
|
||||||
class="password-toggle"
|
|
||||||
on:click={() => togglePassword(q.id)}
|
|
||||||
type="button"
|
|
||||||
aria-label={revealedPasswords[q.id]
|
|
||||||
? "Hide password"
|
|
||||||
: "Show password"}
|
|
||||||
>
|
|
||||||
{#if revealedPasswords[q.id]}
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M14.12 14.12a3 3 0 11-4.24-4.24"
|
|
||||||
/>
|
|
||||||
<line x1="1" y1="1" x2="23" y2="23" />
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
|
|
||||||
/>
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{:else if q.fieldType === "TextArea"}
|
|
||||||
<span class="question-value textarea-value"
|
|
||||||
>{q.answer || "—"}</span
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<span class="question-value"
|
|
||||||
>{q.answer || "—"}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else if !selectedConfig.notes}
|
|
||||||
<div class="config-no-questions">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
width="32"
|
|
||||||
height="32"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
|
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
||||||
</svg>
|
|
||||||
<p>No configuration fields available</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Footer metadata -->
|
|
||||||
{#if selectedConfig.info}
|
|
||||||
<div class="config-info-footer">
|
|
||||||
{#if selectedConfig.info.enteredBy || selectedConfig.info.dateEntered}
|
|
||||||
<div class="config-info-item">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
width="13"
|
|
||||||
height="13"
|
|
||||||
>
|
|
||||||
<path d="M12 5v14M5 12h14" />
|
|
||||||
</svg>
|
|
||||||
Created{#if selectedConfig.info.enteredBy} by <strong
|
|
||||||
>{selectedConfig.info.enteredBy}</strong
|
|
||||||
>{/if}{#if selectedConfig.info.dateEntered} on
|
|
||||||
{formatDate(selectedConfig.info.dateEntered)}{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if selectedConfig.info.updatedBy || selectedConfig.info.lastUpdated}
|
|
||||||
<div class="config-info-item">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
width="13"
|
|
||||||
height="13"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<polyline points="12 6 12 12 16 14" />
|
|
||||||
</svg>
|
|
||||||
Updated{#if selectedConfig.info.updatedBy} by <strong
|
|
||||||
>{selectedConfig.info.updatedBy}</strong
|
|
||||||
>{/if}{#if selectedConfig.info.lastUpdated} on
|
|
||||||
{formatDate(selectedConfig.info.lastUpdated)}{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/key}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else if activeTab === "Users"}
|
|
||||||
<p class="tab-placeholder">Users content</p>
|
|
||||||
{:else if activeTab === "Activity"}
|
{:else if activeTab === "Activity"}
|
||||||
<p class="tab-placeholder">Activity content</p>
|
<ActivityTab />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Activity tab — placeholder for future implementation
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p class="tab-placeholder">Activity content</p>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import {
|
||||||
|
type CompanyData,
|
||||||
|
companyInitials,
|
||||||
|
statusClass,
|
||||||
|
formatDate,
|
||||||
|
formatPhone,
|
||||||
|
formatAddress,
|
||||||
|
} from "../types";
|
||||||
|
import type { PermissionMap } from "$lib/permissions";
|
||||||
|
|
||||||
|
export let company: CompanyData | null;
|
||||||
|
export let permissions: PermissionMap;
|
||||||
|
export let isMobile: boolean;
|
||||||
|
export let mobileActiveTab: string | null;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="company-detail-left"
|
||||||
|
class:mobile-collapsed={isMobile && mobileActiveTab !== null}
|
||||||
|
>
|
||||||
|
<div class="detail-pane-body">
|
||||||
|
<button
|
||||||
|
class="back-btn"
|
||||||
|
on:click={() => goto("/companies")}
|
||||||
|
aria-label="Back to companies"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{#if company}
|
||||||
|
<!-- Avatar + name + status -->
|
||||||
|
<div class="profile-header">
|
||||||
|
<div class="profile-avatar">
|
||||||
|
<span class="profile-initials">{companyInitials(company.name)}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="profile-name">{company.name}</h3>
|
||||||
|
{#if company.status}
|
||||||
|
<span class="profile-status {statusClass(company.status)}"
|
||||||
|
>{company.status}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info rows -->
|
||||||
|
<div class="profile-info">
|
||||||
|
{#if company.cw_Data?.primaryContact}
|
||||||
|
{@const contact = company.cw_Data.primaryContact}
|
||||||
|
<div class="primary-contact-section">
|
||||||
|
<div class="primary-contact-header">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
<span class="primary-contact-label">Primary Contact</span>
|
||||||
|
</div>
|
||||||
|
<div class="primary-contact-card">
|
||||||
|
<div class="primary-contact-name">
|
||||||
|
{[contact.firstName, contact.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
{#if contact.inactive}
|
||||||
|
<span class="primary-contact-inactive">Inactive</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if contact.title}
|
||||||
|
<div class="primary-contact-title">{contact.title}</div>
|
||||||
|
{/if}
|
||||||
|
{#if contact.email}
|
||||||
|
<div class="primary-contact-detail">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
>
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||||
|
<path d="M22 7l-10 7L2 7" />
|
||||||
|
</svg>
|
||||||
|
<a
|
||||||
|
href="mailto:{contact.email}"
|
||||||
|
class="primary-contact-link"
|
||||||
|
on:click|stopPropagation>{contact.email}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if contact.phone}
|
||||||
|
<div class="primary-contact-detail">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{formatPhone(contact.phone)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if company.type}
|
||||||
|
<div class="info-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="info-icon"
|
||||||
|
>
|
||||||
|
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
|
||||||
|
<path d="M16 3h-8l-2 4h12z" />
|
||||||
|
</svg>
|
||||||
|
<div class="info-content">
|
||||||
|
<span class="info-label">Type</span>
|
||||||
|
<span class="info-value">{company.type}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if company.contactEmail}
|
||||||
|
<div class="info-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="info-icon"
|
||||||
|
>
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||||
|
<path d="M22 7l-10 7L2 7" />
|
||||||
|
</svg>
|
||||||
|
<div class="info-content">
|
||||||
|
<span class="info-label">Email</span>
|
||||||
|
<span class="info-value">{company.contactEmail}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if company.contactPhone}
|
||||||
|
<div class="info-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="info-icon"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="info-content">
|
||||||
|
<span class="info-label">Phone</span>
|
||||||
|
<span class="info-value">{formatPhone(company.contactPhone)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if permissions["company.fetch.address"] && formatAddress(company).length > 0}
|
||||||
|
<div class="info-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="info-icon"
|
||||||
|
>
|
||||||
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
|
||||||
|
<circle cx="12" cy="10" r="3" />
|
||||||
|
</svg>
|
||||||
|
<div class="info-content">
|
||||||
|
<span class="info-label">Address</span>
|
||||||
|
<span class="info-value address-multiline">
|
||||||
|
{#each formatAddress(company) as line}
|
||||||
|
{line}<br />
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if formatDate(company.createdAt)}
|
||||||
|
<div class="info-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="info-icon"
|
||||||
|
>
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
|
</svg>
|
||||||
|
<div class="info-content">
|
||||||
|
<span class="info-label">Created</span>
|
||||||
|
<span class="info-value">{formatDate(company.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if formatDate(company.updatedAt)}
|
||||||
|
<div class="info-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="info-icon"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
<div class="info-content">
|
||||||
|
<span class="info-label">Updated</span>
|
||||||
|
<span class="info-value">{formatDate(company.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if company.identifier || company.id}
|
||||||
|
<div class="side-pane-identifier">
|
||||||
|
{company.identifier || company.id}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="profile-empty">
|
||||||
|
<p>Company not found.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ConfigurationData } from "../types";
|
||||||
|
import { formatDate, configStatusClass } from "../types";
|
||||||
|
|
||||||
|
export let configurations: ConfigurationData[];
|
||||||
|
export let isMobile: boolean;
|
||||||
|
|
||||||
|
// Configurations split-view state
|
||||||
|
let selectedConfig: ConfigurationData | null = null;
|
||||||
|
let configFadeKey = 0;
|
||||||
|
|
||||||
|
// Track which password fields are revealed (by question id)
|
||||||
|
let revealedPasswords: Record<number, boolean> = {};
|
||||||
|
|
||||||
|
function togglePassword(questionId: number) {
|
||||||
|
revealedPasswords[questionId] = !revealedPasswords[questionId];
|
||||||
|
revealedPasswords = revealedPasswords;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectConfig(config: ConfigurationData) {
|
||||||
|
if (selectedConfig?.id === config.id) {
|
||||||
|
selectedConfig = null;
|
||||||
|
} else {
|
||||||
|
selectedConfig = config;
|
||||||
|
configFadeKey++;
|
||||||
|
revealedPasswords = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if configurations.length === 0}
|
||||||
|
<div class="tab-empty">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
class="tab-empty-icon"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p>No configurations found</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="config-split"
|
||||||
|
class:expanded={selectedConfig !== null && !isMobile}
|
||||||
|
>
|
||||||
|
<!-- Left side: config buttons -->
|
||||||
|
<div
|
||||||
|
class="config-list"
|
||||||
|
class:collapsed={selectedConfig !== null && !isMobile}
|
||||||
|
>
|
||||||
|
{#each configurations as config (config.id)}
|
||||||
|
<button
|
||||||
|
class="config-item"
|
||||||
|
class:selected={selectedConfig?.id === config.id}
|
||||||
|
class:config-inactive={config.status?.name === "Inactive" ||
|
||||||
|
config.status?.name === "Automate Inactive"}
|
||||||
|
on:click={() => selectConfig(config)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div class="config-item-header">
|
||||||
|
<div class="config-name-group">
|
||||||
|
<span
|
||||||
|
class="config-status-dot dot-{configStatusClass(
|
||||||
|
config.status?.name,
|
||||||
|
)}"
|
||||||
|
title={config.status?.name ?? "Unknown"}
|
||||||
|
></span>
|
||||||
|
<span class="config-name">{config.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-header-badges">
|
||||||
|
{#if config.status?.name && (!selectedConfig || isMobile)}
|
||||||
|
<span
|
||||||
|
class="config-status-badge status-{configStatusClass(
|
||||||
|
config.status.name,
|
||||||
|
)}">{config.status.name}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if config.type?.name && (!selectedConfig || isMobile)}
|
||||||
|
<span class="config-type-badge">{config.type.name}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if !selectedConfig || isMobile}
|
||||||
|
{#if config.description}
|
||||||
|
<p class="config-description">{config.description}</p>
|
||||||
|
{/if}
|
||||||
|
{#if config.key}
|
||||||
|
<div class="config-kv">
|
||||||
|
<span class="config-key">{config.key}</span>
|
||||||
|
{#if config.value}
|
||||||
|
<span class="config-value">{config.value}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if formatDate(config.updatedAt) || formatDate(config.createdAt) || formatDate(config.info?.lastUpdated) || formatDate(config.info?.dateEntered)}
|
||||||
|
<span class="config-date">
|
||||||
|
{#if formatDate(config.updatedAt)}
|
||||||
|
Updated {formatDate(config.updatedAt)}
|
||||||
|
{:else if formatDate(config.info?.lastUpdated)}
|
||||||
|
Updated {formatDate(config.info?.lastUpdated)}
|
||||||
|
{:else if formatDate(config.createdAt)}
|
||||||
|
Created {formatDate(config.createdAt)}
|
||||||
|
{:else if formatDate(config.info?.dateEntered)}
|
||||||
|
Created {formatDate(config.info?.dateEntered)}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right side: config detail panel -->
|
||||||
|
{#if selectedConfig}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div
|
||||||
|
class="bottom-sheet-overlay"
|
||||||
|
class:active={selectedConfig !== null}
|
||||||
|
on:click={() => {
|
||||||
|
selectedConfig = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div class="bottom-sheet-panel" on:click|stopPropagation>
|
||||||
|
<div class="bottom-sheet-handle"></div>
|
||||||
|
<div class="bottom-sheet-body">
|
||||||
|
<div class="config-detail-panel">
|
||||||
|
{#key configFadeKey}
|
||||||
|
<div class="config-detail-content">
|
||||||
|
<div class="config-detail-header">
|
||||||
|
<div class="config-detail-header-left">
|
||||||
|
<h3 class="config-detail-title">
|
||||||
|
{selectedConfig.name}
|
||||||
|
</h3>
|
||||||
|
<div class="config-detail-meta-badges">
|
||||||
|
{#if selectedConfig.type?.name}
|
||||||
|
<span class="config-badge type"
|
||||||
|
>{selectedConfig.type.name}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if selectedConfig.status?.name}
|
||||||
|
<span
|
||||||
|
class="config-badge status-{configStatusClass(
|
||||||
|
selectedConfig.status.name,
|
||||||
|
)}"
|
||||||
|
>
|
||||||
|
{selectedConfig.status.name}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="config-detail-close"
|
||||||
|
on:click={() => (selectedConfig = null)}
|
||||||
|
aria-label="Close detail view"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedConfig.serialNumber}
|
||||||
|
<div class="config-serial">
|
||||||
|
<span class="config-serial-label">Serial #</span>
|
||||||
|
<span class="config-serial-value"
|
||||||
|
>{selectedConfig.serialNumber}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Questions / Fields -->
|
||||||
|
{#if (selectedConfig.questions && selectedConfig.questions.length > 0) || selectedConfig.notes}
|
||||||
|
<div class="config-questions">
|
||||||
|
{#if selectedConfig.notes}
|
||||||
|
<div class="config-notes">
|
||||||
|
<h4 class="config-section-title">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
|
||||||
|
/>
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<polyline points="10 9 9 9 8 9" />
|
||||||
|
</svg>
|
||||||
|
Notes
|
||||||
|
</h4>
|
||||||
|
<p class="config-notes-text">
|
||||||
|
{selectedConfig.notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<h4 class="config-section-title">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"
|
||||||
|
/>
|
||||||
|
<rect x="9" y="3" width="6" height="4" rx="1" />
|
||||||
|
</svg>
|
||||||
|
Configuration Details
|
||||||
|
</h4>
|
||||||
|
<div class="questions-grid">
|
||||||
|
{#each selectedConfig.questions as q (q.id)}
|
||||||
|
<div
|
||||||
|
class="question-row"
|
||||||
|
class:has-answer={!!q.answer}
|
||||||
|
>
|
||||||
|
<span class="question-label">{q.question}</span>
|
||||||
|
<div class="question-value-wrap">
|
||||||
|
{#if q.fieldType === "Password"}
|
||||||
|
<span class="question-value password-value">
|
||||||
|
{#if revealedPasswords[q.id]}
|
||||||
|
{q.answer || "—"}
|
||||||
|
{:else}
|
||||||
|
{q.answer ? "••••••••" : "—"}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if q.answer}
|
||||||
|
<button
|
||||||
|
class="password-toggle"
|
||||||
|
on:click={() => togglePassword(q.id)}
|
||||||
|
type="button"
|
||||||
|
aria-label={revealedPasswords[q.id]
|
||||||
|
? "Hide password"
|
||||||
|
: "Show password"}
|
||||||
|
>
|
||||||
|
{#if revealedPasswords[q.id]}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14.12 14.12a3 3 0 11-4.24-4.24"
|
||||||
|
/>
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{:else if q.fieldType === "TextArea"}
|
||||||
|
<span class="question-value textarea-value"
|
||||||
|
>{q.answer || "—"}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="question-value"
|
||||||
|
>{q.answer || "—"}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !selectedConfig.notes}
|
||||||
|
<div class="config-no-questions">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
|
</svg>
|
||||||
|
<p>No configuration fields available</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Footer metadata -->
|
||||||
|
{#if selectedConfig.info}
|
||||||
|
<div class="config-info-footer">
|
||||||
|
{#if selectedConfig.info.enteredBy || selectedConfig.info.dateEntered}
|
||||||
|
<div class="config-info-item">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
>
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
Created{#if selectedConfig.info.enteredBy} by
|
||||||
|
<strong>{selectedConfig.info.enteredBy}</strong
|
||||||
|
>{/if}{#if selectedConfig.info.dateEntered} on
|
||||||
|
{formatDate(selectedConfig.info.dateEntered)}{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if selectedConfig.info.updatedBy || selectedConfig.info.lastUpdated}
|
||||||
|
<div class="config-info-item">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
Updated{#if selectedConfig.info.updatedBy} by
|
||||||
|
<strong>{selectedConfig.info.updatedBy}</strong
|
||||||
|
>{/if}{#if selectedConfig.info.lastUpdated} on
|
||||||
|
{formatDate(selectedConfig.info.lastUpdated)}{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CompanyData } from "../types";
|
||||||
|
import { formatPhone } from "../types";
|
||||||
|
|
||||||
|
export let company: CompanyData | null;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !company?.cw_Data?.allContacts || company.cw_Data.allContacts.length === 0}
|
||||||
|
<div class="tab-empty">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
class="tab-empty-icon"
|
||||||
|
>
|
||||||
|
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 00-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 010 7.75" />
|
||||||
|
</svg>
|
||||||
|
<p>No contacts found</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@const activeContacts = company.cw_Data.allContacts.filter(
|
||||||
|
(c) => !c.inactive,
|
||||||
|
)}
|
||||||
|
{@const inactiveContacts = company.cw_Data.allContacts.filter(
|
||||||
|
(c) => c.inactive,
|
||||||
|
)}
|
||||||
|
|
||||||
|
<!-- Active contacts -->
|
||||||
|
{#if activeContacts.length > 0}
|
||||||
|
<div class="contacts-section">
|
||||||
|
<div class="contacts-section-header">
|
||||||
|
<h3 class="contacts-section-title">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--text-muted)"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 00-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 010 7.75" />
|
||||||
|
</svg>
|
||||||
|
Active
|
||||||
|
</h3>
|
||||||
|
<span class="contacts-section-count">{activeContacts.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="contacts-grid">
|
||||||
|
{#each activeContacts as contact (contact.cwId ?? `${contact.firstName}-${contact.lastName}`)}
|
||||||
|
<div class="contact-card">
|
||||||
|
<div class="contact-card-header">
|
||||||
|
<div class="contact-avatar">
|
||||||
|
<span class="contact-initials">
|
||||||
|
{contact.firstName?.[0] ?? ""}{contact.lastName?.[0] ?? ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="contact-card-info">
|
||||||
|
<div class="contact-name">
|
||||||
|
{[contact.firstName, contact.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
</div>
|
||||||
|
{#if contact.title}
|
||||||
|
<div class="contact-title">{contact.title}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="contact-card-details">
|
||||||
|
{#if contact.email}
|
||||||
|
<div class="contact-detail-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--text-secondary)"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
style="min-width:13px;min-height:13px;"
|
||||||
|
>
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||||
|
<path d="M22 7l-10 7L2 7" />
|
||||||
|
</svg>
|
||||||
|
<a
|
||||||
|
href="mailto:{contact.email}"
|
||||||
|
class="contact-link"
|
||||||
|
on:click|stopPropagation>{contact.email}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if contact.phone}
|
||||||
|
<div class="contact-detail-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--text-secondary)"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
style="min-width:13px;min-height:13px;"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{formatPhone(contact.phone)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Inactive contacts -->
|
||||||
|
{#if inactiveContacts.length > 0}
|
||||||
|
<div class="contacts-section contacts-section-inactive">
|
||||||
|
<div class="contacts-section-header">
|
||||||
|
<h3 class="contacts-section-title">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--text-muted)"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<line x1="23" y1="13" x2="17" y2="13" />
|
||||||
|
</svg>
|
||||||
|
Inactive
|
||||||
|
</h3>
|
||||||
|
<span class="contacts-section-count inactive"
|
||||||
|
>{inactiveContacts.length}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="contacts-grid">
|
||||||
|
{#each inactiveContacts as contact (contact.cwId ?? `${contact.firstName}-${contact.lastName}`)}
|
||||||
|
<div class="contact-card contact-inactive">
|
||||||
|
<div class="contact-card-header">
|
||||||
|
<div class="contact-avatar contact-avatar-inactive">
|
||||||
|
<span class="contact-initials">
|
||||||
|
{contact.firstName?.[0] ?? ""}{contact.lastName?.[0] ?? ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="contact-card-info">
|
||||||
|
<div class="contact-name">
|
||||||
|
{[contact.firstName, contact.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
<span class="contact-inactive-badge">Inactive</span>
|
||||||
|
</div>
|
||||||
|
{#if contact.title}
|
||||||
|
<div class="contact-title">{contact.title}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="contact-card-details">
|
||||||
|
{#if contact.email}
|
||||||
|
<div class="contact-detail-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--text-secondary)"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
style="min-width:13px;min-height:13px;"
|
||||||
|
>
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||||
|
<path d="M22 7l-10 7L2 7" />
|
||||||
|
</svg>
|
||||||
|
<a
|
||||||
|
href="mailto:{contact.email}"
|
||||||
|
class="contact-link"
|
||||||
|
on:click|stopPropagation>{contact.email}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if contact.phone}
|
||||||
|
<div class="contact-detail-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--text-secondary)"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
style="min-width:13px;min-height:13px;"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{formatPhone(contact.phone)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CompanyData, ConfigurationData } from "../types";
|
||||||
|
import type { Credential } from "$lib/optima-api/modules/credentials";
|
||||||
|
import type { UnifiSite } from "$lib/optima-api/modules/unifi";
|
||||||
|
|
||||||
|
export let company: CompanyData | null;
|
||||||
|
export let credentials: Credential[];
|
||||||
|
export let configurations: ConfigurationData[];
|
||||||
|
export let unifiSites: UnifiSite[];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overview-tab">
|
||||||
|
<div class="overview-section">
|
||||||
|
<h3 class="overview-section-title">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
Company Details
|
||||||
|
</h3>
|
||||||
|
<div class="overview-details-grid">
|
||||||
|
<div class="overview-detail-item">
|
||||||
|
<span class="overview-detail-label">Name</span>
|
||||||
|
<span class="overview-detail-value">{company?.name ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="overview-detail-item">
|
||||||
|
<span class="overview-detail-label">ID</span>
|
||||||
|
<span class="overview-detail-value mono">{company?.id ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overview-section">
|
||||||
|
<h3 class="overview-section-title">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||||
|
</svg>
|
||||||
|
At a Glance
|
||||||
|
</h3>
|
||||||
|
<div class="overview-stats-grid">
|
||||||
|
<div class="overview-stat-card">
|
||||||
|
<span class="overview-stat-value">{credentials.length}</span>
|
||||||
|
<span class="overview-stat-label">Credentials</span>
|
||||||
|
</div>
|
||||||
|
<div class="overview-stat-card">
|
||||||
|
<span class="overview-stat-value">{configurations.length}</span>
|
||||||
|
<span class="overview-stat-label">Configurations</span>
|
||||||
|
</div>
|
||||||
|
<div class="overview-stat-card">
|
||||||
|
<span class="overview-stat-value">{unifiSites.length}</span>
|
||||||
|
<span class="overview-stat-label">UniFi Sites</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overview-section">
|
||||||
|
<h3 class="overview-section-title">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 6v6l4 2" />
|
||||||
|
</svg>
|
||||||
|
Recent Activity
|
||||||
|
</h3>
|
||||||
|
<p class="overview-placeholder">Activity feed coming soon.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
import { optima } from "$lib";
|
||||||
|
import { json, error } from "@sveltejs/kit";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
throw error(401, "Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentialId = url.searchParams.get("credentialId");
|
||||||
|
const fieldId = url.searchParams.get("fieldId");
|
||||||
|
if (!credentialId || !fieldId) {
|
||||||
|
throw error(400, "Missing credentialId or fieldId");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await optima.credential.fetchSecureValue(
|
||||||
|
accessToken,
|
||||||
|
credentialId,
|
||||||
|
fieldId,
|
||||||
|
);
|
||||||
|
return json(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("Failed to fetch secure value:", err);
|
||||||
|
const status =
|
||||||
|
err && typeof err === "object" && "status" in err
|
||||||
|
? (err as { status: number }).status
|
||||||
|
: 500;
|
||||||
|
throw error(status, "Failed to fetch secure value");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import type { Credential } from "$lib/optima-api/modules/credentials";
|
||||||
|
import type { CredentialType } from "$lib/optima-api/modules/credentialTypes";
|
||||||
|
import type { UnifiSite } from "$lib/optima-api/modules/unifi";
|
||||||
|
import type { PermissionMap } from "$lib/permissions";
|
||||||
|
|
||||||
|
export interface CompanyData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status?: string;
|
||||||
|
type?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
identifier?: string;
|
||||||
|
contactEmail?: string;
|
||||||
|
contactPhone?: string;
|
||||||
|
address?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip?: string;
|
||||||
|
country?: string;
|
||||||
|
cw_Data?: {
|
||||||
|
address?: {
|
||||||
|
line1?: string;
|
||||||
|
line2?: string | null;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip?: string;
|
||||||
|
country?: string;
|
||||||
|
};
|
||||||
|
primaryContact?: {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
cwId?: number;
|
||||||
|
inactive?: boolean;
|
||||||
|
title?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
allContacts?: Array<{
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
cwId?: number;
|
||||||
|
inactive?: boolean;
|
||||||
|
title?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
}>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigurationData {
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
active?: boolean;
|
||||||
|
serialNumber?: string;
|
||||||
|
status?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
type?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
_info?: { type_href?: string };
|
||||||
|
};
|
||||||
|
notes?: string;
|
||||||
|
questions?: Array<{
|
||||||
|
id: number;
|
||||||
|
question: string;
|
||||||
|
answer?: string;
|
||||||
|
fieldType: string;
|
||||||
|
}> | null;
|
||||||
|
info?: {
|
||||||
|
lastUpdated?: string;
|
||||||
|
updatedBy?: string;
|
||||||
|
dateEntered?: string;
|
||||||
|
enteredBy?: string;
|
||||||
|
};
|
||||||
|
key?: string;
|
||||||
|
value?: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageData {
|
||||||
|
company: CompanyData | null;
|
||||||
|
configurations: ConfigurationData[];
|
||||||
|
credentials: Credential[];
|
||||||
|
credentialTypes: CredentialType[];
|
||||||
|
unifiSites: UnifiSite[];
|
||||||
|
accessToken: string | null;
|
||||||
|
permissions: PermissionMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared utility functions
|
||||||
|
export function companyInitials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusClass(status?: string): string {
|
||||||
|
if (!status) return "neutral";
|
||||||
|
const s = status.toLowerCase();
|
||||||
|
if (s === "active") return "active";
|
||||||
|
if (s === "inactive" || s === "disabled") return "inactive";
|
||||||
|
if (s === "pending") return "pending";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configStatusClass(statusName?: string): string {
|
||||||
|
if (!statusName) return "neutral";
|
||||||
|
const s = statusName.toLowerCase();
|
||||||
|
if (s === "active") return "active";
|
||||||
|
if (s === "inactive" || s === "automate inactive") return "inactive";
|
||||||
|
if (s === "reserved") return "reserved";
|
||||||
|
if (s === "provisioning" || s === "pending approval") return "pending";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(dateStr?: string): string {
|
||||||
|
if (!dateStr) return "";
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPhone(phone?: string): string {
|
||||||
|
if (!phone) return "";
|
||||||
|
const digits = phone.replace(/\D/g, "");
|
||||||
|
if (digits.length === 10) {
|
||||||
|
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||||
|
}
|
||||||
|
if (digits.length === 11 && digits.startsWith("1")) {
|
||||||
|
return `+1 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`;
|
||||||
|
}
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatValueTypeLabel(vt: string): string {
|
||||||
|
return vt
|
||||||
|
.split("_")
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAddress(c: CompanyData): string[] {
|
||||||
|
const addr = c.cw_Data?.address;
|
||||||
|
if (addr) {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (addr.line1) lines.push(addr.line1);
|
||||||
|
if (addr.line2) lines.push(addr.line2);
|
||||||
|
const cityStateZip = [addr.city, addr.state, addr.zip]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
if (cityStateZip) lines.push(cityStateZip);
|
||||||
|
if (addr.country) lines.push(addr.country);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (c.address) lines.push(c.address);
|
||||||
|
const cityStateZip = [c.city, c.state, c.zip].filter(Boolean).join(", ");
|
||||||
|
if (cityStateZip) lines.push(cityStateZip);
|
||||||
|
if (c.country) lines.push(c.country);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUptime(seconds: number): string {
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (days > 0) return `${days}d ${hours}h`;
|
||||||
|
if (hours > 0) return `${hours}h ${mins}m`;
|
||||||
|
return `${mins}m`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
/* ── Credential Types Admin Page ── */
|
||||||
|
|
||||||
|
/* ── Table header layout ── */
|
||||||
|
:global(.admin-table-header) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-ct-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
background: var(--accent-color, #0066cc);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-ct-btn:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Search bar ── */
|
||||||
|
.ct-search-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-search-input {
|
||||||
|
padding: 7px 12px 7px 30px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
width: 220px;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-search-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--input-focus-border);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Credential Type row ── */
|
||||||
|
.ct-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-row.expanded {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-name-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-scope {
|
||||||
|
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-inset);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-field-count {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-cred-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-cred-count svg {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Expanded detail row ── */
|
||||||
|
.ct-detail-row td {
|
||||||
|
padding: 0 !important;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-detail-content {
|
||||||
|
padding: 16px 24px 20px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
animation: ctDetailFadeIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ctDetailFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-detail-section {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-detail-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-detail-empty {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Field cards in detail view ── */
|
||||||
|
.ct-field-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-field-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-field-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--status-neutral-bg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-field-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-field-name {
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-field-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-field-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--status-neutral-bg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-field-badge.required {
|
||||||
|
background: rgba(0, 102, 204, 0.08);
|
||||||
|
color: var(--accent-color, #0066cc);
|
||||||
|
border-color: rgba(0, 102, 204, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-field-badge.secure {
|
||||||
|
background: rgba(220, 38, 38, 0.06);
|
||||||
|
color: #dc2626;
|
||||||
|
border-color: rgba(220, 38, 38, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sub-field display in detail view ── */
|
||||||
|
.ct-subfield-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
padding-left: 14px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-subfield-connector {
|
||||||
|
width: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-subfield-connector::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: -3px;
|
||||||
|
bottom: 50%;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-subfield-connector::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
width: 12px;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-subtle);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-subfield-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-subfield-card {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-style: dashed;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-subfield-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Detail info fields ── */
|
||||||
|
.ct-detail-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-detail-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-mono {
|
||||||
|
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Three-dot menu ── */
|
||||||
|
.ct-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 200;
|
||||||
|
min-width: 130px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||||
|
padding: 4px;
|
||||||
|
animation: menuIn 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-menu-item:hover {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-menu-item svg {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-menu-item-danger {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-menu-item-danger svg {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-menu-item-danger:hover {
|
||||||
|
background: rgba(220, 38, 38, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-menu-sep {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-subtle);
|
||||||
|
margin: 3px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Delete confirmation overlay ── */
|
||||||
|
.confirm-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog {
|
||||||
|
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: 380px;
|
||||||
|
padding: 28px 24px 22px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
animation: modalIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-icon-wrap {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(220, 38, 38, 0.1);
|
||||||
|
border: 1px solid rgba(220, 38, 38, 0.2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #dc2626;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-body {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-error {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-actions form {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover:not(:disabled) {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
border-color: var(--border-default);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-confirm {
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #dc2626;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: #fff;
|
||||||
|
transition: filter 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-confirm:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-confirm:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shared ── */
|
||||||
|
.detail-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-end-cell {
|
||||||
|
width: 1%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-end-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-chevron {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-chevron.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition:
|
||||||
|
background 0.12s,
|
||||||
|
border-color 0.12s,
|
||||||
|
color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn:hover {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
border-color: var(--border-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes menuIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px) scale(0.97);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.97);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
/* ── Roles Admin Page ── */
|
||||||
|
|
||||||
|
/* ── Table header layout ── */
|
||||||
|
:global(.admin-table-header) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-role-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
background: var(--accent-color, #0066cc);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-role-btn:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Role-specific styles ── */
|
||||||
|
.role-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-row.expanded {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-title-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-moniker {
|
||||||
|
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-inset);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-perm-count {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-user-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-user-count svg {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded detail row */
|
||||||
|
.role-detail-row td {
|
||||||
|
padding: 0 !important;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-detail-content {
|
||||||
|
padding: 16px 24px 20px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
animation: roleDetailFadeIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes roleDetailFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-detail-section {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-detail-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-detail-empty {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Permission tags */
|
||||||
|
.permission-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--status-neutral-bg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User list in role detail */
|
||||||
|
.user-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-color, #0066cc);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-login {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Three-dot menu ── */
|
||||||
|
.role-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 200;
|
||||||
|
min-width: 130px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||||
|
padding: 4px;
|
||||||
|
animation: menuIn 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-menu-item:hover {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-menu-item svg {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-menu-item-danger {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-menu-item-danger svg {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-menu-item-danger:hover {
|
||||||
|
background: rgba(220, 38, 38, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-menu-sep {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-subtle);
|
||||||
|
margin: 3px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── System badge ── */
|
||||||
|
.system-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Delete confirmation overlay ── */
|
||||||
|
.confirm-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog {
|
||||||
|
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: 380px;
|
||||||
|
padding: 28px 24px 22px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
animation: modalIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-icon-wrap {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(220, 38, 38, 0.1);
|
||||||
|
border: 1px solid rgba(220, 38, 38, 0.2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #dc2626;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-body {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-error {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-actions form {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover:not(:disabled) {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
border-color: var(--border-default);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-confirm {
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #dc2626;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: #fff;
|
||||||
|
transition: filter 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-confirm:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-confirm:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shared ── */
|
||||||
|
.detail-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-end-cell {
|
||||||
|
width: 1%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-end-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-chevron {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-chevron.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition:
|
||||||
|
background 0.12s,
|
||||||
|
border-color 0.12s,
|
||||||
|
color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn:hover {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
border-color: var(--border-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes menuIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px) scale(0.97);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.97);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
/* ── Users Admin Page ── */
|
||||||
|
|
||||||
|
/* ── Search bar ── */
|
||||||
|
.user-search-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-search-input {
|
||||||
|
padding: 7px 12px 7px 30px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
width: 220px;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-search-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--input-focus-border);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── User row ── */
|
||||||
|
.user-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-row.expanded {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-table-avatar {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-table-avatar-initials {
|
||||||
|
background: var(--accent-color, #0066cc);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-table-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-email {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-login-mono {
|
||||||
|
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-inset);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role-count {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Expanded detail row ── */
|
||||||
|
.user-detail-row td {
|
||||||
|
padding: 0 !important;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-detail-content {
|
||||||
|
padding: 16px 24px 20px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
animation: userDetailFadeIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes userDetailFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-detail-section {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-detail-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-detail-empty {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail fields */
|
||||||
|
.user-detail-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-detail-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 60px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-mono {
|
||||||
|
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Role cards in expanded row ── */
|
||||||
|
.user-role-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role-card {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role-card-header svg {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role-moniker {
|
||||||
|
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-inset);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Permission tags */
|
||||||
|
.permission-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--status-neutral-bg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-tag-more {
|
||||||
|
background: var(--bg-inset);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Three-dot menu ── */
|
||||||
|
.user-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 200;
|
||||||
|
min-width: 130px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||||
|
padding: 4px;
|
||||||
|
animation: menuIn 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-item:hover {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-item svg {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-item-danger {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-item-danger svg {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-item-danger:hover {
|
||||||
|
background: rgba(220, 38, 38, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-sep {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-subtle);
|
||||||
|
margin: 3px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Edit dialog ── */
|
||||||
|
.edit-dialog {
|
||||||
|
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: 520px;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 28px 24px 22px;
|
||||||
|
animation: modalIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-dialog-title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-dialog-sub {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-field label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-field input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-field input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--input-focus-border);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--accent-color, #0066cc);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: #fff;
|
||||||
|
transition: filter 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Edit modal sections (roles & permissions) ── */
|
||||||
|
.edit-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-section-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-section-label svg {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-section-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Role chips ── */
|
||||||
|
.edit-role-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
background 0.12s,
|
||||||
|
border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-chip:hover {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-chip.selected {
|
||||||
|
background: rgba(0, 102, 204, 0.08);
|
||||||
|
border-color: var(--accent-color, #0066cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1.5px solid var(--border-subtle);
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--accent-color, #0066cc);
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-chip.selected .edit-role-check {
|
||||||
|
border-color: var(--accent-color, #0066cc);
|
||||||
|
background: var(--accent-color, #0066cc);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-chip-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-chip-moniker {
|
||||||
|
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Permission list ── */
|
||||||
|
.edit-perm-search-wrap {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-perm-search {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-perm-search::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-perm-search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--input-focus-border);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-perm-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-perm-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-perm-item:hover {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-perm-item input[type="checkbox"] {
|
||||||
|
accent-color: var(--accent-color, #0066cc);
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-perm-node {
|
||||||
|
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-perm-empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbars for edit lists */
|
||||||
|
.edit-role-list::-webkit-scrollbar,
|
||||||
|
.edit-perm-list::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-list::-webkit-scrollbar-track,
|
||||||
|
.edit-perm-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-list::-webkit-scrollbar-thumb,
|
||||||
|
.edit-perm-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar-thumb);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
+6
-2
@@ -26,10 +26,11 @@
|
|||||||
--text-primary: #2c3e50;
|
--text-primary: #2c3e50;
|
||||||
--text-secondary: #666666;
|
--text-secondary: #666666;
|
||||||
--text-muted: #8492a6;
|
--text-muted: #8492a6;
|
||||||
--text-faint: #9ca3af;
|
--text-faint: #6b7280;
|
||||||
--text-inverse: #ffffff;
|
--text-inverse: #ffffff;
|
||||||
|
|
||||||
/* Nav */
|
/* Accent */
|
||||||
|
--accent: #3498db;
|
||||||
--nav-hover-bg: rgba(0, 0, 0, 0.04);
|
--nav-hover-bg: rgba(0, 0, 0, 0.04);
|
||||||
--nav-active-bg: rgba(52, 152, 219, 0.08);
|
--nav-active-bg: rgba(52, 152, 219, 0.08);
|
||||||
--nav-active-color: #3498db;
|
--nav-active-color: #3498db;
|
||||||
@@ -137,6 +138,9 @@
|
|||||||
--text-faint: #525252;
|
--text-faint: #525252;
|
||||||
--text-inverse: #0e0e0e;
|
--text-inverse: #0e0e0e;
|
||||||
|
|
||||||
|
/* Accent */
|
||||||
|
--accent: #333333;
|
||||||
|
|
||||||
/* Nav */
|
/* Nav */
|
||||||
--nav-hover-bg: rgba(255, 255, 255, 0.04);
|
--nav-hover-bg: rgba(255, 255, 255, 0.04);
|
||||||
--nav-active-bg: rgba(255, 255, 255, 0.06);
|
--nav-active-bg: rgba(255, 255, 255, 0.06);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user