Setup unifi wlans
This commit is contained in:
+12
-9
@@ -1,12 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
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;
|
||||
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
|
||||
const sel = new Set(roleToEdit.permissions);
|
||||
const expanded = new Set<string>();
|
||||
@@ -39,10 +47,12 @@
|
||||
let moniker = "";
|
||||
let monikerEdited = false;
|
||||
let selectedPermissions = new Set<string>();
|
||||
let permissionSearch = "";
|
||||
let expandedCategories = new Set<string>();
|
||||
let isSubmitting = false;
|
||||
let submitError = "";
|
||||
let customPermNode = "";
|
||||
let customPermError = "";
|
||||
let customNodes: string[] = [];
|
||||
|
||||
$: if (!monikerEdited) {
|
||||
moniker = title
|
||||
@@ -51,27 +61,10 @@
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
}
|
||||
|
||||
$: filteredEntries = (() => {
|
||||
const entries = Object.entries(permissionNodes) as [
|
||||
string,
|
||||
PermissionCategory,
|
||||
][];
|
||||
if (!permissionSearch.trim()) return entries;
|
||||
const q = permissionSearch.toLowerCase();
|
||||
return entries
|
||||
.map(([key, cat]): [string, PermissionCategory] => [
|
||||
key,
|
||||
{
|
||||
...cat,
|
||||
permissions: cat.permissions.filter(
|
||||
(p: PermissionNode) =>
|
||||
p.node.toLowerCase().includes(q) ||
|
||||
p.description.toLowerCase().includes(q),
|
||||
),
|
||||
},
|
||||
])
|
||||
.filter(([, cat]) => cat.permissions.length > 0);
|
||||
})();
|
||||
$: permissionEntries = Object.entries(permissionNodes ?? {}) as [
|
||||
string,
|
||||
PermissionCategory,
|
||||
][];
|
||||
|
||||
$: isValid = title.trim().length > 0 && moniker.trim().length > 0;
|
||||
$: selectedCount = selectedPermissions.size;
|
||||
@@ -97,15 +90,52 @@
|
||||
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() {
|
||||
title = "";
|
||||
moniker = "";
|
||||
monikerEdited = false;
|
||||
selectedPermissions = new Set();
|
||||
permissionSearch = "";
|
||||
expandedCategories = new Set();
|
||||
isSubmitting = false;
|
||||
submitError = "";
|
||||
customPermNode = "";
|
||||
customPermError = "";
|
||||
customNodes = [];
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
@@ -113,9 +143,8 @@
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains("modal-backdrop"))
|
||||
handleClose();
|
||||
function handleBackdropClick() {
|
||||
handleClose();
|
||||
}
|
||||
|
||||
function handleBackdropKeydown(e: KeyboardEvent) {
|
||||
@@ -130,12 +159,15 @@
|
||||
on:click={handleBackdropClick}
|
||||
on:keydown={handleBackdropKeydown}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={isEditMode ? "Edit Role" : "Create Role"}
|
||||
tabindex="-1"
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<div class="modal-header">
|
||||
<div class="modal-title-group">
|
||||
@@ -174,7 +206,11 @@
|
||||
<form
|
||||
method="POST"
|
||||
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;
|
||||
submitError = "";
|
||||
return async ({ result, update }) => {
|
||||
@@ -197,9 +233,6 @@
|
||||
};
|
||||
}}
|
||||
>
|
||||
{#each [...selectedPermissions] as node (node)}
|
||||
<input type="hidden" name="permissions" value={node} />
|
||||
{/each}
|
||||
{#if isEditMode && roleToEdit}
|
||||
<input type="hidden" name="id" value={roleToEdit.id} />
|
||||
{/if}
|
||||
@@ -261,64 +294,19 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="search-wrap">
|
||||
<svg
|
||||
class="search-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="13"
|
||||
height="13"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search permissions…"
|
||||
bind:value={permissionSearch}
|
||||
/>
|
||||
{#if permissionSearch}
|
||||
<button
|
||||
type="button"
|
||||
class="search-clear"
|
||||
on:click={() => (permissionSearch = "")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
width="11"
|
||||
height="11"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="perm-list">
|
||||
{#if Object.keys(permissionNodes).length === 0}
|
||||
<p class="perm-empty">No permission data available.</p>
|
||||
{:else if filteredEntries.length === 0}
|
||||
<p class="perm-empty">No permissions match your search.</p>
|
||||
{:else}
|
||||
{#each filteredEntries as [catKey, category] (catKey)}
|
||||
{@const catPerms = category.permissions}
|
||||
{#each permissionEntries as [catKey, category] (catKey)}
|
||||
{@const catPerms = category.permissions ?? []}
|
||||
{@const allSel =
|
||||
catPerms.length > 0 &&
|
||||
catPerms.every((p) => selectedPermissions.has(p.node))}
|
||||
{@const someSel =
|
||||
!allSel &&
|
||||
catPerms.some((p) => selectedPermissions.has(p.node))}
|
||||
{@const isExpanded =
|
||||
permissionSearch.trim().length > 0 ||
|
||||
expandedCategories.has(catKey)}
|
||||
{@const isExpanded = expandedCategories.has(catKey)}
|
||||
<div class="cat-group">
|
||||
<div class="cat-header">
|
||||
<button
|
||||
@@ -419,7 +407,96 @@
|
||||
</div>
|
||||
{/each}
|
||||
{/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 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>
|
||||
|
||||
@@ -659,58 +736,81 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Search ── */
|
||||
.search-wrap {
|
||||
position: relative;
|
||||
/* ── Custom permission input ── */
|
||||
.custom-perm-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 7px 10px 7px 29px;
|
||||
background: var(--bg-inset);
|
||||
.custom-perm-input {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
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);
|
||||
outline: none;
|
||||
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);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
.custom-perm-input:focus {
|
||||
border-color: var(--accent-color, #0066cc);
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.12);
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
background: none;
|
||||
.custom-perm-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 7px 14px;
|
||||
background: var(--accent-color, #0066cc);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 3px;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.1s;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.search-clear:hover {
|
||||
color: var(--text-primary);
|
||||
.custom-cat-label svg {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── 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,
|
||||
permission: (await import("./optima-api/modules/permissions")).permission,
|
||||
user,
|
||||
users: (await import("./optima-api/modules/users")).users,
|
||||
unifi: (await import("./optima-api/modules/unifi")).unifi,
|
||||
};
|
||||
/**
|
||||
* @TODO
|
||||
|
||||
@@ -4,10 +4,16 @@ export const company = {
|
||||
async fetch(
|
||||
accessToken: string,
|
||||
id: string,
|
||||
options?: { includeAddress?: boolean },
|
||||
options?: {
|
||||
includeAddress?: boolean;
|
||||
includePrimaryContact?: boolean;
|
||||
includeAllContacts?: boolean;
|
||||
},
|
||||
) {
|
||||
const params: Record<string, string> = {};
|
||||
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}`, {
|
||||
params,
|
||||
|
||||
@@ -5,7 +5,8 @@ export interface CredentialTypeField {
|
||||
name: string;
|
||||
required: boolean;
|
||||
secure: boolean;
|
||||
valueType: "plain_text" | "password" | "number" | "email" | "url";
|
||||
valueType: string;
|
||||
subFields?: CredentialTypeField[];
|
||||
}
|
||||
|
||||
export interface CredentialType {
|
||||
|
||||
@@ -2,15 +2,20 @@ import api from "../axios";
|
||||
|
||||
export interface CredentialField {
|
||||
id: string;
|
||||
fieldId: string;
|
||||
name: string;
|
||||
secure: boolean;
|
||||
required: boolean;
|
||||
valueType: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Credential {
|
||||
id: string;
|
||||
name: string;
|
||||
notes?: string;
|
||||
typeId: string;
|
||||
companyId: string;
|
||||
subCredentialOfId?: string;
|
||||
fields: CredentialField[];
|
||||
type?: {
|
||||
id: string;
|
||||
@@ -45,6 +50,7 @@ export const credential = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -62,7 +68,11 @@ export const credential = {
|
||||
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, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
@@ -96,4 +106,79 @@ export const credential = {
|
||||
});
|
||||
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 {}
|
||||
reject(new Error("Timed out waiting for auth callback"));
|
||||
},
|
||||
2 * 60 * 1000,
|
||||
); // 2 minutes
|
||||
5 * 60 * 1000,
|
||||
); // 5 minutes
|
||||
|
||||
const handlePayload = (payload: any) => {
|
||||
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">
|
||||
import { enhance } from "$app/forms";
|
||||
import type { SubmitFunction } from "@sveltejs/kit";
|
||||
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;
|
||||
$: 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>
|
||||
|
||||
<svelte:window on:click={() => (openMenuId = null)} />
|
||||
|
||||
{#if !hasAccess}
|
||||
<div class="admin-denied">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -18,16 +148,632 @@
|
||||
administrator to request access.
|
||||
</p>
|
||||
</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">
|
||||
<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" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<h3>Credential Type Management</h3>
|
||||
<h3>No Credential Types Found</h3>
|
||||
<p>
|
||||
Credential type definitions and configuration will be wired up here.
|
||||
Connect an API module to populate this view.
|
||||
There are no credential types configured yet. Create your first credential
|
||||
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>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { Role } from "$lib/optima-api/modules/roles";
|
||||
import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions";
|
||||
import CreateRoleModal from "../../../components/CreateRoleModal.svelte";
|
||||
import "../../../styles/admin/roles.css";
|
||||
|
||||
interface RoleUser {
|
||||
id: string;
|
||||
@@ -555,473 +556,3 @@
|
||||
</table>
|
||||
</div>
|
||||
{/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">
|
||||
import { enhance } from "$app/forms";
|
||||
import type { SubmitFunction } from "@sveltejs/kit";
|
||||
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;
|
||||
$: 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>
|
||||
|
||||
<svelte:window on:click={() => (openMenuId = null)} />
|
||||
|
||||
{#if !hasAccess}
|
||||
<div class="admin-denied">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -18,7 +155,7 @@
|
||||
request access.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{:else if users.length === 0}
|
||||
<div class="admin-tab-empty">
|
||||
<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" />
|
||||
@@ -26,10 +163,441 @@
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
<h3>User Management</h3>
|
||||
<p>
|
||||
User listing and editing will be wired up here. Connect an API module to
|
||||
populate this view.
|
||||
</p>
|
||||
<h3>No Users Found</h3>
|
||||
<p>There are no users in the system yet.</p>
|
||||
</div>
|
||||
{: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>
|
||||
{#if deleteError}
|
||||
<p class="confirm-error">{deleteError}</p>
|
||||
{/if}
|
||||
<div class="confirm-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-cancel"
|
||||
on:click={cancelDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteUser"
|
||||
use:enhance={handleDeleteEnhance}
|
||||
>
|
||||
<input type="hidden" name="id" value={userToDelete.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-delete-confirm"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting…" : "Delete User"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="admin-table-header">
|
||||
<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 { handleApiError } from "$lib/optima-api/errorHandler";
|
||||
import { checkPermissions } from "$lib/permissions";
|
||||
import type { PageServerLoad } from "./$types";
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
@@ -11,6 +12,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
currentPage: 1,
|
||||
totalRecords: 0,
|
||||
search: "",
|
||||
permissions: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +20,21 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
const search = url.searchParams.get("search") || "";
|
||||
|
||||
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 {
|
||||
companies: result?.data ?? [],
|
||||
@@ -27,6 +43,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
totalRecords:
|
||||
result?.meta?.pagination?.totalRecords ?? result?.data?.length ?? 0,
|
||||
search,
|
||||
permissions,
|
||||
};
|
||||
} catch (err) {
|
||||
handleApiError(err);
|
||||
|
||||
+267
-208
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { afterNavigate } from "$app/navigation";
|
||||
import type { PermissionMap } from "$lib/permissions";
|
||||
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
|
||||
import "../../styles/companies/companylist.css";
|
||||
|
||||
export let data: {
|
||||
permissions: PermissionMap;
|
||||
companies: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -21,6 +23,8 @@
|
||||
search: string;
|
||||
};
|
||||
|
||||
$: hasAccess = data.permissions["companies.view"] === true;
|
||||
|
||||
let searchInput = data.search;
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
let isSearching = false;
|
||||
@@ -134,229 +138,284 @@
|
||||
<title>Companies — Project Optima</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="companies-page">
|
||||
<div class="companies-pane">
|
||||
<!-- Pane header -->
|
||||
<div class="pane-header">
|
||||
<div class="pane-header-left">
|
||||
<h2 class="page-title">Companies</h2>
|
||||
{#if totalRecords > 0}
|
||||
<span class="result-count"
|
||||
>{totalRecords} record{totalRecords === 1 ? "" : "s"}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
<svg
|
||||
class="search-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search companies…"
|
||||
bind:this={searchInputEl}
|
||||
bind:value={searchInput}
|
||||
on:input={handleSearch}
|
||||
on:keydown={handleKeydown}
|
||||
/>
|
||||
{#if searchInput}
|
||||
<button
|
||||
class="search-clear"
|
||||
on:click={() => {
|
||||
searchInput = "";
|
||||
handleSearch();
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="14"
|
||||
height="14"
|
||||
{#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-pane">
|
||||
<!-- Pane header -->
|
||||
<div class="pane-header">
|
||||
<div class="pane-header-left">
|
||||
<h2 class="page-title">Companies</h2>
|
||||
{#if totalRecords > 0}
|
||||
<span class="result-count"
|
||||
>{totalRecords} record{totalRecords === 1 ? "" : "s"}</span
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pane body -->
|
||||
<div class="pane-body">
|
||||
{#if isSearching}
|
||||
<div class="search-loading-overlay">
|
||||
<div class="search-spinner"></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if companies.length === 0}
|
||||
<div class="empty-state">
|
||||
<NoResultsMonkey
|
||||
message={searchInput
|
||||
? "No companies match your search"
|
||||
: "No companies found"}
|
||||
<div class="search-bar">
|
||||
<svg
|
||||
class="search-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search companies…"
|
||||
bind:this={searchInputEl}
|
||||
bind:value={searchInput}
|
||||
on:input={handleSearch}
|
||||
on:keydown={handleKeydown}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-grid">
|
||||
{#each companies as company (company.id)}
|
||||
{#if searchInput}
|
||||
<button
|
||||
class="company-card"
|
||||
on:click={() => goto(`/companies/${company.id}`)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === "Enter") goto(`/companies/${company.id}`);
|
||||
class="search-clear"
|
||||
on:click={() => {
|
||||
searchInput = "";
|
||||
handleSearch();
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<!-- Card header: avatar + status -->
|
||||
<div class="card-top">
|
||||
<div class="card-avatar">
|
||||
<span class="avatar-initials"
|
||||
>{companyInitials(company.name)}</span
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
class="status-dot {statusClass(company.status)}"
|
||||
title={company.status || "Unknown"}
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<!-- Card body -->
|
||||
<div class="card-body">
|
||||
<h3 class="card-name">{company.name}</h3>
|
||||
{#if company.contactEmail}
|
||||
<span class="card-email">{company.contactEmail}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Card meta -->
|
||||
<div class="card-meta">
|
||||
{#if company.type}
|
||||
<div class="meta-item">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="meta-icon"
|
||||
>
|
||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
|
||||
<path d="M16 3h-8l-2 4h12z" />
|
||||
</svg>
|
||||
<span>{company.type}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if company.identifier || company.id}
|
||||
<div class="meta-item">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="meta-icon"
|
||||
>
|
||||
<path d="M4 9h16M4 15h16M10 3L8 21M16 3l-2 18" />
|
||||
</svg>
|
||||
<span class="mono"
|
||||
>{company.identifier || company.id?.slice(0, 8)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Card footer -->
|
||||
<div class="card-footer">
|
||||
{#if company.status}
|
||||
<span class="status-label {statusClass(company.status)}"
|
||||
>{company.status}</span
|
||||
>
|
||||
{/if}
|
||||
{#if formatDate(company.createdAt)}
|
||||
<span class="card-date">{formatDate(company.createdAt)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Hover arrow -->
|
||||
<svg
|
||||
class="card-arrow"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pane body -->
|
||||
<div class="pane-body">
|
||||
{#if isSearching}
|
||||
<div class="search-loading-overlay">
|
||||
<div class="search-spinner"></div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if companies.length === 0}
|
||||
<div class="empty-state">
|
||||
<NoResultsMonkey
|
||||
message={searchInput
|
||||
? "No companies match your search"
|
||||
: "No companies found"}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-grid">
|
||||
{#each companies as company (company.id)}
|
||||
<button
|
||||
class="company-card"
|
||||
on:click={() => goto(`/companies/${company.id}`)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === "Enter") goto(`/companies/${company.id}`);
|
||||
}}
|
||||
>
|
||||
<!-- Card header: avatar + status -->
|
||||
<div class="card-top">
|
||||
<div class="card-avatar">
|
||||
<span class="avatar-initials"
|
||||
>{companyInitials(company.name)}</span
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
class="status-dot {statusClass(company.status)}"
|
||||
title={company.status || "Unknown"}
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<!-- Card body -->
|
||||
<div class="card-body">
|
||||
<h3 class="card-name">{company.name}</h3>
|
||||
{#if company.contactEmail}
|
||||
<span class="card-email">{company.contactEmail}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Card meta -->
|
||||
<div class="card-meta">
|
||||
{#if company.type}
|
||||
<div class="meta-item">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="meta-icon"
|
||||
>
|
||||
<rect
|
||||
x="2"
|
||||
y="7"
|
||||
width="20"
|
||||
height="14"
|
||||
rx="2"
|
||||
ry="2"
|
||||
/>
|
||||
<path d="M16 3h-8l-2 4h12z" />
|
||||
</svg>
|
||||
<span>{company.type}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if company.identifier || company.id}
|
||||
<div class="meta-item">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="meta-icon"
|
||||
>
|
||||
<path d="M4 9h16M4 15h16M10 3L8 21M16 3l-2 18" />
|
||||
</svg>
|
||||
<span class="mono"
|
||||
>{company.identifier || company.id?.slice(0, 8)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Card footer -->
|
||||
<div class="card-footer">
|
||||
{#if company.status}
|
||||
<span class="status-label {statusClass(company.status)}"
|
||||
>{company.status}</span
|
||||
>
|
||||
{/if}
|
||||
{#if formatDate(company.createdAt)}
|
||||
<span class="card-date"
|
||||
>{formatDate(company.createdAt)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Hover arrow -->
|
||||
<svg
|
||||
class="card-arrow"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Pane footer / Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<div class="pane-footer">
|
||||
<span class="page-info">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<nav class="pagination" aria-label="Pagination">
|
||||
<button
|
||||
class="page-btn"
|
||||
disabled={currentPage <= 1}
|
||||
on:click={() => navigateToPage(currentPage - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#each pageNumbers as p}
|
||||
{#if p === "..."}
|
||||
<span class="page-ellipsis">…</span>
|
||||
{:else}
|
||||
<button
|
||||
class="page-btn"
|
||||
class:active={p === currentPage}
|
||||
on:click={() => navigateToPage(p)}
|
||||
aria-current={p === currentPage ? "page" : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<button
|
||||
class="page-btn"
|
||||
disabled={currentPage >= totalPages}
|
||||
on:click={() => navigateToPage(currentPage + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Pane footer / Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<div class="pane-footer">
|
||||
<span class="page-info">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<nav class="pagination" aria-label="Pagination">
|
||||
<button
|
||||
class="page-btn"
|
||||
disabled={currentPage <= 1}
|
||||
on:click={() => navigateToPage(currentPage - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#each pageNumbers as p}
|
||||
{#if p === "..."}
|
||||
<span class="page-ellipsis">…</span>
|
||||
{:else}
|
||||
<button
|
||||
class="page-btn"
|
||||
class:active={p === currentPage}
|
||||
on:click={() => navigateToPage(p)}
|
||||
aria-current={p === currentPage ? "page" : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<button
|
||||
class="page-btn"
|
||||
disabled={currentPage >= totalPages}
|
||||
on:click={() => navigateToPage(currentPage + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</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 {
|
||||
company: null,
|
||||
configurations: [],
|
||||
credentials: [],
|
||||
credentialTypes: [],
|
||||
unifiSites: [],
|
||||
accessToken: null,
|
||||
permissions: {} as PermissionMap,
|
||||
};
|
||||
}
|
||||
@@ -16,19 +20,45 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
try {
|
||||
// Run permission checks in parallel with other data fetches.
|
||||
// Add any new permissions the company page needs to this array.
|
||||
const [permissions, configsResult] = await Promise.all([
|
||||
checkPermissions(accessToken, ["company.fetch.address"]),
|
||||
const [
|
||||
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.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
|
||||
const companyResult = await optima.company.fetch(accessToken, params.id, {
|
||||
includeAddress: permissions["company.fetch.address"] === true,
|
||||
includePrimaryContact: true,
|
||||
includeAllContacts: permissions["company.fetch.contacts"] === true,
|
||||
});
|
||||
|
||||
return {
|
||||
company: companyResult?.data ?? null,
|
||||
configurations: configsResult?.data ?? [],
|
||||
credentials: credentialsResult?.data ?? [],
|
||||
credentialTypes: credentialTypesResult?.data ?? [],
|
||||
unifiSites: unifiSitesResult?.data ?? [],
|
||||
accessToken,
|
||||
permissions,
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,168 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
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: {
|
||||
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;
|
||||
};
|
||||
export let data: PageData;
|
||||
|
||||
$: company = data.company;
|
||||
$: configurations = data.configurations;
|
||||
$: credentials = data.credentials;
|
||||
$: credentialTypes = data.credentialTypes;
|
||||
$: unifiSites = data.unifiSites;
|
||||
$: accessToken = data.accessToken;
|
||||
$: permissions = data.permissions;
|
||||
|
||||
const tabs = ["Credentials", "Configurations", "Users", "Activity"] as const;
|
||||
// Mobile detection
|
||||
let isMobile = false;
|
||||
function checkMobile() {
|
||||
isMobile = typeof window !== "undefined" && window.innerWidth <= 768;
|
||||
}
|
||||
onMount(() => {
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
});
|
||||
|
||||
// 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 = "Credentials";
|
||||
let activeTab: Tab = "Overview";
|
||||
|
||||
// Configurations split-view state
|
||||
let selectedConfig: (typeof configurations)[number] | null = null;
|
||||
let configFadeKey = 0;
|
||||
// Mobile nav state: null = show vertical nav menu; set = show tab content
|
||||
let mobileActiveTab: Tab | null = null;
|
||||
|
||||
// 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
|
||||
function selectMobileTab(tab: Tab) {
|
||||
activeTab = tab;
|
||||
mobileActiveTab = tab;
|
||||
}
|
||||
|
||||
function selectConfig(config: (typeof configurations)[number]) {
|
||||
if (selectedConfig?.id === config.id) {
|
||||
// Clicking the active config collapses back to full list
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
|
||||
function formatAddress(c: NonNullable<typeof company>): string[] {
|
||||
// Prefer the nested cw_Data.address structure returned when includeAddress=true
|
||||
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;
|
||||
function mobileBack() {
|
||||
mobileActiveTab = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -172,192 +69,195 @@
|
||||
|
||||
<div class="company-detail-page">
|
||||
<!-- Left pane (1/4) — Company overview -->
|
||||
<div class="company-detail-left">
|
||||
<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"
|
||||
<CompanySidebar {company} {permissions} {isMobile} {mobileActiveTab} />
|
||||
|
||||
<!-- 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
|
||||
class="mobile-nav-item"
|
||||
on:click={() => selectMobileTab(tab)}
|
||||
type="button"
|
||||
>
|
||||
<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.type}
|
||||
<div class="info-row">
|
||||
<span class="mobile-nav-icon">
|
||||
{#if tab === "Credentials"}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="info-icon"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<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.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"
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path
|
||||
d="M7 11V7a5 5 0 0 1 10 0v4"
|
||||
/>
|
||||
</svg>
|
||||
<div class="info-content">
|
||||
<span class="info-label">Phone</span>
|
||||
<span class="info-value">{company.contactPhone}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if permissions["company.fetch.address"] && formatAddress(company).length > 0}
|
||||
<div class="info-row">
|
||||
{:else if tab === "Configurations"}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="info-icon"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
<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>
|
||||
<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">
|
||||
{:else if tab === "UniFi"}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="info-icon"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<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" />
|
||||
<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 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">
|
||||
{:else if tab === "Contacts"}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="info-icon"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
<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>
|
||||
<div class="info-content">
|
||||
<span class="info-label">Updated</span>
|
||||
<span class="info-value">{formatDate(company.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{: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}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="profile-empty">
|
||||
<p>Company not found.</p>
|
||||
</div>
|
||||
{/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"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Right pane (3/4) -->
|
||||
<div class="company-detail-right">
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
<h3 class="mobile-content-title">{mobileActiveTab}</h3>
|
||||
{#if mobileActiveTab === "Credentials"}
|
||||
<button
|
||||
type="button"
|
||||
class="create-credential-btn mobile-create-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>
|
||||
New
|
||||
</button>
|
||||
{/if}
|
||||
{#if mobileActiveTab === "UniFi"}
|
||||
<button
|
||||
type="button"
|
||||
class="create-credential-btn mobile-create-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
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="tab-bar" role="tablist">
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
@@ -368,344 +268,91 @@
|
||||
on:click={() => (activeTab = tab)}
|
||||
>
|
||||
{tab}
|
||||
{#if tab === "Credentials" && credentials.length > 0}
|
||||
<span class="tab-count-badge">{credentials.length}</span>
|
||||
{/if}
|
||||
{#if tab === "Configurations" && configurations.length > 0}
|
||||
<span class="tab-count-badge">{configurations.length}</span>
|
||||
{/if}
|
||||
{#if tab === "UniFi" && unifiSites.length > 0}
|
||||
<span class="tab-count-badge">{unifiSites.length}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/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 class="detail-pane-body">
|
||||
{#if activeTab === "Credentials"}
|
||||
<p class="tab-placeholder">Credentials content</p>
|
||||
{#if activeTab === "Overview"}
|
||||
<OverviewTab {company} {credentials} {configurations} {unifiSites} />
|
||||
{:else if activeTab === "Credentials"}
|
||||
<CredentialsTab
|
||||
companyId={company?.id ?? ""}
|
||||
{credentials}
|
||||
{credentialTypes}
|
||||
{accessToken}
|
||||
{permissions}
|
||||
{isMobile}
|
||||
bind:isCreateCredentialOpen
|
||||
/>
|
||||
{:else if activeTab === "Configurations"}
|
||||
{#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}>
|
||||
<!-- 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>
|
||||
<ConfigurationsTab {configurations} {isMobile} />
|
||||
{:else if activeTab === "UniFi"}
|
||||
<UniFiTab
|
||||
companyId={company?.id ?? ""}
|
||||
{unifiSites}
|
||||
{accessToken}
|
||||
{permissions}
|
||||
bind:isLinkUnifiOpen
|
||||
/>
|
||||
{:else if activeTab === "Contacts"}
|
||||
<ContactsTab {company} />
|
||||
{:else if activeTab === "Activity"}
|
||||
<p class="tab-placeholder">Activity content</p>
|
||||
<ActivityTab />
|
||||
{/if}
|
||||
</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-secondary: #666666;
|
||||
--text-muted: #8492a6;
|
||||
--text-faint: #9ca3af;
|
||||
--text-faint: #6b7280;
|
||||
--text-inverse: #ffffff;
|
||||
|
||||
/* Nav */
|
||||
/* Accent */
|
||||
--accent: #3498db;
|
||||
--nav-hover-bg: rgba(0, 0, 0, 0.04);
|
||||
--nav-active-bg: rgba(52, 152, 219, 0.08);
|
||||
--nav-active-color: #3498db;
|
||||
@@ -137,6 +138,9 @@
|
||||
--text-faint: #525252;
|
||||
--text-inverse: #0e0e0e;
|
||||
|
||||
/* Accent */
|
||||
--accent: #333333;
|
||||
|
||||
/* Nav */
|
||||
--nav-hover-bg: rgba(255, 255, 255, 0.04);
|
||||
--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