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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+208 -108
View File
@@ -21,6 +21,14 @@
monikerEdited = true;
selectedPermissions = new Set(roleToEdit.permissions);
// Identify permissions not in the categorized list → show as custom
const catalogued = new Set(
Object.values(permissionNodes).flatMap((cat) =>
(cat as PermissionCategory).permissions.map((p) => p.node),
),
);
customNodes = roleToEdit.permissions.filter((p) => !catalogued.has(p));
// Auto-expand categories that contain selected permissions
const sel = new Set(roleToEdit.permissions);
const expanded = new Set<string>();
@@ -39,10 +47,12 @@
let moniker = "";
let monikerEdited = false;
let selectedPermissions = new Set<string>();
let permissionSearch = "";
let expandedCategories = new Set<string>();
let isSubmitting = false;
let submitError = "";
let customPermNode = "";
let customPermError = "";
let customNodes: string[] = [];
$: if (!monikerEdited) {
moniker = title
@@ -51,27 +61,10 @@
.replace(/[^a-z0-9-]/g, "");
}
$: filteredEntries = (() => {
const entries = Object.entries(permissionNodes) as [
string,
PermissionCategory,
][];
if (!permissionSearch.trim()) return entries;
const q = permissionSearch.toLowerCase();
return entries
.map(([key, cat]): [string, PermissionCategory] => [
key,
{
...cat,
permissions: cat.permissions.filter(
(p: PermissionNode) =>
p.node.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q),
),
},
])
.filter(([, cat]) => cat.permissions.length > 0);
})();
$: permissionEntries = Object.entries(permissionNodes ?? {}) as [
string,
PermissionCategory,
][];
$: isValid = title.trim().length > 0 && moniker.trim().length > 0;
$: selectedCount = selectedPermissions.size;
@@ -97,15 +90,52 @@
selectedPermissions = next;
}
function addCustomPermission() {
const node = customPermNode.trim();
customPermError = "";
if (!node) return;
// Validate permission node format per PERMISSIONS.md:
// - dot-separated tokens of lowercase alphanumeric + underscores
// - special tokens: * (wildcard), ? (single-char wildcard)
// - inclusive list [a,b,c] or exclusive list <a,b,c>
// - standalone * for full access
if (
!/^(?:\*|(?:(?:[a-z0-9_]+|\*|\?|\[[a-z0-9_]+(?:,[a-z0-9_]+)*\]|<[a-z0-9_]+(?:,[a-z0-9_]+)*>)(?:\.(?:[a-z0-9_]+|\*|\?|\[[a-z0-9_]+(?:,[a-z0-9_]+)*\]|<[a-z0-9_]+(?:,[a-z0-9_]+)*>))*))$/.test(
node,
)
) {
customPermError =
"Use dot-separated tokens (e.g. resource.action). Supports: a-z, 0-9, underscores, * ? wildcards, [a,b] or <a,b> lists.";
return;
}
if (selectedPermissions.has(node)) {
customPermError = "This permission is already selected.";
return;
}
// Add to custom list if not from the categorized set
const catalogued = Object.values(permissionNodes).flatMap((cat) =>
(cat as PermissionCategory).permissions.map((p) => p.node),
);
if (!catalogued.includes(node)) {
customNodes = [...customNodes, node];
}
const next = new Set(selectedPermissions);
next.add(node);
selectedPermissions = next;
customPermNode = "";
}
function reset() {
title = "";
moniker = "";
monikerEdited = false;
selectedPermissions = new Set();
permissionSearch = "";
expandedCategories = new Set();
isSubmitting = false;
submitError = "";
customPermNode = "";
customPermError = "";
customNodes = [];
}
function handleClose() {
@@ -113,9 +143,8 @@
onClose();
}
function handleBackdropClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains("modal-backdrop"))
handleClose();
function handleBackdropClick() {
handleClose();
}
function handleBackdropKeydown(e: KeyboardEvent) {
@@ -130,12 +159,15 @@
on:click={handleBackdropClick}
on:keydown={handleBackdropKeydown}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="modal"
role="dialog"
aria-modal="true"
aria-label={isEditMode ? "Edit Role" : "Create Role"}
tabindex="-1"
on:click|stopPropagation
>
<div class="modal-header">
<div class="modal-title-group">
@@ -174,7 +206,11 @@
<form
method="POST"
action={isEditMode ? "?/updateRole" : "?/createRole"}
use:enhance={() => {
use:enhance={({ formData }) => {
// Append permissions at submit time instead of via reactive hidden inputs
for (const node of selectedPermissions) {
formData.append("permissions", node);
}
isSubmitting = true;
submitError = "";
return async ({ result, update }) => {
@@ -197,9 +233,6 @@
};
}}
>
{#each [...selectedPermissions] as node (node)}
<input type="hidden" name="permissions" value={node} />
{/each}
{#if isEditMode && roleToEdit}
<input type="hidden" name="id" value={roleToEdit.id} />
{/if}
@@ -261,64 +294,19 @@
{/if}
</div>
<div class="search-wrap">
<svg
class="search-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
class="search-input"
placeholder="Search permissions…"
bind:value={permissionSearch}
/>
{#if permissionSearch}
<button
type="button"
class="search-clear"
on:click={() => (permissionSearch = "")}
aria-label="Clear search"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
width="11"
height="11"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/if}
</div>
<div class="perm-list">
{#if Object.keys(permissionNodes).length === 0}
<p class="perm-empty">No permission data available.</p>
{:else if filteredEntries.length === 0}
<p class="perm-empty">No permissions match your search.</p>
{:else}
{#each filteredEntries as [catKey, category] (catKey)}
{@const catPerms = category.permissions}
{#each permissionEntries as [catKey, category] (catKey)}
{@const catPerms = category.permissions ?? []}
{@const allSel =
catPerms.length > 0 &&
catPerms.every((p) => selectedPermissions.has(p.node))}
{@const someSel =
!allSel &&
catPerms.some((p) => selectedPermissions.has(p.node))}
{@const isExpanded =
permissionSearch.trim().length > 0 ||
expandedCategories.has(catKey)}
{@const isExpanded = expandedCategories.has(catKey)}
<div class="cat-group">
<div class="cat-header">
<button
@@ -419,7 +407,96 @@
</div>
{/each}
{/if}
<!-- Custom permission nodes -->
{#if customNodes.length > 0}
<div class="cat-group">
<div class="cat-header">
<div class="cat-toggle custom-cat-label">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="11"
height="11"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
<span class="cat-name">Custom</span>
<span class="cat-count">{customNodes.length}</span>
</div>
</div>
<div class="cat-perms">
{#each customNodes as node (node)}
{@const sel = selectedPermissions.has(node)}
<label class="perm-row" class:perm-sel={sel}>
<input
type="checkbox"
class="sr-only"
checked={sel}
on:change={() => togglePermission(node)}
/>
<div class="cb" class:cb-checked={sel}>
{#if sel}
<svg
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
stroke-width="2"
width="8"
height="8"
>
<polyline points="1.5 5 4 8 8.5 2" />
</svg>
{/if}
</div>
<div class="perm-text">
<code class="perm-node">{node}</code>
<span class="perm-desc">Custom permission</span>
</div>
</label>
{/each}
</div>
</div>
{/if}
</div>
<!-- Custom permission input -->
<div class="custom-perm-wrap">
<input
type="text"
class="custom-perm-input"
placeholder="Add custom node… (e.g. my.custom.node)"
bind:value={customPermNode}
on:keydown={(e) =>
e.key === "Enter" &&
(e.preventDefault(), addCustomPermission())}
/>
<button
type="button"
class="custom-perm-btn"
on:click={addCustomPermission}
disabled={!customPermNode.trim()}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add
</button>
</div>
{#if customPermError}
<p class="custom-perm-error">{customPermError}</p>
{/if}
</div>
</div>
@@ -659,58 +736,81 @@
color: #fff;
}
/* ── Search ── */
.search-wrap {
position: relative;
/* ── Custom permission input ── */
.custom-perm-wrap {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.search-icon {
position: absolute;
left: 9px;
color: var(--text-muted);
pointer-events: none;
}
.search-input {
width: 100%;
padding: 7px 10px 7px 29px;
background: var(--bg-inset);
.custom-perm-input {
flex: 1;
padding: 7px 10px;
border: 1px solid var(--border-subtle);
border-radius: 7px;
font-size: 13px;
background: var(--bg-inset);
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 12px;
color: var(--text-primary);
outline: none;
box-sizing: border-box;
transition: border-color 0.15s;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.search-input::placeholder {
.custom-perm-input::placeholder {
color: var(--text-muted);
font-family: inherit;
}
.search-input:focus {
.custom-perm-input:focus {
border-color: var(--accent-color, #0066cc);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.12);
}
.search-clear {
position: absolute;
right: 8px;
background: none;
.custom-perm-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 7px 14px;
background: var(--accent-color, #0066cc);
color: #fff;
border: none;
padding: 3px;
border-radius: 7px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
color: var(--text-muted);
border-radius: 4px;
white-space: nowrap;
transition: filter 0.15s;
}
.custom-perm-btn:hover:not(:disabled) {
filter: brightness(1.1);
}
.custom-perm-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.custom-perm-error {
margin: 4px 0 0;
font-size: 11.5px;
color: #dc2626;
}
.custom-cat-label {
cursor: default;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.1s;
gap: 6px;
padding: 7px 10px;
}
.search-clear:hover {
color: var(--text-primary);
.custom-cat-label svg {
color: var(--text-muted);
flex-shrink: 0;
}
/* ── Permission list ── */
+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>