Setup unifi wlans

This commit is contained in:
2026-02-22 19:12:13 -06:00
parent a99c9f5102
commit 6791a6735b
38 changed files with 24435 additions and 1663 deletions
+4 -1
View File
@@ -3,7 +3,10 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+204 -104
View File
@@ -21,6 +21,14 @@
monikerEdited = true; monikerEdited = true;
selectedPermissions = new Set(roleToEdit.permissions); selectedPermissions = new Set(roleToEdit.permissions);
// Identify permissions not in the categorized list → show as custom
const catalogued = new Set(
Object.values(permissionNodes).flatMap((cat) =>
(cat as PermissionCategory).permissions.map((p) => p.node),
),
);
customNodes = roleToEdit.permissions.filter((p) => !catalogued.has(p));
// Auto-expand categories that contain selected permissions // Auto-expand categories that contain selected permissions
const sel = new Set(roleToEdit.permissions); const sel = new Set(roleToEdit.permissions);
const expanded = new Set<string>(); const expanded = new Set<string>();
@@ -39,10 +47,12 @@
let moniker = ""; let moniker = "";
let monikerEdited = false; let monikerEdited = false;
let selectedPermissions = new Set<string>(); let selectedPermissions = new Set<string>();
let permissionSearch = "";
let expandedCategories = new Set<string>(); let expandedCategories = new Set<string>();
let isSubmitting = false; let isSubmitting = false;
let submitError = ""; let submitError = "";
let customPermNode = "";
let customPermError = "";
let customNodes: string[] = [];
$: if (!monikerEdited) { $: if (!monikerEdited) {
moniker = title moniker = title
@@ -51,27 +61,10 @@
.replace(/[^a-z0-9-]/g, ""); .replace(/[^a-z0-9-]/g, "");
} }
$: filteredEntries = (() => { $: permissionEntries = Object.entries(permissionNodes ?? {}) as [
const entries = Object.entries(permissionNodes) as [
string, string,
PermissionCategory, PermissionCategory,
][]; ][];
if (!permissionSearch.trim()) return entries;
const q = permissionSearch.toLowerCase();
return entries
.map(([key, cat]): [string, PermissionCategory] => [
key,
{
...cat,
permissions: cat.permissions.filter(
(p: PermissionNode) =>
p.node.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q),
),
},
])
.filter(([, cat]) => cat.permissions.length > 0);
})();
$: isValid = title.trim().length > 0 && moniker.trim().length > 0; $: isValid = title.trim().length > 0 && moniker.trim().length > 0;
$: selectedCount = selectedPermissions.size; $: selectedCount = selectedPermissions.size;
@@ -97,15 +90,52 @@
selectedPermissions = next; selectedPermissions = next;
} }
function addCustomPermission() {
const node = customPermNode.trim();
customPermError = "";
if (!node) return;
// Validate permission node format per PERMISSIONS.md:
// - dot-separated tokens of lowercase alphanumeric + underscores
// - special tokens: * (wildcard), ? (single-char wildcard)
// - inclusive list [a,b,c] or exclusive list <a,b,c>
// - standalone * for full access
if (
!/^(?:\*|(?:(?:[a-z0-9_]+|\*|\?|\[[a-z0-9_]+(?:,[a-z0-9_]+)*\]|<[a-z0-9_]+(?:,[a-z0-9_]+)*>)(?:\.(?:[a-z0-9_]+|\*|\?|\[[a-z0-9_]+(?:,[a-z0-9_]+)*\]|<[a-z0-9_]+(?:,[a-z0-9_]+)*>))*))$/.test(
node,
)
) {
customPermError =
"Use dot-separated tokens (e.g. resource.action). Supports: a-z, 0-9, underscores, * ? wildcards, [a,b] or <a,b> lists.";
return;
}
if (selectedPermissions.has(node)) {
customPermError = "This permission is already selected.";
return;
}
// Add to custom list if not from the categorized set
const catalogued = Object.values(permissionNodes).flatMap((cat) =>
(cat as PermissionCategory).permissions.map((p) => p.node),
);
if (!catalogued.includes(node)) {
customNodes = [...customNodes, node];
}
const next = new Set(selectedPermissions);
next.add(node);
selectedPermissions = next;
customPermNode = "";
}
function reset() { function reset() {
title = ""; title = "";
moniker = ""; moniker = "";
monikerEdited = false; monikerEdited = false;
selectedPermissions = new Set(); selectedPermissions = new Set();
permissionSearch = "";
expandedCategories = new Set(); expandedCategories = new Set();
isSubmitting = false; isSubmitting = false;
submitError = ""; submitError = "";
customPermNode = "";
customPermError = "";
customNodes = [];
} }
function handleClose() { function handleClose() {
@@ -113,8 +143,7 @@
onClose(); onClose();
} }
function handleBackdropClick(e: MouseEvent) { function handleBackdropClick() {
if ((e.target as HTMLElement).classList.contains("modal-backdrop"))
handleClose(); handleClose();
} }
@@ -130,12 +159,15 @@
on:click={handleBackdropClick} on:click={handleBackdropClick}
on:keydown={handleBackdropKeydown} on:keydown={handleBackdropKeydown}
> >
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
class="modal" class="modal"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={isEditMode ? "Edit Role" : "Create Role"} aria-label={isEditMode ? "Edit Role" : "Create Role"}
tabindex="-1" tabindex="-1"
on:click|stopPropagation
> >
<div class="modal-header"> <div class="modal-header">
<div class="modal-title-group"> <div class="modal-title-group">
@@ -174,7 +206,11 @@
<form <form
method="POST" method="POST"
action={isEditMode ? "?/updateRole" : "?/createRole"} action={isEditMode ? "?/updateRole" : "?/createRole"}
use:enhance={() => { use:enhance={({ formData }) => {
// Append permissions at submit time instead of via reactive hidden inputs
for (const node of selectedPermissions) {
formData.append("permissions", node);
}
isSubmitting = true; isSubmitting = true;
submitError = ""; submitError = "";
return async ({ result, update }) => { return async ({ result, update }) => {
@@ -197,9 +233,6 @@
}; };
}} }}
> >
{#each [...selectedPermissions] as node (node)}
<input type="hidden" name="permissions" value={node} />
{/each}
{#if isEditMode && roleToEdit} {#if isEditMode && roleToEdit}
<input type="hidden" name="id" value={roleToEdit.id} /> <input type="hidden" name="id" value={roleToEdit.id} />
{/if} {/if}
@@ -261,64 +294,19 @@
{/if} {/if}
</div> </div>
<div class="search-wrap">
<svg
class="search-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
class="search-input"
placeholder="Search permissions…"
bind:value={permissionSearch}
/>
{#if permissionSearch}
<button
type="button"
class="search-clear"
on:click={() => (permissionSearch = "")}
aria-label="Clear search"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
width="11"
height="11"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/if}
</div>
<div class="perm-list"> <div class="perm-list">
{#if Object.keys(permissionNodes).length === 0} {#if Object.keys(permissionNodes).length === 0}
<p class="perm-empty">No permission data available.</p> <p class="perm-empty">No permission data available.</p>
{:else if filteredEntries.length === 0}
<p class="perm-empty">No permissions match your search.</p>
{:else} {:else}
{#each filteredEntries as [catKey, category] (catKey)} {#each permissionEntries as [catKey, category] (catKey)}
{@const catPerms = category.permissions} {@const catPerms = category.permissions ?? []}
{@const allSel = {@const allSel =
catPerms.length > 0 && catPerms.length > 0 &&
catPerms.every((p) => selectedPermissions.has(p.node))} catPerms.every((p) => selectedPermissions.has(p.node))}
{@const someSel = {@const someSel =
!allSel && !allSel &&
catPerms.some((p) => selectedPermissions.has(p.node))} catPerms.some((p) => selectedPermissions.has(p.node))}
{@const isExpanded = {@const isExpanded = expandedCategories.has(catKey)}
permissionSearch.trim().length > 0 ||
expandedCategories.has(catKey)}
<div class="cat-group"> <div class="cat-group">
<div class="cat-header"> <div class="cat-header">
<button <button
@@ -419,8 +407,97 @@
</div> </div>
{/each} {/each}
{/if} {/if}
<!-- Custom permission nodes -->
{#if customNodes.length > 0}
<div class="cat-group">
<div class="cat-header">
<div class="cat-toggle custom-cat-label">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="11"
height="11"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
<span class="cat-name">Custom</span>
<span class="cat-count">{customNodes.length}</span>
</div> </div>
</div> </div>
<div class="cat-perms">
{#each customNodes as node (node)}
{@const sel = selectedPermissions.has(node)}
<label class="perm-row" class:perm-sel={sel}>
<input
type="checkbox"
class="sr-only"
checked={sel}
on:change={() => togglePermission(node)}
/>
<div class="cb" class:cb-checked={sel}>
{#if sel}
<svg
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
stroke-width="2"
width="8"
height="8"
>
<polyline points="1.5 5 4 8 8.5 2" />
</svg>
{/if}
</div>
<div class="perm-text">
<code class="perm-node">{node}</code>
<span class="perm-desc">Custom permission</span>
</div>
</label>
{/each}
</div>
</div>
{/if}
</div>
<!-- Custom permission input -->
<div class="custom-perm-wrap">
<input
type="text"
class="custom-perm-input"
placeholder="Add custom node… (e.g. my.custom.node)"
bind:value={customPermNode}
on:keydown={(e) =>
e.key === "Enter" &&
(e.preventDefault(), addCustomPermission())}
/>
<button
type="button"
class="custom-perm-btn"
on:click={addCustomPermission}
disabled={!customPermNode.trim()}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add
</button>
</div>
{#if customPermError}
<p class="custom-perm-error">{customPermError}</p>
{/if}
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -659,58 +736,81 @@
color: #fff; color: #fff;
} }
/* ── Search ── */ /* ── Custom permission input ── */
.search-wrap { .custom-perm-wrap {
position: relative;
display: flex; display: flex;
align-items: center; gap: 6px;
margin-top: 8px;
} }
.search-icon { .custom-perm-input {
position: absolute; flex: 1;
left: 9px; padding: 7px 10px;
color: var(--text-muted);
pointer-events: none;
}
.search-input {
width: 100%;
padding: 7px 10px 7px 29px;
background: var(--bg-inset);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
border-radius: 7px; border-radius: 7px;
font-size: 13px; background: var(--bg-inset);
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 12px;
color: var(--text-primary); color: var(--text-primary);
outline: none; outline: none;
box-sizing: border-box; box-sizing: border-box;
transition: border-color 0.15s; transition:
border-color 0.15s,
box-shadow 0.15s;
} }
.search-input::placeholder { .custom-perm-input::placeholder {
color: var(--text-muted); color: var(--text-muted);
font-family: inherit;
} }
.search-input:focus { .custom-perm-input:focus {
border-color: var(--accent-color, #0066cc); border-color: var(--accent-color, #0066cc);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.12);
} }
.search-clear { .custom-perm-btn {
position: absolute; display: inline-flex;
right: 8px; align-items: center;
background: none; gap: 4px;
padding: 7px 14px;
background: var(--accent-color, #0066cc);
color: #fff;
border: none; border: none;
padding: 3px; border-radius: 7px;
font-size: 12px;
font-weight: 500;
cursor: pointer; cursor: pointer;
color: var(--text-muted); white-space: nowrap;
border-radius: 4px; transition: filter 0.15s;
}
.custom-perm-btn:hover:not(:disabled) {
filter: brightness(1.1);
}
.custom-perm-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.custom-perm-error {
margin: 4px 0 0;
font-size: 11.5px;
color: #dc2626;
}
.custom-cat-label {
cursor: default;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 6px;
transition: color 0.1s; padding: 7px 10px;
} }
.search-clear:hover { .custom-cat-label svg {
color: var(--text-primary); color: var(--text-muted);
flex-shrink: 0;
} }
/* ── Permission list ── */ /* ── Permission list ── */
+724
View File
@@ -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>
+999
View File
@@ -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>
+303
View File
@@ -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>
+2
View File
@@ -11,6 +11,8 @@ export const optima = {
role: (await import("./optima-api/modules/roles")).role, role: (await import("./optima-api/modules/roles")).role,
permission: (await import("./optima-api/modules/permissions")).permission, permission: (await import("./optima-api/modules/permissions")).permission,
user, user,
users: (await import("./optima-api/modules/users")).users,
unifi: (await import("./optima-api/modules/unifi")).unifi,
}; };
/** /**
* @TODO * @TODO
+7 -1
View File
@@ -4,10 +4,16 @@ export const company = {
async fetch( async fetch(
accessToken: string, accessToken: string,
id: string, id: string,
options?: { includeAddress?: boolean }, options?: {
includeAddress?: boolean;
includePrimaryContact?: boolean;
includeAllContacts?: boolean;
},
) { ) {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (options?.includeAddress) params.includeAddress = "true"; if (options?.includeAddress) params.includeAddress = "true";
if (options?.includePrimaryContact) params.includePrimaryContact = "true";
if (options?.includeAllContacts) params.includeAllContacts = "true";
const company = await api.get(`/v1/company/companies/${id}`, { const company = await api.get(`/v1/company/companies/${id}`, {
params, params,
@@ -5,7 +5,8 @@ export interface CredentialTypeField {
name: string; name: string;
required: boolean; required: boolean;
secure: boolean; secure: boolean;
valueType: "plain_text" | "password" | "number" | "email" | "url"; valueType: string;
subFields?: CredentialTypeField[];
} }
export interface CredentialType { export interface CredentialType {
+87 -2
View File
@@ -2,15 +2,20 @@ import api from "../axios";
export interface CredentialField { export interface CredentialField {
id: string; id: string;
fieldId: string; name: string;
secure: boolean;
required: boolean;
valueType: string;
value: string; value: string;
} }
export interface Credential { export interface Credential {
id: string; id: string;
name: string; name: string;
notes?: string;
typeId: string; typeId: string;
companyId: string; companyId: string;
subCredentialOfId?: string;
fields: CredentialField[]; fields: CredentialField[];
type?: { type?: {
id: string; id: string;
@@ -45,6 +50,7 @@ export const credential = {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
}, },
}); });
return response.data; return response.data;
}, },
@@ -62,7 +68,11 @@ export const credential = {
return response.data; return response.data;
}, },
async update(accessToken: string, id: string, data: { name: string }) { async update(
accessToken: string,
id: string,
data: { name?: string; notes?: string },
) {
const response = await api.patch(`/v1/credential/credentials/${id}`, data, { const response = await api.patch(`/v1/credential/credentials/${id}`, data, {
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
@@ -96,4 +106,79 @@ export const credential = {
}); });
return response.data; return response.data;
}, },
async fetchSecureValue(
accessToken: string,
credentialId: string,
fieldId: string,
) {
const response = await api.get(
`/v1/credential/credentials/${credentialId}/secure-values/${fieldId}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async fetchValueTypes(accessToken: string) {
const response = await api.get("/v1/credential/valuetypes", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async fetchSubCredentials(accessToken: string, credentialId: string) {
const response = await api.get(
`/v1/credential/credentials/${credentialId}/sub-credentials`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async addSubCredential(
accessToken: string,
credentialId: string,
data: {
fieldId: string;
name: string;
fields: Array<{ fieldId: string; value: string }>;
},
) {
const response = await api.post(
`/v1/credential/credentials/${credentialId}/sub-credentials`,
data,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async removeSubCredential(
accessToken: string,
credentialId: string,
subId: string,
) {
const response = await api.delete(
`/v1/credential/credentials/${credentialId}/sub-credentials/${subId}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
}; };
+383
View File
@@ -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;
},
};
+2 -2
View File
@@ -89,8 +89,8 @@ export const user = {
} catch {} } catch {}
reject(new Error("Timed out waiting for auth callback")); reject(new Error("Timed out waiting for auth callback"));
}, },
2 * 60 * 1000, 5 * 60 * 1000,
); // 2 minutes ); // 5 minutes
const handlePayload = (payload: any) => { const handlePayload = (payload: any) => {
try { try {
+126
View File
@@ -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 });
}
},
};
+751 -5
View File
@@ -1,11 +1,141 @@
<script lang="ts"> <script lang="ts">
import { enhance } from "$app/forms";
import type { SubmitFunction } from "@sveltejs/kit";
import type { PermissionMap } from "$lib/permissions"; import type { PermissionMap } from "$lib/permissions";
import type {
CredentialType,
CredentialTypeField,
} from "$lib/optima-api/modules/credentialTypes";
import CreateCredentialTypeModal from "../../../components/CreateCredentialTypeModal.svelte";
import "../../../styles/admin/credential-types.css";
export let data: { permissions: PermissionMap }; export let data: {
permissions: PermissionMap;
credentialTypes: CredentialType[];
valueTypes: string[];
};
$: hasAccess = data.permissions["admin.credential-types.view"] === true; $: hasAccess = data.permissions["admin.credential-types.view"] === true;
$: canCreate = data.permissions["admin.credential-types.create"] === true;
$: canEdit = data.permissions["admin.credential-types.edit"] === true;
$: canDelete = data.permissions["admin.credential-types.delete"] === true;
$: credentialTypes = data.credentialTypes;
$: valueTypes = data.valueTypes ?? [];
// Search / filter
let searchQuery = "";
$: filteredTypes = credentialTypes.filter((ct) => {
if (!searchQuery.trim()) return true;
const q = searchQuery.toLowerCase();
return (
ct.name.toLowerCase().includes(q) ||
ct.permissionScope.toLowerCase().includes(q)
);
});
// Create / edit modal state
let isCreateModalOpen = false;
let typeToEdit: CredentialType | null = null;
function openEdit(ct: CredentialType) {
typeToEdit = ct;
isCreateModalOpen = true;
openMenuId = null;
}
// Three-dot menu
let openMenuId: string | null = null;
function toggleMenu(id: string) {
openMenuId = openMenuId === id ? null : id;
}
/** Position the dropdown using fixed coordinates so it escapes overflow:hidden parents */
function positionMenu(node: HTMLElement) {
const btn = node.parentElement?.querySelector(
".menu-btn",
) as HTMLElement | null;
if (!btn) return;
function update() {
const rect = btn!.getBoundingClientRect();
node.style.top = `${rect.bottom + 4}px`;
node.style.left = `${rect.right - node.offsetWidth}px`;
}
update();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return {
destroy() {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
},
};
}
// Delete confirmation
let typeToDelete: CredentialType | null = null;
let isDeleting = false;
let deleteError = "";
function openDeleteConfirm(ct: CredentialType) {
typeToDelete = ct;
openMenuId = null;
}
function cancelDelete() {
typeToDelete = null;
deleteError = "";
}
const handleDeleteEnhance: SubmitFunction = () => {
isDeleting = true;
deleteError = "";
return async ({ result, update }) => {
isDeleting = false;
if (result.type === "success") {
typeToDelete = null;
} else if (result.type === "failure") {
deleteError =
(result.data as { message?: string })?.message ??
"Failed to delete credential type.";
}
await update();
};
};
// Expanded row state
let expandedTypeId: string | null = null;
function toggleType(id: string) {
expandedTypeId = expandedTypeId === id ? null : id;
}
function formatDate(dateStr?: string): string {
if (!dateStr) return "";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "";
}
}
function valueTypeLabel(vt: string): string {
return vt
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
</script> </script>
<svelte:window on:click={() => (openMenuId = null)} />
{#if !hasAccess} {#if !hasAccess}
<div class="admin-denied"> <div class="admin-denied">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -18,16 +148,632 @@
administrator to request access. administrator to request access.
</p> </p>
</div> </div>
{:else} {:else if credentialTypes.length === 0}
<CreateCredentialTypeModal
isOpen={isCreateModalOpen}
{typeToEdit}
{valueTypes}
onClose={() => {
isCreateModalOpen = false;
typeToEdit = null;
}}
onSuccess={() => {
isCreateModalOpen = false;
typeToEdit = null;
}}
/>
<div class="admin-tab-empty"> <div class="admin-tab-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /> <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg> </svg>
<h3>Credential Type Management</h3> <h3>No Credential Types Found</h3>
<p> <p>
Credential type definitions and configuration will be wired up here. There are no credential types configured yet. Create your first credential
Connect an API module to populate this view. type to get started.
</p>
{#if canCreate}
<button
type="button"
class="create-ct-btn"
on:click={() => (isCreateModalOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Create Credential Type
</button>
{/if}
</div>
{:else}
<CreateCredentialTypeModal
isOpen={isCreateModalOpen}
{typeToEdit}
{valueTypes}
onClose={() => {
isCreateModalOpen = false;
typeToEdit = null;
}}
onSuccess={() => {
isCreateModalOpen = false;
typeToEdit = null;
}}
/>
{#if typeToDelete}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="confirm-backdrop"
on:click={cancelDelete}
on:keydown={(e) => e.key === "Escape" && cancelDelete()}
>
<div
class="confirm-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
tabindex="-1"
on:click|stopPropagation
on:keydown|stopPropagation
>
<div class="confirm-icon-wrap">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
width="22"
height="22"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
</div>
<h3 id="confirm-title" class="confirm-title">Delete Credential Type</h3>
<p class="confirm-body">
Are you sure you want to delete
<strong>{typeToDelete.name}</strong>?
{#if typeToDelete.credentialCount > 0}
This type has <strong>{typeToDelete.credentialCount}</strong>
credential{typeToDelete.credentialCount === 1 ? "" : "s"} associated
with it.
{/if}
This action cannot be undone.
</p>
{#if deleteError}
<p class="confirm-error">{deleteError}</p>
{/if}
<div class="confirm-actions">
<button
type="button"
class="btn-cancel"
on:click={cancelDelete}
disabled={isDeleting}
>
Cancel
</button>
<form
method="POST"
action="?/deleteCredentialType"
use:enhance={handleDeleteEnhance}
>
<input type="hidden" name="id" value={typeToDelete.id} />
<button
type="submit"
class="btn-delete-confirm"
disabled={isDeleting}
>
{isDeleting ? "Deleting…" : "Delete"}
</button>
</form>
</div>
</div>
</div>
{/if}
<div class="admin-table-header">
<h3>
Credential Types
<span class="result-count"
>{filteredTypes.length} type{filteredTypes.length === 1
? ""
: "s"}{#if searchQuery.trim()}&nbsp;(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 &ldquo;{searchQuery}&rdquo;. Try a different
search.
</p> </p>
</div> </div>
{/if}
{/if} {/if}
+1 -470
View File
@@ -5,6 +5,7 @@
import type { Role } from "$lib/optima-api/modules/roles"; import type { Role } from "$lib/optima-api/modules/roles";
import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions"; import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions";
import CreateRoleModal from "../../../components/CreateRoleModal.svelte"; import CreateRoleModal from "../../../components/CreateRoleModal.svelte";
import "../../../styles/admin/roles.css";
interface RoleUser { interface RoleUser {
id: string; id: string;
@@ -555,473 +556,3 @@
</table> </table>
</div> </div>
{/if} {/if}
<style>
/* ── Table header layout ── */
:global(.admin-table-header) {
display: flex;
align-items: center;
justify-content: space-between;
}
.create-role-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
background: var(--accent-color, #0066cc);
color: #fff;
border: none;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: filter 0.15s;
flex-shrink: 0;
}
.create-role-btn:hover {
filter: brightness(1.1);
}
/* ── Role-specific styles ── */
.role-row {
cursor: pointer;
}
.role-row.expanded {
background: var(--card-hover-bg);
}
.role-title-cell {
display: flex;
align-items: center;
gap: 10px;
}
.role-icon {
width: 18px;
height: 18px;
color: var(--text-muted);
flex-shrink: 0;
}
.role-title {
font-weight: 600;
color: var(--text-primary);
}
.role-moniker {
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-inset);
padding: 2px 8px;
border-radius: 4px;
}
.role-perm-count {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.role-user-count {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.role-user-count svg {
color: var(--text-muted);
flex-shrink: 0;
}
.row-chevron {
transition: transform 0.2s ease;
color: var(--text-muted);
}
.row-chevron.open {
transform: rotate(180deg);
}
/* Expanded detail row */
.role-detail-row td {
padding: 0 !important;
border-bottom: 1px solid var(--border-subtle);
}
.role-detail-content {
padding: 16px 24px 20px;
background: var(--bg-inset);
animation: roleDetailFadeIn 0.15s ease;
}
@keyframes roleDetailFadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.role-detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.role-detail-section {
margin-bottom: 0;
}
.detail-count {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
padding: 1px 6px;
border-radius: 8px;
margin-left: 2px;
}
.user-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.user-card {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 7px;
}
.user-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--accent-color, #0066cc);
color: #fff;
font-size: 10.5px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
letter-spacing: 0.02em;
}
.user-info {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.user-name {
font-size: 12.5px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-login {
font-size: 11px;
color: var(--text-muted);
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.role-detail-heading {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.role-detail-empty {
margin: 0;
font-size: 13px;
color: var(--text-muted);
}
/* Permission tags */
.permission-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.permission-tag {
display: inline-block;
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 11px;
font-weight: 500;
padding: 3px 8px;
border-radius: 5px;
background: var(--status-neutral-bg);
color: var(--text-secondary);
border: 1px solid var(--border-subtle);
}
/* ── Row end cell (actions + chevron) ── */
.row-end-cell {
width: 1%;
white-space: nowrap;
}
.row-end-content {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
/* ── System badge ── */
.system-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 5px;
background: var(--bg-inset);
color: var(--text-muted);
border: 1px solid var(--border-subtle);
white-space: nowrap;
}
/* ── Three-dot menu ── */
.menu-wrap {
position: relative;
}
.menu-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
background: none;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
color: var(--text-muted);
transition:
background 0.12s,
border-color 0.12s,
color 0.12s;
}
.menu-btn:hover {
background: var(--card-hover-bg);
border-color: var(--border-subtle);
color: var(--text-primary);
}
.role-menu {
position: fixed;
z-index: 200;
min-width: 130px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
padding: 4px;
animation: menuIn 0.1s ease;
}
@keyframes menuIn {
from {
opacity: 0;
transform: translateY(-4px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.role-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
background: none;
border: none;
border-radius: 5px;
font-size: 13px;
font-weight: 400;
color: var(--text-primary);
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.role-menu-item:hover {
background: var(--card-hover-bg);
}
.role-menu-item svg {
color: var(--text-muted);
flex-shrink: 0;
}
.role-menu-item-danger {
color: #dc2626;
}
.role-menu-item-danger svg {
color: #dc2626;
}
.role-menu-item-danger:hover {
background: rgba(220, 38, 38, 0.08);
}
.role-menu-sep {
height: 1px;
background: var(--border-subtle);
margin: 3px 4px;
}
/* ── Delete confirmation overlay ── */
.confirm-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.confirm-dialog {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
width: 90%;
max-width: 380px;
padding: 28px 24px 22px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
animation: modalIn 0.15s ease;
}
.confirm-icon-wrap {
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(220, 38, 38, 0.1);
border: 1px solid rgba(220, 38, 38, 0.2);
display: flex;
align-items: center;
justify-content: center;
color: #dc2626;
margin-bottom: 14px;
}
.confirm-title {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.confirm-body {
margin: 0 0 16px;
font-size: 13.5px;
color: var(--text-secondary);
line-height: 1.5;
}
.confirm-error {
margin: 0 0 12px;
font-size: 13px;
color: #dc2626;
}
.confirm-actions {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
justify-content: center;
}
.confirm-actions form {
display: contents;
}
.btn-cancel {
padding: 7px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
background: var(--bg-inset);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
transition:
background 0.15s,
border-color 0.15s;
}
.btn-cancel:hover:not(:disabled) {
background: var(--card-hover-bg);
border-color: var(--border-default);
color: var(--text-primary);
}
.btn-cancel:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-delete-confirm {
padding: 7px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
background: #dc2626;
border: 1px solid transparent;
color: #fff;
transition: filter 0.15s;
}
.btn-delete-confirm:hover:not(:disabled) {
filter: brightness(1.1);
}
.btn-delete-confirm:disabled {
opacity: 0.45;
cursor: not-allowed;
}
</style>
+143
View File
@@ -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 });
}
},
};
+575 -7
View File
@@ -1,11 +1,148 @@
<script lang="ts"> <script lang="ts">
import { enhance } from "$app/forms";
import type { SubmitFunction } from "@sveltejs/kit";
import type { PermissionMap } from "$lib/permissions"; import type { PermissionMap } from "$lib/permissions";
import type { User } from "$lib/optima-api/modules/users";
import type { Role } from "$lib/optima-api/modules/roles";
import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions";
import EditUserModal from "../../../components/EditUserModal.svelte";
import "../../../styles/admin/users.css";
export let data: { permissions: PermissionMap }; type UserWithRoles = User & { roleDetails: Role[] };
export let data: {
permissions: PermissionMap;
users: UserWithRoles[];
roles: Role[];
permissionNodes: PermissionsCategorized;
};
$: hasAccess = data.permissions["admin.users.view"] === true; $: hasAccess = data.permissions["admin.users.view"] === true;
$: canEdit = data.permissions["admin.users.edit"] === true;
$: canDelete = data.permissions["admin.users.delete"] === true;
$: canEditRoles = data.permissions["user.roles.other"] === true;
$: canEditPermissions = data.permissions["user.permissions.other"] === true;
$: users = data.users;
$: allRoles = data.roles;
// Search / filter
let searchQuery = "";
$: filteredUsers = users.filter((u) => {
if (!searchQuery.trim()) return true;
const q = searchQuery.toLowerCase();
return (
u.name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
u.login.toLowerCase().includes(q)
);
});
// Expanded row state
let expandedUserId: string | null = null;
function toggleUser(id: string) {
expandedUserId = expandedUserId === id ? null : id;
}
// Three-dot menu
let openMenuId: string | null = null;
function toggleMenu(id: string) {
openMenuId = openMenuId === id ? null : id;
}
function positionMenu(node: HTMLElement) {
const btn = node.parentElement?.querySelector(
".menu-btn",
) as HTMLElement | null;
if (!btn) return;
function update() {
const rect = btn!.getBoundingClientRect();
node.style.top = `${rect.bottom + 4}px`;
node.style.left = `${rect.right - node.offsetWidth}px`;
}
update();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return {
destroy() {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
},
};
}
// Edit modal state
let editingUser: UserWithRoles | null = null;
function openEdit(u: UserWithRoles) {
editingUser = u;
openMenuId = null;
}
function cancelEdit() {
editingUser = null;
}
// Delete confirmation
let userToDelete: UserWithRoles | null = null;
let isDeleting = false;
let deleteError = "";
function openDeleteConfirm(u: UserWithRoles) {
userToDelete = u;
openMenuId = null;
}
function cancelDelete() {
userToDelete = null;
deleteError = "";
}
const handleDeleteEnhance: SubmitFunction = () => {
isDeleting = true;
deleteError = "";
return async ({ result, update }) => {
isDeleting = false;
if (result.type === "success") {
userToDelete = null;
} else if (result.type === "failure") {
deleteError =
(result.data as { message?: string })?.message ??
"Failed to delete user.";
}
await update();
};
};
function initials(name: string): string {
return name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
function formatDate(dateStr?: string): string {
if (!dateStr) return "";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "";
}
}
</script> </script>
<svelte:window on:click={() => (openMenuId = null)} />
{#if !hasAccess} {#if !hasAccess}
<div class="admin-denied"> <div class="admin-denied">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -18,7 +155,7 @@
request access. request access.
</p> </p>
</div> </div>
{:else} {:else if users.length === 0}
<div class="admin-tab-empty"> <div class="admin-tab-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
@@ -26,10 +163,441 @@
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /> <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" /> <path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg> </svg>
<h3>User Management</h3> <h3>No Users Found</h3>
<p> <p>There are no users in the system yet.</p>
User listing and editing will be wired up here. Connect an API module to
populate this view.
</p>
</div> </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()}
&nbsp;(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 &ldquo;{searchQuery}&rdquo;. Try a different search.</p>
</div>
{/if}
{/if} {/if}
+18 -1
View File
@@ -1,5 +1,6 @@
import { optima } from "$lib"; import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler"; import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions } from "$lib/permissions";
import type { PageServerLoad } from "./$types"; import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals, url }) => { export const load: PageServerLoad = async ({ locals, url }) => {
@@ -11,6 +12,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
currentPage: 1, currentPage: 1,
totalRecords: 0, totalRecords: 0,
search: "", search: "",
permissions: {},
}; };
} }
@@ -18,7 +20,21 @@ export const load: PageServerLoad = async ({ locals, url }) => {
const search = url.searchParams.get("search") || ""; const search = url.searchParams.get("search") || "";
try { try {
const result = await optima.company.fetchMany(accessToken, page, search); const [result, permissions] = await Promise.all([
optima.company.fetchMany(accessToken, page, search).catch((err) => {
console.error(
"Failed to fetch companies:",
err?.response?.data ?? err?.message ?? err,
);
return {
data: [],
meta: {
pagination: { totalPages: 1, currentPage: 1, totalRecords: 0 },
},
};
}),
checkPermissions(accessToken, ["companies.view"]),
]);
return { return {
companies: result?.data ?? [], companies: result?.data ?? [],
@@ -27,6 +43,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
totalRecords: totalRecords:
result?.meta?.pagination?.totalRecords ?? result?.data?.length ?? 0, result?.meta?.pagination?.totalRecords ?? result?.data?.length ?? 0,
search, search,
permissions,
}; };
} catch (err) { } catch (err) {
handleApiError(err); handleApiError(err);
+63 -4
View File
@@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { afterNavigate } from "$app/navigation"; import { afterNavigate } from "$app/navigation";
import type { PermissionMap } from "$lib/permissions";
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte"; import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
import "../../styles/companies/companylist.css"; import "../../styles/companies/companylist.css";
export let data: { export let data: {
permissions: PermissionMap;
companies: Array<{ companies: Array<{
id: string; id: string;
name: string; name: string;
@@ -21,6 +23,8 @@
search: string; search: string;
}; };
$: hasAccess = data.permissions["companies.view"] === true;
let searchInput = data.search; let searchInput = data.search;
let debounceTimer: ReturnType<typeof setTimeout>; let debounceTimer: ReturnType<typeof setTimeout>;
let isSearching = false; let isSearching = false;
@@ -134,7 +138,20 @@
<title>Companies — Project Optima</title> <title>Companies — Project Optima</title>
</svelte:head> </svelte:head>
<div class="companies-page"> {#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"> <div class="companies-pane">
<!-- Pane header --> <!-- Pane header -->
<div class="pane-header"> <div class="pane-header">
@@ -246,7 +263,14 @@
stroke-width="2" stroke-width="2"
class="meta-icon" class="meta-icon"
> >
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" /> <rect
x="2"
y="7"
width="20"
height="14"
rx="2"
ry="2"
/>
<path d="M16 3h-8l-2 4h12z" /> <path d="M16 3h-8l-2 4h12z" />
</svg> </svg>
<span>{company.type}</span> <span>{company.type}</span>
@@ -278,7 +302,9 @@
> >
{/if} {/if}
{#if formatDate(company.createdAt)} {#if formatDate(company.createdAt)}
<span class="card-date">{formatDate(company.createdAt)}</span> <span class="card-date"
>{formatDate(company.createdAt)}</span
>
{/if} {/if}
</div> </div>
@@ -359,4 +385,37 @@
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
{/if}
<style>
.access-denied {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 12px;
}
.access-denied svg {
width: 40px;
height: 40px;
color: var(--status-inactive-color);
}
.access-denied h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.access-denied p {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
text-align: center;
max-width: 360px;
}
</style>
+32 -2
View File
@@ -9,6 +9,10 @@ export const load: PageServerLoad = async ({ locals, params }) => {
return { return {
company: null, company: null,
configurations: [], configurations: [],
credentials: [],
credentialTypes: [],
unifiSites: [],
accessToken: null,
permissions: {} as PermissionMap, permissions: {} as PermissionMap,
}; };
} }
@@ -16,19 +20,45 @@ export const load: PageServerLoad = async ({ locals, params }) => {
try { try {
// Run permission checks in parallel with other data fetches. // Run permission checks in parallel with other data fetches.
// Add any new permissions the company page needs to this array. // Add any new permissions the company page needs to this array.
const [permissions, configsResult] = await Promise.all([ const [
checkPermissions(accessToken, ["company.fetch.address"]), permissions,
configsResult,
credentialsResult,
credentialTypesResult,
unifiSitesResult,
] = await Promise.all([
checkPermissions(accessToken, [
"company.fetch.address",
"company.fetch.contacts",
"credential.secure_values.read",
"unifi.site.wifi",
"unifi.site.wifi.read.name",
"unifi.site.wifi.update",
]),
optima.company.fetchConfigurations(accessToken, params.id), optima.company.fetchConfigurations(accessToken, params.id),
optima.credential
.fetchByCompany(accessToken, params.id)
.catch(() => ({ data: [] })),
optima.credentialType.fetchMany(accessToken).catch(() => ({ data: [] })),
optima.unifi
.fetchCompanySites(accessToken, params.id)
.catch(() => ({ data: [] })),
]); ]);
// Fetch company with or without address based on permission // Fetch company with or without address based on permission
const companyResult = await optima.company.fetch(accessToken, params.id, { const companyResult = await optima.company.fetch(accessToken, params.id, {
includeAddress: permissions["company.fetch.address"] === true, includeAddress: permissions["company.fetch.address"] === true,
includePrimaryContact: true,
includeAllContacts: permissions["company.fetch.contacts"] === true,
}); });
return { return {
company: companyResult?.data ?? null, company: companyResult?.data ?? null,
configurations: configsResult?.data ?? [], configurations: configsResult?.data ?? [],
credentials: credentialsResult?.data ?? [],
credentialTypes: credentialTypesResult?.data ?? [],
unifiSites: unifiSitesResult?.data ?? [],
accessToken,
permissions, permissions,
}; };
} catch (err) { } catch (err) {
+280 -633
View File
@@ -1,168 +1,65 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation";
import "../../../styles/companies/companydetail.css"; import "../../../styles/companies/companydetail.css";
import { onMount } from "svelte";
import type { PageData } from "./types";
import type { PermissionMap } from "$lib/permissions"; // Tab components
import CompanySidebar from "./components/CompanySidebar.svelte";
import OverviewTab from "./components/OverviewTab.svelte";
import CredentialsTab from "./components/CredentialsTab.svelte";
import ConfigurationsTab from "./components/ConfigurationsTab.svelte";
import ContactsTab from "./components/ContactsTab.svelte";
import UniFiTab from "./components/UniFiTab.svelte";
import ActivityTab from "./components/ActivityTab.svelte";
export let data: { export let data: PageData;
company: {
id: string;
name: string;
status?: string;
type?: string;
createdAt?: string;
updatedAt?: string;
identifier?: string;
contactEmail?: string;
contactPhone?: string;
address?: string;
city?: string;
state?: string;
zip?: string;
country?: string;
cw_Data?: {
address?: {
line1?: string;
line2?: string | null;
city?: string;
state?: string;
zip?: string;
country?: string;
};
[key: string]: unknown;
};
[key: string]: unknown;
} | null;
configurations: Array<{
id: string | number;
name: string;
active?: boolean;
serialNumber?: string;
status?: {
id: number;
name: string;
};
type?: {
id: number;
name: string;
_info?: { type_href?: string };
};
notes?: string;
questions?: Array<{
id: number;
question: string;
answer?: string;
fieldType: string;
}> | null;
info?: {
lastUpdated?: string;
updatedBy?: string;
dateEntered?: string;
enteredBy?: string;
};
key?: string;
value?: string;
description?: string;
createdAt?: string;
updatedAt?: string;
[key: string]: unknown;
}>;
permissions: PermissionMap;
};
$: company = data.company; $: company = data.company;
$: configurations = data.configurations; $: configurations = data.configurations;
$: credentials = data.credentials;
$: credentialTypes = data.credentialTypes;
$: unifiSites = data.unifiSites;
$: accessToken = data.accessToken;
$: permissions = data.permissions; $: permissions = data.permissions;
const tabs = ["Credentials", "Configurations", "Users", "Activity"] as const; // Mobile detection
type Tab = (typeof tabs)[number]; let isMobile = false;
let activeTab: Tab = "Credentials"; function checkMobile() {
isMobile = typeof window !== "undefined" && window.innerWidth <= 768;
// Configurations split-view state
let selectedConfig: (typeof configurations)[number] | null = null;
let configFadeKey = 0;
// Track which password fields are revealed (by question id)
let revealedPasswords: Record<number, boolean> = {};
function togglePassword(questionId: number) {
revealedPasswords[questionId] = !revealedPasswords[questionId];
revealedPasswords = revealedPasswords; // trigger reactivity
} }
onMount(() => {
function selectConfig(config: (typeof configurations)[number]) { checkMobile();
if (selectedConfig?.id === config.id) { window.addEventListener("resize", checkMobile);
// Clicking the active config collapses back to full list return () => window.removeEventListener("resize", checkMobile);
selectedConfig = null;
} else {
selectedConfig = config;
configFadeKey++; // bump key to trigger fade animation
revealedPasswords = {}; // reset password visibility
}
}
function companyInitials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase();
}
function statusClass(status?: string): string {
if (!status) return "neutral";
const s = status.toLowerCase();
if (s === "active") return "active";
if (s === "inactive" || s === "disabled") return "inactive";
if (s === "pending") return "pending";
return "neutral";
}
function configStatusClass(statusName?: string): string {
if (!statusName) return "neutral";
const s = statusName.toLowerCase();
if (s === "active") return "active";
if (s === "inactive" || s === "automate inactive") return "inactive";
if (s === "reserved") return "reserved";
if (s === "provisioning" || s === "pending approval") return "pending";
return "neutral";
}
function formatDate(dateStr?: string): string {
if (!dateStr) return "";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}); });
} catch {
return ""; // Create credential modal state (bound to CredentialsTab)
} let isCreateCredentialOpen = false;
// Link UniFi site modal state (bound to UniFiTab)
let isLinkUnifiOpen = false;
// Tab navigation
const tabs = [
"Overview",
"Credentials",
"Configurations",
"UniFi",
"Contacts",
"Activity",
] as const;
type Tab = (typeof tabs)[number];
let activeTab: Tab = "Overview";
// Mobile nav state: null = show vertical nav menu; set = show tab content
let mobileActiveTab: Tab | null = null;
function selectMobileTab(tab: Tab) {
activeTab = tab;
mobileActiveTab = tab;
} }
function formatAddress(c: NonNullable<typeof company>): string[] { function mobileBack() {
// Prefer the nested cw_Data.address structure returned when includeAddress=true mobileActiveTab = null;
const addr = c.cw_Data?.address;
if (addr) {
const lines: string[] = [];
if (addr.line1) lines.push(addr.line1);
if (addr.line2) lines.push(addr.line2);
const cityStateZip = [addr.city, addr.state, addr.zip]
.filter(Boolean)
.join(", ");
if (cityStateZip) lines.push(cityStateZip);
if (addr.country) lines.push(addr.country);
return lines;
}
// Fallback to flat fields
const lines: string[] = [];
if (c.address) lines.push(c.address);
const cityStateZip = [c.city, c.state, c.zip].filter(Boolean).join(", ");
if (cityStateZip) lines.push(cityStateZip);
if (c.country) lines.push(c.country);
return lines;
} }
</script> </script>
@@ -172,192 +69,195 @@
<div class="company-detail-page"> <div class="company-detail-page">
<!-- Left pane (1/4) — Company overview --> <!-- Left pane (1/4) — Company overview -->
<div class="company-detail-left"> <CompanySidebar {company} {permissions} {isMobile} {mobileActiveTab} />
<div class="detail-pane-body">
<!-- Mobile vertical nav menu (only visible on mobile when no tab selected) -->
{#if isMobile && mobileActiveTab === null}
<div class="mobile-nav-menu">
{#each tabs as tab}
<button <button
class="back-btn" class="mobile-nav-item"
on:click={() => goto("/companies")} on:click={() => selectMobileTab(tab)}
aria-label="Back to companies" type="button"
> >
<span class="mobile-nav-icon">
{#if tab === "Credentials"}
<svg <svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path
d="M7 11V7a5 5 0 0 1 10 0v4"
/>
</svg>
{:else if tab === "Configurations"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<circle cx="12" cy="12" r="3" /><path
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"
/>
</svg>
{:else if tab === "UniFi"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path d="M12 2L2 7l10 5 10-5-10-5z" /><path
d="M2 17l10 5 10-5"
/><path d="M2 12l10 5 10-5" />
</svg>
{:else if tab === "Contacts"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/><path d="M23 21v-2a4 4 0 00-3-3.87" /><path
d="M16 3.13a4 4 0 010 7.75"
/>
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
{/if}
</span>
<span class="mobile-nav-label">{tab}</span>
{#if tab === "Credentials" && credentials.length > 0}
<span class="mobile-nav-badge">{credentials.length}</span>
{/if}
{#if tab === "Configurations" && configurations.length > 0}
<span class="mobile-nav-badge">{configurations.length}</span>
{/if}
{#if tab === "UniFi" && unifiSites.length > 0}
<span class="mobile-nav-badge">{unifiSites.length}</span>
{/if}
{#if tab === "Contacts" && (company?.cw_Data?.allContacts?.length ?? 0) > 0}
<span class="mobile-nav-badge"
>{company?.cw_Data?.allContacts?.length}</span
>
{/if}
<svg
class="mobile-nav-chevron"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-width="2" stroke-width="2"
width="16" width="16"
height="16" height="16"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
{/each}
</div>
{/if}
<!-- Right pane (3/4) -->
<div
class="company-detail-right"
class:mobile-hidden={isMobile && mobileActiveTab === null}
>
<!-- Mobile content header with back button -->
{#if isMobile && mobileActiveTab !== null}
<div class="mobile-content-header">
<button
class="mobile-back-btn"
on:click={mobileBack}
type="button"
aria-label="Back to menu"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
> >
<path d="M19 12H5M12 19l-7-7 7-7" /> <path d="M19 12H5M12 19l-7-7 7-7" />
</svg> </svg>
</button> </button>
{#if company} <h3 class="mobile-content-title">{mobileActiveTab}</h3>
<!-- Avatar + name + status --> {#if mobileActiveTab === "Credentials"}
<div class="profile-header"> <button
<div class="profile-avatar"> type="button"
<span class="profile-initials">{companyInitials(company.name)}</span class="create-credential-btn mobile-create-btn"
on:click={() => (isCreateCredentialOpen = true)}
> >
</div>
<h3 class="profile-name">{company.name}</h3>
{#if company.status}
<span class="profile-status {statusClass(company.status)}"
>{company.status}</span
>
{/if}
</div>
<!-- Info rows -->
<div class="profile-info">
{#if company.type}
<div class="info-row">
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-width="2" stroke-width="2"
class="info-icon" width="14"
height="14"
> >
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" /> <line x1="12" y1="5" x2="12" y2="19" /><line
<path d="M16 3h-8l-2 4h12z" /> x1="5"
</svg> y1="12"
<div class="info-content"> x2="19"
<span class="info-label">Type</span> y2="12"
<span class="info-value">{company.type}</span>
</div>
</div>
{/if}
{#if company.identifier || company.id}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<path d="M4 9h16M4 15h16M10 3L8 21M16 3l-2 18" />
</svg>
<div class="info-content">
<span class="info-label">Identifier</span>
<span class="info-value mono"
>{company.identifier || company.id}</span
>
</div>
</div>
{/if}
{#if company.contactEmail}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M22 7l-10 7L2 7" />
</svg>
<div class="info-content">
<span class="info-label">Email</span>
<span class="info-value">{company.contactEmail}</span>
</div>
</div>
{/if}
{#if company.contactPhone}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<path
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
/> />
</svg> </svg>
<div class="info-content"> New
<span class="info-label">Phone</span> </button>
<span class="info-value">{company.contactPhone}</span>
</div>
</div>
{/if} {/if}
{#if mobileActiveTab === "UniFi"}
{#if permissions["company.fetch.address"] && formatAddress(company).length > 0} <button
<div class="info-row"> type="button"
class="create-credential-btn mobile-create-btn"
on:click={() => (isLinkUnifiOpen = true)}
>
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-width="2" stroke-width="2"
class="info-icon" width="14"
height="14"
> >
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" /> <path
<circle cx="12" cy="10" r="3" /> d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"
/>
<path
d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"
/>
</svg> </svg>
<div class="info-content"> Link
<span class="info-label">Address</span> </button>
<span class="info-value address-multiline"> {/if}
{#each formatAddress(company) as line}
{line}<br />
{/each}
</span>
</div>
</div> </div>
{/if} {/if}
{#if formatDate(company.createdAt)}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<div class="info-content">
<span class="info-label">Created</span>
<span class="info-value">{formatDate(company.createdAt)}</span>
</div>
</div>
{/if}
{#if formatDate(company.updatedAt)}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<div class="info-content">
<span class="info-label">Updated</span>
<span class="info-value">{formatDate(company.updatedAt)}</span>
</div>
</div>
{/if}
</div>
{:else}
<div class="profile-empty">
<p>Company not found.</p>
</div>
{/if}
</div>
</div>
<!-- Right pane (3/4) -->
<div class="company-detail-right">
<div class="tab-bar" role="tablist"> <div class="tab-bar" role="tablist">
{#each tabs as tab} {#each tabs as tab}
<button <button
@@ -368,344 +268,91 @@
on:click={() => (activeTab = tab)} on:click={() => (activeTab = tab)}
> >
{tab} {tab}
{#if tab === "Credentials" && credentials.length > 0}
<span class="tab-count-badge">{credentials.length}</span>
{/if}
{#if tab === "Configurations" && configurations.length > 0} {#if tab === "Configurations" && configurations.length > 0}
<span class="tab-count-badge">{configurations.length}</span> <span class="tab-count-badge">{configurations.length}</span>
{/if} {/if}
{#if tab === "UniFi" && unifiSites.length > 0}
<span class="tab-count-badge">{unifiSites.length}</span>
{/if}
</button> </button>
{/each} {/each}
{#if activeTab === "Credentials"}
<div class="tab-bar-spacer"></div>
<button
type="button"
class="create-credential-btn"
on:click={() => (isCreateCredentialOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Create Credential
</button>
{/if}
{#if activeTab === "UniFi"}
<div class="tab-bar-spacer"></div>
<button
type="button"
class="create-credential-btn"
on:click={() => (isLinkUnifiOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"
/>
<path
d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"
/>
</svg>
Link Site
</button>
{/if}
</div> </div>
<div class="detail-pane-body"> <div class="detail-pane-body">
{#if activeTab === "Credentials"} {#if activeTab === "Overview"}
<p class="tab-placeholder">Credentials content</p> <OverviewTab {company} {credentials} {configurations} {unifiSites} />
{:else if activeTab === "Credentials"}
<CredentialsTab
companyId={company?.id ?? ""}
{credentials}
{credentialTypes}
{accessToken}
{permissions}
{isMobile}
bind:isCreateCredentialOpen
/>
{:else if activeTab === "Configurations"} {:else if activeTab === "Configurations"}
{#if configurations.length === 0} <ConfigurationsTab {configurations} {isMobile} />
<div class="tab-empty"> {:else if activeTab === "UniFi"}
<svg <UniFiTab
viewBox="0 0 24 24" companyId={company?.id ?? ""}
fill="none" {unifiSites}
stroke="currentColor" {accessToken}
stroke-width="1.5" {permissions}
class="tab-empty-icon" bind:isLinkUnifiOpen
>
<path
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/> />
</svg> {:else if activeTab === "Contacts"}
<p>No configurations found</p> <ContactsTab {company} />
</div>
{:else}
<div class="config-split" class:expanded={selectedConfig !== null}>
<!-- Left side: config buttons -->
<div class="config-list" class:collapsed={selectedConfig !== null}>
{#each configurations as config (config.id)}
<button
class="config-item"
class:selected={selectedConfig?.id === config.id}
class:config-inactive={config.status?.name === "Inactive" ||
config.status?.name === "Automate Inactive"}
on:click={() => selectConfig(config)}
type="button"
>
<div class="config-item-header">
<div class="config-name-group">
<span
class="config-status-dot dot-{configStatusClass(
config.status?.name,
)}"
title={config.status?.name ?? "Unknown"}
></span>
<span class="config-name">{config.name}</span>
</div>
<div class="config-header-badges">
{#if config.status?.name && !selectedConfig}
<span
class="config-status-badge status-{configStatusClass(
config.status.name,
)}">{config.status.name}</span
>
{/if}
{#if config.type?.name && !selectedConfig}
<span class="config-type-badge">{config.type.name}</span
>
{/if}
</div>
</div>
{#if !selectedConfig}
{#if config.description}
<p class="config-description">{config.description}</p>
{/if}
{#if config.key}
<div class="config-kv">
<span class="config-key">{config.key}</span>
{#if config.value}
<span class="config-value">{config.value}</span>
{/if}
</div>
{/if}
{#if formatDate(config.updatedAt) || formatDate(config.createdAt) || formatDate(config.info?.lastUpdated) || formatDate(config.info?.dateEntered)}
<span class="config-date">
{#if formatDate(config.updatedAt)}
Updated {formatDate(config.updatedAt)}
{:else if formatDate(config.info?.lastUpdated)}
Updated {formatDate(config.info?.lastUpdated)}
{:else if formatDate(config.createdAt)}
Created {formatDate(config.createdAt)}
{:else if formatDate(config.info?.dateEntered)}
Created {formatDate(config.info?.dateEntered)}
{/if}
</span>
{/if}
{/if}
</button>
{/each}
</div>
<!-- Right side: config detail panel -->
{#if selectedConfig}
<div class="config-detail-panel">
{#key configFadeKey}
<div class="config-detail-content">
<div class="config-detail-header">
<div class="config-detail-header-left">
<h3 class="config-detail-title">
{selectedConfig.name}
</h3>
<div class="config-detail-meta-badges">
{#if selectedConfig.type?.name}
<span class="config-badge type"
>{selectedConfig.type.name}</span
>
{/if}
{#if selectedConfig.status?.name}
<span
class="config-badge status-{configStatusClass(
selectedConfig.status.name,
)}"
>
{selectedConfig.status.name}
</span>
{/if}
</div>
</div>
<button
class="config-detail-close"
on:click={() => (selectedConfig = null)}
aria-label="Close detail view"
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{#if selectedConfig.serialNumber}
<div class="config-serial">
<span class="config-serial-label">Serial #</span>
<span class="config-serial-value"
>{selectedConfig.serialNumber}</span
>
</div>
{/if}
<!-- Notes -->
{#if selectedConfig.notes}
<div class="config-notes">
<h4 class="config-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
Notes
</h4>
<p class="config-notes-text">{selectedConfig.notes}</p>
</div>
{/if}
<!-- Questions / Fields -->
{#if selectedConfig.questions && selectedConfig.questions.length > 0}
<div class="config-questions">
<h4 class="config-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"
/>
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
Configuration Details
</h4>
<div class="questions-grid">
{#each selectedConfig.questions as q (q.id)}
<div
class="question-row"
class:has-answer={!!q.answer}
>
<span class="question-label">{q.question}</span>
<div class="question-value-wrap">
{#if q.fieldType === "Password"}
<span class="question-value password-value">
{#if revealedPasswords[q.id]}
{q.answer || "—"}
{:else}
{q.answer ? "••••••••" : "—"}
{/if}
</span>
{#if q.answer}
<button
class="password-toggle"
on:click={() => togglePassword(q.id)}
type="button"
aria-label={revealedPasswords[q.id]
? "Hide password"
: "Show password"}
>
{#if revealedPasswords[q.id]}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94"
/>
<path
d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19"
/>
<path
d="M14.12 14.12a3 3 0 11-4.24-4.24"
/>
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
{/if}
</button>
{/if}
{:else if q.fieldType === "TextArea"}
<span class="question-value textarea-value"
>{q.answer || "—"}</span
>
{:else}
<span class="question-value"
>{q.answer || "—"}</span
>
{/if}
</div>
</div>
{/each}
</div>
</div>
{:else if !selectedConfig.notes}
<div class="config-no-questions">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="32"
height="32"
>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<p>No configuration fields available</p>
</div>
{/if}
<!-- Footer metadata -->
{#if selectedConfig.info}
<div class="config-info-footer">
{#if selectedConfig.info.enteredBy || selectedConfig.info.dateEntered}
<div class="config-info-item">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path d="M12 5v14M5 12h14" />
</svg>
Created{#if selectedConfig.info.enteredBy}&nbsp;by <strong
>{selectedConfig.info.enteredBy}</strong
>{/if}{#if selectedConfig.info.dateEntered}&nbsp;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}&nbsp;by <strong
>{selectedConfig.info.updatedBy}</strong
>{/if}{#if selectedConfig.info.lastUpdated}&nbsp;on
{formatDate(selectedConfig.info.lastUpdated)}{/if}
</div>
{/if}
</div>
{/if}
</div>
{/key}
</div>
{/if}
</div>
{/if}
{:else if activeTab === "Users"}
<p class="tab-placeholder">Users content</p>
{:else if activeTab === "Activity"} {:else if activeTab === "Activity"}
<p class="tab-placeholder">Activity content</p> <ActivityTab />
{/if} {/if}
</div> </div>
</div> </div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
<script lang="ts">
// Activity tab — placeholder for future implementation
</script>
<p class="tab-placeholder">Activity content</p>
@@ -0,0 +1,260 @@
<script lang="ts">
import { goto } from "$app/navigation";
import {
type CompanyData,
companyInitials,
statusClass,
formatDate,
formatPhone,
formatAddress,
} from "../types";
import type { PermissionMap } from "$lib/permissions";
export let company: CompanyData | null;
export let permissions: PermissionMap;
export let isMobile: boolean;
export let mobileActiveTab: string | null;
</script>
<div
class="company-detail-left"
class:mobile-collapsed={isMobile && mobileActiveTab !== null}
>
<div class="detail-pane-body">
<button
class="back-btn"
on:click={() => goto("/companies")}
aria-label="Back to companies"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
{#if company}
<!-- Avatar + name + status -->
<div class="profile-header">
<div class="profile-avatar">
<span class="profile-initials">{companyInitials(company.name)}</span>
</div>
<h3 class="profile-name">{company.name}</h3>
{#if company.status}
<span class="profile-status {statusClass(company.status)}"
>{company.status}</span
>
{/if}
</div>
<!-- Info rows -->
<div class="profile-info">
{#if company.cw_Data?.primaryContact}
{@const contact = company.cw_Data.primaryContact}
<div class="primary-contact-section">
<div class="primary-contact-header">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span class="primary-contact-label">Primary Contact</span>
</div>
<div class="primary-contact-card">
<div class="primary-contact-name">
{[contact.firstName, contact.lastName]
.filter(Boolean)
.join(" ")}
{#if contact.inactive}
<span class="primary-contact-inactive">Inactive</span>
{/if}
</div>
{#if contact.title}
<div class="primary-contact-title">{contact.title}</div>
{/if}
{#if contact.email}
<div class="primary-contact-detail">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M22 7l-10 7L2 7" />
</svg>
<a
href="mailto:{contact.email}"
class="primary-contact-link"
on:click|stopPropagation>{contact.email}</a
>
</div>
{/if}
{#if contact.phone}
<div class="primary-contact-detail">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<path
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
/>
</svg>
<span>{formatPhone(contact.phone)}</span>
</div>
{/if}
</div>
</div>
{/if}
{#if company.type}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
<path d="M16 3h-8l-2 4h12z" />
</svg>
<div class="info-content">
<span class="info-label">Type</span>
<span class="info-value">{company.type}</span>
</div>
</div>
{/if}
{#if company.contactEmail}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M22 7l-10 7L2 7" />
</svg>
<div class="info-content">
<span class="info-label">Email</span>
<span class="info-value">{company.contactEmail}</span>
</div>
</div>
{/if}
{#if company.contactPhone}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<path
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
/>
</svg>
<div class="info-content">
<span class="info-label">Phone</span>
<span class="info-value">{formatPhone(company.contactPhone)}</span
>
</div>
</div>
{/if}
{#if permissions["company.fetch.address"] && formatAddress(company).length > 0}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<div class="info-content">
<span class="info-label">Address</span>
<span class="info-value address-multiline">
{#each formatAddress(company) as line}
{line}<br />
{/each}
</span>
</div>
</div>
{/if}
{#if formatDate(company.createdAt)}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<div class="info-content">
<span class="info-label">Created</span>
<span class="info-value">{formatDate(company.createdAt)}</span>
</div>
</div>
{/if}
{#if formatDate(company.updatedAt)}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<div class="info-content">
<span class="info-label">Updated</span>
<span class="info-value">{formatDate(company.updatedAt)}</span>
</div>
</div>
{/if}
</div>
{#if company.identifier || company.id}
<div class="side-pane-identifier">
{company.identifier || company.id}
</div>
{/if}
{:else}
<div class="profile-empty">
<p>Company not found.</p>
</div>
{/if}
</div>
</div>
@@ -0,0 +1,376 @@
<script lang="ts">
import type { ConfigurationData } from "../types";
import { formatDate, configStatusClass } from "../types";
export let configurations: ConfigurationData[];
export let isMobile: boolean;
// Configurations split-view state
let selectedConfig: ConfigurationData | null = null;
let configFadeKey = 0;
// Track which password fields are revealed (by question id)
let revealedPasswords: Record<number, boolean> = {};
function togglePassword(questionId: number) {
revealedPasswords[questionId] = !revealedPasswords[questionId];
revealedPasswords = revealedPasswords;
}
function selectConfig(config: ConfigurationData) {
if (selectedConfig?.id === config.id) {
selectedConfig = null;
} else {
selectedConfig = config;
configFadeKey++;
revealedPasswords = {};
}
}
</script>
{#if configurations.length === 0}
<div class="tab-empty">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tab-empty-icon"
>
<path
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
<p>No configurations found</p>
</div>
{:else}
<div
class="config-split"
class:expanded={selectedConfig !== null && !isMobile}
>
<!-- Left side: config buttons -->
<div
class="config-list"
class:collapsed={selectedConfig !== null && !isMobile}
>
{#each configurations as config (config.id)}
<button
class="config-item"
class:selected={selectedConfig?.id === config.id}
class:config-inactive={config.status?.name === "Inactive" ||
config.status?.name === "Automate Inactive"}
on:click={() => selectConfig(config)}
type="button"
>
<div class="config-item-header">
<div class="config-name-group">
<span
class="config-status-dot dot-{configStatusClass(
config.status?.name,
)}"
title={config.status?.name ?? "Unknown"}
></span>
<span class="config-name">{config.name}</span>
</div>
<div class="config-header-badges">
{#if config.status?.name && (!selectedConfig || isMobile)}
<span
class="config-status-badge status-{configStatusClass(
config.status.name,
)}">{config.status.name}</span
>
{/if}
{#if config.type?.name && (!selectedConfig || isMobile)}
<span class="config-type-badge">{config.type.name}</span>
{/if}
</div>
</div>
{#if !selectedConfig || isMobile}
{#if config.description}
<p class="config-description">{config.description}</p>
{/if}
{#if config.key}
<div class="config-kv">
<span class="config-key">{config.key}</span>
{#if config.value}
<span class="config-value">{config.value}</span>
{/if}
</div>
{/if}
{#if formatDate(config.updatedAt) || formatDate(config.createdAt) || formatDate(config.info?.lastUpdated) || formatDate(config.info?.dateEntered)}
<span class="config-date">
{#if formatDate(config.updatedAt)}
Updated {formatDate(config.updatedAt)}
{:else if formatDate(config.info?.lastUpdated)}
Updated {formatDate(config.info?.lastUpdated)}
{:else if formatDate(config.createdAt)}
Created {formatDate(config.createdAt)}
{:else if formatDate(config.info?.dateEntered)}
Created {formatDate(config.info?.dateEntered)}
{/if}
</span>
{/if}
{/if}
</button>
{/each}
</div>
<!-- Right side: config detail panel -->
{#if selectedConfig}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="bottom-sheet-overlay"
class:active={selectedConfig !== null}
on:click={() => {
selectedConfig = null;
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="bottom-sheet-panel" on:click|stopPropagation>
<div class="bottom-sheet-handle"></div>
<div class="bottom-sheet-body">
<div class="config-detail-panel">
{#key configFadeKey}
<div class="config-detail-content">
<div class="config-detail-header">
<div class="config-detail-header-left">
<h3 class="config-detail-title">
{selectedConfig.name}
</h3>
<div class="config-detail-meta-badges">
{#if selectedConfig.type?.name}
<span class="config-badge type"
>{selectedConfig.type.name}</span
>
{/if}
{#if selectedConfig.status?.name}
<span
class="config-badge status-{configStatusClass(
selectedConfig.status.name,
)}"
>
{selectedConfig.status.name}
</span>
{/if}
</div>
</div>
<button
class="config-detail-close"
on:click={() => (selectedConfig = null)}
aria-label="Close detail view"
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{#if selectedConfig.serialNumber}
<div class="config-serial">
<span class="config-serial-label">Serial #</span>
<span class="config-serial-value"
>{selectedConfig.serialNumber}</span
>
</div>
{/if}
<!-- Questions / Fields -->
{#if (selectedConfig.questions && selectedConfig.questions.length > 0) || selectedConfig.notes}
<div class="config-questions">
{#if selectedConfig.notes}
<div class="config-notes">
<h4 class="config-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
Notes
</h4>
<p class="config-notes-text">
{selectedConfig.notes}
</p>
</div>
{/if}
<h4 class="config-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"
/>
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
Configuration Details
</h4>
<div class="questions-grid">
{#each selectedConfig.questions as q (q.id)}
<div
class="question-row"
class:has-answer={!!q.answer}
>
<span class="question-label">{q.question}</span>
<div class="question-value-wrap">
{#if q.fieldType === "Password"}
<span class="question-value password-value">
{#if revealedPasswords[q.id]}
{q.answer || "—"}
{:else}
{q.answer ? "••••••••" : "—"}
{/if}
</span>
{#if q.answer}
<button
class="password-toggle"
on:click={() => togglePassword(q.id)}
type="button"
aria-label={revealedPasswords[q.id]
? "Hide password"
: "Show password"}
>
{#if revealedPasswords[q.id]}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94"
/>
<path
d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19"
/>
<path
d="M14.12 14.12a3 3 0 11-4.24-4.24"
/>
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
{/if}
</button>
{/if}
{:else if q.fieldType === "TextArea"}
<span class="question-value textarea-value"
>{q.answer || "—"}</span
>
{:else}
<span class="question-value"
>{q.answer || "—"}</span
>
{/if}
</div>
</div>
{/each}
</div>
</div>
{:else if !selectedConfig.notes}
<div class="config-no-questions">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="32"
height="32"
>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<p>No configuration fields available</p>
</div>
{/if}
<!-- Footer metadata -->
{#if selectedConfig.info}
<div class="config-info-footer">
{#if selectedConfig.info.enteredBy || selectedConfig.info.dateEntered}
<div class="config-info-item">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path d="M12 5v14M5 12h14" />
</svg>
Created{#if selectedConfig.info.enteredBy}&nbsp;by
<strong>{selectedConfig.info.enteredBy}</strong
>{/if}{#if selectedConfig.info.dateEntered}&nbsp;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}&nbsp;by
<strong>{selectedConfig.info.updatedBy}</strong
>{/if}{#if selectedConfig.info.lastUpdated}&nbsp;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");
}
};
+187
View File
@@ -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`;
}
+591
View File
@@ -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);
}
}
+481
View File
@@ -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);
}
}
+575
View File
@@ -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
View File
@@ -26,10 +26,11 @@
--text-primary: #2c3e50; --text-primary: #2c3e50;
--text-secondary: #666666; --text-secondary: #666666;
--text-muted: #8492a6; --text-muted: #8492a6;
--text-faint: #9ca3af; --text-faint: #6b7280;
--text-inverse: #ffffff; --text-inverse: #ffffff;
/* Nav */ /* Accent */
--accent: #3498db;
--nav-hover-bg: rgba(0, 0, 0, 0.04); --nav-hover-bg: rgba(0, 0, 0, 0.04);
--nav-active-bg: rgba(52, 152, 219, 0.08); --nav-active-bg: rgba(52, 152, 219, 0.08);
--nav-active-color: #3498db; --nav-active-color: #3498db;
@@ -137,6 +138,9 @@
--text-faint: #525252; --text-faint: #525252;
--text-inverse: #0e0e0e; --text-inverse: #0e0e0e;
/* Accent */
--accent: #333333;
/* Nav */ /* Nav */
--nav-hover-bg: rgba(255, 255, 255, 0.04); --nav-hover-bg: rgba(255, 255, 255, 0.04);
--nav-active-bg: rgba(255, 255, 255, 0.06); --nav-active-bg: rgba(255, 255, 255, 0.06);
File diff suppressed because it is too large Load Diff