Setup unifi wlans
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,14 @@
|
||||
monikerEdited = true;
|
||||
selectedPermissions = new Set(roleToEdit.permissions);
|
||||
|
||||
// Identify permissions not in the categorized list → show as custom
|
||||
const catalogued = new Set(
|
||||
Object.values(permissionNodes).flatMap((cat) =>
|
||||
(cat as PermissionCategory).permissions.map((p) => p.node),
|
||||
),
|
||||
);
|
||||
customNodes = roleToEdit.permissions.filter((p) => !catalogued.has(p));
|
||||
|
||||
// Auto-expand categories that contain selected permissions
|
||||
const sel = new Set(roleToEdit.permissions);
|
||||
const expanded = new Set<string>();
|
||||
@@ -39,10 +47,12 @@
|
||||
let moniker = "";
|
||||
let monikerEdited = false;
|
||||
let selectedPermissions = new Set<string>();
|
||||
let permissionSearch = "";
|
||||
let expandedCategories = new Set<string>();
|
||||
let isSubmitting = false;
|
||||
let submitError = "";
|
||||
let customPermNode = "";
|
||||
let customPermError = "";
|
||||
let customNodes: string[] = [];
|
||||
|
||||
$: if (!monikerEdited) {
|
||||
moniker = title
|
||||
@@ -51,27 +61,10 @@
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
}
|
||||
|
||||
$: filteredEntries = (() => {
|
||||
const entries = Object.entries(permissionNodes) as [
|
||||
string,
|
||||
PermissionCategory,
|
||||
][];
|
||||
if (!permissionSearch.trim()) return entries;
|
||||
const q = permissionSearch.toLowerCase();
|
||||
return entries
|
||||
.map(([key, cat]): [string, PermissionCategory] => [
|
||||
key,
|
||||
{
|
||||
...cat,
|
||||
permissions: cat.permissions.filter(
|
||||
(p: PermissionNode) =>
|
||||
p.node.toLowerCase().includes(q) ||
|
||||
p.description.toLowerCase().includes(q),
|
||||
),
|
||||
},
|
||||
])
|
||||
.filter(([, cat]) => cat.permissions.length > 0);
|
||||
})();
|
||||
$: permissionEntries = Object.entries(permissionNodes ?? {}) as [
|
||||
string,
|
||||
PermissionCategory,
|
||||
][];
|
||||
|
||||
$: isValid = title.trim().length > 0 && moniker.trim().length > 0;
|
||||
$: selectedCount = selectedPermissions.size;
|
||||
@@ -97,15 +90,52 @@
|
||||
selectedPermissions = next;
|
||||
}
|
||||
|
||||
function addCustomPermission() {
|
||||
const node = customPermNode.trim();
|
||||
customPermError = "";
|
||||
if (!node) return;
|
||||
// Validate permission node format per PERMISSIONS.md:
|
||||
// - dot-separated tokens of lowercase alphanumeric + underscores
|
||||
// - special tokens: * (wildcard), ? (single-char wildcard)
|
||||
// - inclusive list [a,b,c] or exclusive list <a,b,c>
|
||||
// - standalone * for full access
|
||||
if (
|
||||
!/^(?:\*|(?:(?:[a-z0-9_]+|\*|\?|\[[a-z0-9_]+(?:,[a-z0-9_]+)*\]|<[a-z0-9_]+(?:,[a-z0-9_]+)*>)(?:\.(?:[a-z0-9_]+|\*|\?|\[[a-z0-9_]+(?:,[a-z0-9_]+)*\]|<[a-z0-9_]+(?:,[a-z0-9_]+)*>))*))$/.test(
|
||||
node,
|
||||
)
|
||||
) {
|
||||
customPermError =
|
||||
"Use dot-separated tokens (e.g. resource.action). Supports: a-z, 0-9, underscores, * ? wildcards, [a,b] or <a,b> lists.";
|
||||
return;
|
||||
}
|
||||
if (selectedPermissions.has(node)) {
|
||||
customPermError = "This permission is already selected.";
|
||||
return;
|
||||
}
|
||||
// Add to custom list if not from the categorized set
|
||||
const catalogued = Object.values(permissionNodes).flatMap((cat) =>
|
||||
(cat as PermissionCategory).permissions.map((p) => p.node),
|
||||
);
|
||||
if (!catalogued.includes(node)) {
|
||||
customNodes = [...customNodes, node];
|
||||
}
|
||||
const next = new Set(selectedPermissions);
|
||||
next.add(node);
|
||||
selectedPermissions = next;
|
||||
customPermNode = "";
|
||||
}
|
||||
|
||||
function reset() {
|
||||
title = "";
|
||||
moniker = "";
|
||||
monikerEdited = false;
|
||||
selectedPermissions = new Set();
|
||||
permissionSearch = "";
|
||||
expandedCategories = new Set();
|
||||
isSubmitting = false;
|
||||
submitError = "";
|
||||
customPermNode = "";
|
||||
customPermError = "";
|
||||
customNodes = [];
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
@@ -113,9 +143,8 @@
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains("modal-backdrop"))
|
||||
handleClose();
|
||||
function handleBackdropClick() {
|
||||
handleClose();
|
||||
}
|
||||
|
||||
function handleBackdropKeydown(e: KeyboardEvent) {
|
||||
@@ -130,12 +159,15 @@
|
||||
on:click={handleBackdropClick}
|
||||
on:keydown={handleBackdropKeydown}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={isEditMode ? "Edit Role" : "Create Role"}
|
||||
tabindex="-1"
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<div class="modal-header">
|
||||
<div class="modal-title-group">
|
||||
@@ -174,7 +206,11 @@
|
||||
<form
|
||||
method="POST"
|
||||
action={isEditMode ? "?/updateRole" : "?/createRole"}
|
||||
use:enhance={() => {
|
||||
use:enhance={({ formData }) => {
|
||||
// Append permissions at submit time instead of via reactive hidden inputs
|
||||
for (const node of selectedPermissions) {
|
||||
formData.append("permissions", node);
|
||||
}
|
||||
isSubmitting = true;
|
||||
submitError = "";
|
||||
return async ({ result, update }) => {
|
||||
@@ -197,9 +233,6 @@
|
||||
};
|
||||
}}
|
||||
>
|
||||
{#each [...selectedPermissions] as node (node)}
|
||||
<input type="hidden" name="permissions" value={node} />
|
||||
{/each}
|
||||
{#if isEditMode && roleToEdit}
|
||||
<input type="hidden" name="id" value={roleToEdit.id} />
|
||||
{/if}
|
||||
@@ -261,64 +294,19 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="search-wrap">
|
||||
<svg
|
||||
class="search-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="13"
|
||||
height="13"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search permissions…"
|
||||
bind:value={permissionSearch}
|
||||
/>
|
||||
{#if permissionSearch}
|
||||
<button
|
||||
type="button"
|
||||
class="search-clear"
|
||||
on:click={() => (permissionSearch = "")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
width="11"
|
||||
height="11"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="perm-list">
|
||||
{#if Object.keys(permissionNodes).length === 0}
|
||||
<p class="perm-empty">No permission data available.</p>
|
||||
{:else if filteredEntries.length === 0}
|
||||
<p class="perm-empty">No permissions match your search.</p>
|
||||
{:else}
|
||||
{#each filteredEntries as [catKey, category] (catKey)}
|
||||
{@const catPerms = category.permissions}
|
||||
{#each permissionEntries as [catKey, category] (catKey)}
|
||||
{@const catPerms = category.permissions ?? []}
|
||||
{@const allSel =
|
||||
catPerms.length > 0 &&
|
||||
catPerms.every((p) => selectedPermissions.has(p.node))}
|
||||
{@const someSel =
|
||||
!allSel &&
|
||||
catPerms.some((p) => selectedPermissions.has(p.node))}
|
||||
{@const isExpanded =
|
||||
permissionSearch.trim().length > 0 ||
|
||||
expandedCategories.has(catKey)}
|
||||
{@const isExpanded = expandedCategories.has(catKey)}
|
||||
<div class="cat-group">
|
||||
<div class="cat-header">
|
||||
<button
|
||||
@@ -419,7 +407,96 @@
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Custom permission nodes -->
|
||||
{#if customNodes.length > 0}
|
||||
<div class="cat-group">
|
||||
<div class="cat-header">
|
||||
<div class="cat-toggle custom-cat-label">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="11"
|
||||
height="11"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
<span class="cat-name">Custom</span>
|
||||
<span class="cat-count">{customNodes.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cat-perms">
|
||||
{#each customNodes as node (node)}
|
||||
{@const sel = selectedPermissions.has(node)}
|
||||
<label class="perm-row" class:perm-sel={sel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="sr-only"
|
||||
checked={sel}
|
||||
on:change={() => togglePermission(node)}
|
||||
/>
|
||||
<div class="cb" class:cb-checked={sel}>
|
||||
{#if sel}
|
||||
<svg
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="8"
|
||||
height="8"
|
||||
>
|
||||
<polyline points="1.5 5 4 8 8.5 2" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="perm-text">
|
||||
<code class="perm-node">{node}</code>
|
||||
<span class="perm-desc">Custom permission</span>
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Custom permission input -->
|
||||
<div class="custom-perm-wrap">
|
||||
<input
|
||||
type="text"
|
||||
class="custom-perm-input"
|
||||
placeholder="Add custom node… (e.g. my.custom.node)"
|
||||
bind:value={customPermNode}
|
||||
on:keydown={(e) =>
|
||||
e.key === "Enter" &&
|
||||
(e.preventDefault(), addCustomPermission())}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="custom-perm-btn"
|
||||
on:click={addCustomPermission}
|
||||
disabled={!customPermNode.trim()}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="13"
|
||||
height="13"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{#if customPermError}
|
||||
<p class="custom-perm-error">{customPermError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -659,58 +736,81 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Search ── */
|
||||
.search-wrap {
|
||||
position: relative;
|
||||
/* ── Custom permission input ── */
|
||||
.custom-perm-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 7px 10px 7px 29px;
|
||||
background: var(--bg-inset);
|
||||
.custom-perm-input {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
background: var(--bg-inset);
|
||||
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
.custom-perm-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
.custom-perm-input:focus {
|
||||
border-color: var(--accent-color, #0066cc);
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.12);
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
background: none;
|
||||
.custom-perm-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 7px 14px;
|
||||
background: var(--accent-color, #0066cc);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 3px;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
|
||||
.custom-perm-btn:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.custom-perm-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.custom-perm-error {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.custom-cat-label {
|
||||
cursor: default;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.1s;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.search-clear:hover {
|
||||
color: var(--text-primary);
|
||||
.custom-cat-label svg {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Permission list ── */
|
||||
|
||||
@@ -0,0 +1,724 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from "$app/forms";
|
||||
import type { SubmitFunction } from "@sveltejs/kit";
|
||||
import type { User } from "$lib/optima-api/modules/users";
|
||||
import type { Role } from "$lib/optima-api/modules/roles";
|
||||
import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions";
|
||||
|
||||
type UserWithRoles = User & { roleDetails: Role[] };
|
||||
|
||||
export let user: UserWithRoles;
|
||||
export let allRoles: Role[] = [];
|
||||
export let permissionNodes: PermissionsCategorized = {};
|
||||
export let canEditRoles = false;
|
||||
export let canEditPermissions = false;
|
||||
export let onClose: () => void = () => {};
|
||||
export let onSuccess: () => void = () => {};
|
||||
|
||||
let editName = user.name;
|
||||
let editImage = user.image ?? "";
|
||||
let editError = "";
|
||||
let isEditing = false;
|
||||
|
||||
// Role editing
|
||||
let editSelectedRoles: string[] = user.roleDetails.map((r) => r.id);
|
||||
|
||||
// Permission editing
|
||||
let editSelectedPermissions: string[] = [...(user.permissions ?? [])];
|
||||
let permSearchQuery = "";
|
||||
let customPermNode = "";
|
||||
let customPermError = "";
|
||||
|
||||
// Track custom-added nodes so they appear in the list
|
||||
let customNodes: string[] = [];
|
||||
|
||||
$: allPermissionNodes = [
|
||||
...Object.values(permissionNodes ?? {}).flatMap((cat) =>
|
||||
cat.permissions.map((p) => p.node),
|
||||
),
|
||||
...customNodes.filter(
|
||||
(n) =>
|
||||
!Object.values(permissionNodes ?? {})
|
||||
.flatMap((cat) => cat.permissions.map((p) => p.node))
|
||||
.includes(n),
|
||||
),
|
||||
];
|
||||
|
||||
$: filteredPermNodes = permSearchQuery.trim()
|
||||
? allPermissionNodes.filter((n) =>
|
||||
n.toLowerCase().includes(permSearchQuery.toLowerCase()),
|
||||
)
|
||||
: allPermissionNodes;
|
||||
|
||||
function toggleEditRole(id: string) {
|
||||
if (editSelectedRoles.includes(id)) {
|
||||
editSelectedRoles = editSelectedRoles.filter((r) => r !== id);
|
||||
} else {
|
||||
editSelectedRoles = [...editSelectedRoles, id];
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEditPermission(node: string) {
|
||||
if (editSelectedPermissions.includes(node)) {
|
||||
editSelectedPermissions = editSelectedPermissions.filter(
|
||||
(p) => p !== node,
|
||||
);
|
||||
} else {
|
||||
editSelectedPermissions = [...editSelectedPermissions, node];
|
||||
}
|
||||
}
|
||||
|
||||
function addCustomPermission() {
|
||||
const node = customPermNode.trim();
|
||||
customPermError = "";
|
||||
if (!node) return;
|
||||
// Validate permission node format per PERMISSIONS.md:
|
||||
// - dot-separated tokens of lowercase alphanumeric + underscores
|
||||
// - special tokens: * (wildcard), ? (single-char wildcard)
|
||||
// - inclusive list [a,b,c] or exclusive list <a,b,c>
|
||||
// - standalone * for full access
|
||||
if (
|
||||
!/^(?:\*|(?:(?:[a-z0-9_]+|\*|\?|\[[a-z0-9_]+(?:,[a-z0-9_]+)*\]|<[a-z0-9_]+(?:,[a-z0-9_]+)*>)(?:\.(?:[a-z0-9_]+|\*|\?|\[[a-z0-9_]+(?:,[a-z0-9_]+)*\]|<[a-z0-9_]+(?:,[a-z0-9_]+)*>))*))$/.test(
|
||||
node,
|
||||
)
|
||||
) {
|
||||
customPermError =
|
||||
"Use dot-separated tokens (e.g. resource.action). Supports: a-z, 0-9, underscores, * ? wildcards, [a,b] or <a,b> lists.";
|
||||
return;
|
||||
}
|
||||
if (editSelectedPermissions.includes(node)) {
|
||||
customPermError = "This permission is already selected.";
|
||||
return;
|
||||
}
|
||||
if (!allPermissionNodes.includes(node)) {
|
||||
customNodes = [...customNodes, node];
|
||||
}
|
||||
editSelectedPermissions = [...editSelectedPermissions, node];
|
||||
customPermNode = "";
|
||||
}
|
||||
|
||||
const handleEditEnhance: SubmitFunction = () => {
|
||||
isEditing = true;
|
||||
editError = "";
|
||||
return async ({ result, update }) => {
|
||||
isEditing = false;
|
||||
if (result.type === "success") {
|
||||
onSuccess();
|
||||
} else if (result.type === "failure") {
|
||||
editError =
|
||||
(result.data as { message?: string })?.message ??
|
||||
"Failed to update user.";
|
||||
}
|
||||
await update();
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="confirm-backdrop"
|
||||
on:click={onClose}
|
||||
on:keydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="edit-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="edit-title"
|
||||
tabindex="-1"
|
||||
on:click|stopPropagation
|
||||
on:keydown|stopPropagation
|
||||
>
|
||||
<h3 id="edit-title" class="edit-dialog-title">Edit User</h3>
|
||||
<p class="edit-dialog-sub">
|
||||
Update information for <strong>{user.name}</strong>
|
||||
</p>
|
||||
{#if editError}
|
||||
<p class="confirm-error">{editError}</p>
|
||||
{/if}
|
||||
<form method="POST" action="?/updateUser" use:enhance={handleEditEnhance}>
|
||||
<input type="hidden" name="id" value={user.id} />
|
||||
{#if canEditRoles}
|
||||
<input
|
||||
type="hidden"
|
||||
name="roles"
|
||||
value={JSON.stringify(editSelectedRoles)}
|
||||
/>
|
||||
{/if}
|
||||
{#if canEditPermissions}
|
||||
<input
|
||||
type="hidden"
|
||||
name="permissions"
|
||||
value={JSON.stringify(editSelectedPermissions)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="edit-field">
|
||||
<label for="edit-name">Name</label>
|
||||
<input
|
||||
id="edit-name"
|
||||
type="text"
|
||||
name="name"
|
||||
bind:value={editName}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="edit-field">
|
||||
<label for="edit-image">Avatar URL</label>
|
||||
<input
|
||||
id="edit-image"
|
||||
type="text"
|
||||
name="image"
|
||||
bind:value={editImage}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Role assignment (gated by user.roles.other) -->
|
||||
{#if canEditRoles}
|
||||
<div class="edit-section">
|
||||
<div class="edit-section-label">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="13"
|
||||
height="13"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
Roles
|
||||
<span class="edit-section-count"
|
||||
>{editSelectedRoles.length} selected</span
|
||||
>
|
||||
</div>
|
||||
<div class="edit-role-list">
|
||||
{#each allRoles as role (role.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="edit-role-chip"
|
||||
class:selected={editSelectedRoles.includes(role.id)}
|
||||
on:click={() => toggleEditRole(role.id)}
|
||||
>
|
||||
<span class="edit-role-check">
|
||||
{#if editSelectedRoles.includes(role.id)}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
width="12"
|
||||
height="12"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="edit-role-chip-title">{role.title}</span>
|
||||
<span class="edit-role-chip-moniker">{role.moniker}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Direct permission assignment (gated by user.permissions.other) -->
|
||||
{#if canEditPermissions}
|
||||
<div class="edit-section">
|
||||
<div class="edit-section-label">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="13"
|
||||
height="13"
|
||||
>
|
||||
<path
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"
|
||||
/>
|
||||
<rect x="9" y="3" width="6" height="4" rx="1" />
|
||||
</svg>
|
||||
Direct Permissions
|
||||
<span class="edit-section-count"
|
||||
>{editSelectedPermissions.length} selected</span
|
||||
>
|
||||
</div>
|
||||
<div class="edit-perm-search-wrap">
|
||||
<input
|
||||
type="text"
|
||||
class="edit-perm-search"
|
||||
placeholder="Search permissions…"
|
||||
bind:value={permSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
<div class="edit-custom-perm-wrap">
|
||||
<input
|
||||
type="text"
|
||||
class="edit-custom-perm-input"
|
||||
placeholder="Add custom node… (e.g. my.custom.node)"
|
||||
bind:value={customPermNode}
|
||||
on:keydown={(e) =>
|
||||
e.key === "Enter" &&
|
||||
(e.preventDefault(), addCustomPermission())}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="edit-custom-perm-btn"
|
||||
on:click={addCustomPermission}
|
||||
disabled={!customPermNode.trim()}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="13"
|
||||
height="13"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{#if customPermError}
|
||||
<p class="edit-custom-perm-error">{customPermError}</p>
|
||||
{/if}
|
||||
<div class="edit-perm-list">
|
||||
{#each filteredPermNodes as node}
|
||||
<label class="edit-perm-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editSelectedPermissions.includes(node)}
|
||||
on:change={() => toggleEditPermission(node)}
|
||||
/>
|
||||
<span class="edit-perm-node">{node}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{#if filteredPermNodes.length === 0}
|
||||
<p class="edit-perm-empty">No permissions match your search.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="confirm-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-cancel"
|
||||
on:click={onClose}
|
||||
disabled={isEditing}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-save"
|
||||
disabled={isEditing || !editName.trim()}
|
||||
>
|
||||
{isEditing ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ── Backdrop & dialog ── */
|
||||
.confirm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.edit-dialog {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
|
||||
width: 90%;
|
||||
max-width: 520px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
padding: 28px 24px 22px;
|
||||
animation: modalIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-dialog-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.edit-dialog-sub {
|
||||
margin: 0 0 18px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.confirm-error {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* ── Form fields ── */
|
||||
.edit-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.edit-field label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.edit-field input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-inset);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.edit-field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-focus-border);
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||
}
|
||||
|
||||
/* ── Actions ── */
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 7px 16px;
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: var(--bg-inset);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.btn-cancel:hover:not(:disabled) {
|
||||
background: var(--card-hover-bg);
|
||||
border-color: var(--border-default);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-cancel:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
padding: 7px 16px;
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: var(--accent-color, #0066cc);
|
||||
border: 1px solid transparent;
|
||||
color: #fff;
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
|
||||
.btn-save:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-save:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Edit sections (roles & permissions) ── */
|
||||
.edit-section {
|
||||
margin-bottom: 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.edit-section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.edit-section-label svg {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-section-count {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Role chips ── */
|
||||
.edit-role-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.edit-role-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
background: var(--bg-inset);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
transition:
|
||||
background 0.12s,
|
||||
border-color 0.12s;
|
||||
}
|
||||
|
||||
.edit-role-chip:hover {
|
||||
background: var(--card-hover-bg);
|
||||
}
|
||||
|
||||
.edit-role-chip.selected {
|
||||
background: rgba(0, 102, 204, 0.08);
|
||||
border-color: var(--accent-color, #0066cc);
|
||||
}
|
||||
|
||||
.edit-role-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
border: 1.5px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
color: var(--accent-color, #0066cc);
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
|
||||
.edit-role-chip.selected .edit-role-check {
|
||||
border-color: var(--accent-color, #0066cc);
|
||||
background: var(--accent-color, #0066cc);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.edit-role-chip-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.edit-role-chip-moniker {
|
||||
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Custom permission input ── */
|
||||
.edit-custom-perm-wrap {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.edit-custom-perm-input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-inset);
|
||||
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.edit-custom-perm-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.edit-custom-perm-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-focus-border);
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||
}
|
||||
|
||||
.edit-custom-perm-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: var(--accent-color, #0066cc);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
|
||||
.edit-custom-perm-btn:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.edit-custom-perm-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.edit-custom-perm-error {
|
||||
margin: 0 0 6px;
|
||||
font-size: 11.5px;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* ── Permission list ── */
|
||||
.edit-perm-search-wrap {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.edit-perm-search {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-inset);
|
||||
font-size: 12.5px;
|
||||
color: var(--text-primary);
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.edit-perm-search::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.edit-perm-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-focus-border);
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||
}
|
||||
|
||||
.edit-perm-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.edit-perm-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.edit-perm-item:hover {
|
||||
background: var(--card-hover-bg);
|
||||
}
|
||||
|
||||
.edit-perm-item input[type="checkbox"] {
|
||||
accent-color: var(--accent-color, #0066cc);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-perm-node {
|
||||
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.edit-perm-empty {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
.edit-role-list::-webkit-scrollbar,
|
||||
.edit-perm-list::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.edit-role-list::-webkit-scrollbar-track,
|
||||
.edit-perm-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.edit-role-list::-webkit-scrollbar-thumb,
|
||||
.edit-perm-list::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,999 @@
|
||||
<script lang="ts">
|
||||
import { unifi, type UnifiSite } from "$lib/optima-api/modules/unifi";
|
||||
|
||||
export let isOpen = false;
|
||||
export let accessToken: string;
|
||||
export let companyId: string;
|
||||
export let linkedSiteIds: string[] = [];
|
||||
export let onSuccess: () => void = () => {};
|
||||
|
||||
type ModalStep = "choose" | "existing" | "new";
|
||||
let step: ModalStep = "choose";
|
||||
|
||||
// Existing site linking
|
||||
let allSites: UnifiSite[] = [];
|
||||
let isLoadingSites = false;
|
||||
let selectedSiteId = "";
|
||||
let isLinking = false;
|
||||
let linkError = "";
|
||||
let siteSearch = "";
|
||||
|
||||
// New site creation
|
||||
let newSiteName = "";
|
||||
let isCreating = false;
|
||||
let createError = "";
|
||||
|
||||
function reset() {
|
||||
step = "choose";
|
||||
allSites = [];
|
||||
isLoadingSites = false;
|
||||
selectedSiteId = "";
|
||||
isLinking = false;
|
||||
linkError = "";
|
||||
siteSearch = "";
|
||||
newSiteName = "";
|
||||
isCreating = false;
|
||||
createError = "";
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen = false;
|
||||
reset();
|
||||
}
|
||||
|
||||
async function goToExisting() {
|
||||
step = "existing";
|
||||
linkError = "";
|
||||
isLoadingSites = true;
|
||||
try {
|
||||
const result = await unifi.fetchSites(accessToken);
|
||||
// Filter out sites already linked to this company
|
||||
allSites = (result?.data ?? []).filter(
|
||||
(s: UnifiSite) => !linkedSiteIds.includes(s.id),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch UniFi sites:", err);
|
||||
linkError = err instanceof Error ? err.message : "Failed to load sites";
|
||||
allSites = [];
|
||||
} finally {
|
||||
isLoadingSites = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goToNew() {
|
||||
step = "new";
|
||||
createError = "";
|
||||
newSiteName = "";
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
step = "choose";
|
||||
linkError = "";
|
||||
createError = "";
|
||||
}
|
||||
|
||||
async function linkExistingSite() {
|
||||
if (!selectedSiteId || !accessToken) return;
|
||||
isLinking = true;
|
||||
linkError = "";
|
||||
try {
|
||||
await unifi.linkSite(accessToken, selectedSiteId, companyId);
|
||||
close();
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
linkError = err instanceof Error ? err.message : "Failed to link site";
|
||||
console.error("Failed to link UniFi site:", err);
|
||||
} finally {
|
||||
isLinking = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createAndLinkSite() {
|
||||
if (!newSiteName.trim() || !accessToken) return;
|
||||
isCreating = true;
|
||||
createError = "";
|
||||
try {
|
||||
const result = await unifi.createSite(accessToken, newSiteName.trim());
|
||||
const newSiteId = result?.data?.id;
|
||||
if (newSiteId && companyId) {
|
||||
await unifi.linkSite(accessToken, newSiteId, companyId);
|
||||
}
|
||||
close();
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
createError =
|
||||
err instanceof Error ? err.message : "Failed to create site";
|
||||
console.error("Failed to create UniFi site:", err);
|
||||
} finally {
|
||||
isCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter sites that are unlinked (no company) or linked to a different company
|
||||
$: availableSites = allSites.filter(
|
||||
(s) => !s.companyId || s.companyId !== companyId,
|
||||
);
|
||||
$: unlinkedSites = availableSites.filter((s) => !s.companyId);
|
||||
$: otherCompanySites = availableSites.filter(
|
||||
(s) => s.companyId && s.companyId !== companyId,
|
||||
);
|
||||
|
||||
// Search-filtered lists
|
||||
$: filteredUnlinked = siteSearch.trim()
|
||||
? unlinkedSites.filter((s) => {
|
||||
const q = siteSearch.trim().toLowerCase();
|
||||
return (
|
||||
s.name.toLowerCase().includes(q) || s.siteId.toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
: unlinkedSites;
|
||||
$: filteredOtherCompany = siteSearch.trim()
|
||||
? otherCompanySites.filter((s) => {
|
||||
const q = siteSearch.trim().toLowerCase();
|
||||
return (
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.siteId.toLowerCase().includes(q) ||
|
||||
(s.company?.name ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
: otherCompanySites;
|
||||
$: totalFiltered = filteredUnlinked.length + filteredOtherCompany.length;
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="modal-overlay" on:click={close}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="modal-container" on:click|stopPropagation>
|
||||
<div class="modal-header">
|
||||
{#if step !== "choose"}
|
||||
<button
|
||||
class="modal-back"
|
||||
on:click={goBack}
|
||||
type="button"
|
||||
aria-label="Back"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<h3 class="modal-title">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="18"
|
||||
height="18"
|
||||
>
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
{#if step === "choose"}
|
||||
Link UniFi Site
|
||||
{:else if step === "existing"}
|
||||
Link Existing Site
|
||||
{:else}
|
||||
Create New Site
|
||||
{/if}
|
||||
</h3>
|
||||
<button
|
||||
class="modal-close"
|
||||
on:click={close}
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
{#if step === "choose"}
|
||||
<p class="modal-description">
|
||||
How would you like to add a UniFi site?
|
||||
</p>
|
||||
<div class="choice-grid">
|
||||
<button class="choice-card" on:click={goToNew} type="button">
|
||||
<div class="choice-icon">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
width="28"
|
||||
height="28"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="16" />
|
||||
<line x1="8" y1="12" x2="16" y2="12" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="choice-label">New Site</span>
|
||||
<span class="choice-desc"
|
||||
>Create a new site on the UniFi controller</span
|
||||
>
|
||||
</button>
|
||||
<button class="choice-card" on:click={goToExisting} type="button">
|
||||
<div class="choice-icon">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
width="28"
|
||||
height="28"
|
||||
>
|
||||
<path
|
||||
d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"
|
||||
/>
|
||||
<path
|
||||
d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="choice-label">Existing Site</span>
|
||||
<span class="choice-desc"
|
||||
>Link an existing UniFi site to this company</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
{:else if step === "existing"}
|
||||
{#if isLoadingSites}
|
||||
<div class="modal-loading">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" class="spin-icon">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
fill="none"
|
||||
opacity="0.25"
|
||||
/>
|
||||
<path
|
||||
d="M12 2a10 10 0 0 1 10 10"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>Loading sites…</span>
|
||||
</div>
|
||||
{:else if availableSites.length === 0}
|
||||
<div class="modal-empty">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
width="32"
|
||||
height="32"
|
||||
>
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
<p>No available sites to link</p>
|
||||
<span class="modal-empty-hint"
|
||||
>All sites are already linked to this company, or no sites
|
||||
exist.</span
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Search input -->
|
||||
<div class="site-search-bar">
|
||||
<svg
|
||||
class="site-search-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
class="site-search-input"
|
||||
type="text"
|
||||
placeholder="Search sites…"
|
||||
bind:value={siteSearch}
|
||||
disabled={isLinking}
|
||||
/>
|
||||
{#if siteSearch}
|
||||
<button
|
||||
class="site-search-clear"
|
||||
type="button"
|
||||
on:click={() => (siteSearch = "")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="12"
|
||||
height="12"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Scrollable site list -->
|
||||
<div class="site-list">
|
||||
{#if totalFiltered === 0}
|
||||
<div class="site-list-empty">
|
||||
<p>No sites matching "{siteSearch}"</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if filteredUnlinked.length > 0}
|
||||
<div class="site-list-group-label">Unlinked Sites</div>
|
||||
{#each filteredUnlinked as site (site.id)}
|
||||
<button
|
||||
class="site-list-item"
|
||||
class:selected={selectedSiteId === site.id}
|
||||
type="button"
|
||||
on:click={() => (selectedSiteId = site.id)}
|
||||
disabled={isLinking}
|
||||
>
|
||||
<div class="site-list-item-icon">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
width="18"
|
||||
height="18"
|
||||
>
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="site-list-item-info">
|
||||
<span class="site-list-item-name">{site.name}</span>
|
||||
<span class="site-list-item-id">{site.siteId}</span>
|
||||
</div>
|
||||
{#if selectedSiteId === site.id}
|
||||
<svg
|
||||
class="site-list-item-check"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if filteredOtherCompany.length > 0}
|
||||
<div class="site-list-group-label">
|
||||
Linked to Other Companies
|
||||
</div>
|
||||
{#each filteredOtherCompany as site (site.id)}
|
||||
<button
|
||||
class="site-list-item"
|
||||
class:selected={selectedSiteId === site.id}
|
||||
type="button"
|
||||
on:click={() => (selectedSiteId = site.id)}
|
||||
disabled={isLinking}
|
||||
>
|
||||
<div class="site-list-item-icon">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
width="18"
|
||||
height="18"
|
||||
>
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="site-list-item-info">
|
||||
<span class="site-list-item-name">{site.name}</span>
|
||||
<span class="site-list-item-id"
|
||||
>{site.company?.name ?? "Unknown"}</span
|
||||
>
|
||||
</div>
|
||||
{#if selectedSiteId === site.id}
|
||||
<svg
|
||||
class="site-list-item-check"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if linkError}
|
||||
<div class="modal-error">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" /><line
|
||||
x1="12"
|
||||
y1="8"
|
||||
x2="12"
|
||||
y2="12"
|
||||
/><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
{linkError}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if step === "new"}
|
||||
<div class="modal-field">
|
||||
<label class="modal-label" for="new-unifi-site-name"
|
||||
>Site Name</label
|
||||
>
|
||||
<input
|
||||
id="new-unifi-site-name"
|
||||
class="modal-input"
|
||||
type="text"
|
||||
bind:value={newSiteName}
|
||||
placeholder="e.g. Main Office"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
{#if createError}
|
||||
<div class="modal-error">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" /><line
|
||||
x1="12"
|
||||
y1="8"
|
||||
x2="12"
|
||||
y2="12"
|
||||
/><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
{createError}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if step !== "choose"}
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
class="modal-btn modal-btn-cancel"
|
||||
on:click={close}
|
||||
type="button"
|
||||
disabled={isLinking || isCreating}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{#if step === "existing"}
|
||||
<button
|
||||
class="modal-btn modal-btn-primary"
|
||||
on:click={linkExistingSite}
|
||||
type="button"
|
||||
disabled={isLinking || !selectedSiteId}
|
||||
>
|
||||
{isLinking ? "Linking…" : "Link Site"}
|
||||
</button>
|
||||
{:else if step === "new"}
|
||||
<button
|
||||
class="modal-btn modal-btn-primary"
|
||||
on:click={createAndLinkSite}
|
||||
type="button"
|
||||
disabled={isCreating || !newSiteName.trim()}
|
||||
>
|
||||
{isCreating ? "Creating…" : "Create & Link"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
width: 480px;
|
||||
max-width: 90vw;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-back:hover {
|
||||
background: var(--nav-hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: var(--nav-hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modal-description {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Choice cards */
|
||||
.choice-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.choice-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 24px 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-base);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.choice-card:hover {
|
||||
border-color: var(--input-focus-border);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--input-focus-border) 5%,
|
||||
var(--bg-base)
|
||||
);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.choice-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--input-focus-border) 10%, transparent);
|
||||
color: var(--input-focus-border);
|
||||
}
|
||||
|
||||
.choice-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.choice-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Form fields */
|
||||
.modal-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.modal-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.modal-input:focus {
|
||||
border-color: var(--input-focus-border);
|
||||
box-shadow: 0 0 0 2px
|
||||
color-mix(in srgb, var(--input-focus-border) 15%, transparent);
|
||||
}
|
||||
|
||||
.modal-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--status-error) 10%, transparent);
|
||||
color: var(--status-error);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 32px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-empty p {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-empty-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
padding: 7px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.modal-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-btn-cancel {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.modal-btn-cancel:hover:not(:disabled) {
|
||||
background: var(--nav-hover-bg);
|
||||
}
|
||||
|
||||
.modal-btn-primary {
|
||||
background: var(--accent-color, #0066cc);
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modal-btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
/* Site search bar */
|
||||
.site-search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.site-search-bar:focus-within {
|
||||
border-color: var(--input-focus-border);
|
||||
box-shadow: 0 0 0 2px
|
||||
color-mix(in srgb, var(--input-focus-border) 12%, transparent);
|
||||
}
|
||||
|
||||
.site-search-icon {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.site-search-bar:focus-within .site-search-icon {
|
||||
color: var(--input-focus-border);
|
||||
}
|
||||
|
||||
.site-search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.site-search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.site-search-input:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.site-search-clear {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.site-search-clear:hover {
|
||||
background: var(--nav-hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Scrollable site list */
|
||||
.site-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.site-list-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.site-list-empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.site-list-group-label {
|
||||
padding: 8px 12px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-base);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.site-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.12s;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.site-list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.site-list-item:hover:not(:disabled) {
|
||||
background: var(--nav-hover-bg);
|
||||
}
|
||||
|
||||
.site-list-item.selected {
|
||||
background: color-mix(in srgb, var(--input-focus-border) 10%, transparent);
|
||||
}
|
||||
|
||||
.site-list-item:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.site-list-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--input-focus-border) 8%, transparent);
|
||||
color: var(--input-focus-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.site-list-item.selected .site-list-item-icon {
|
||||
background: color-mix(in srgb, var(--input-focus-border) 15%, transparent);
|
||||
}
|
||||
|
||||
.site-list-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.site-list-item-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.site-list-item-id {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.site-list-item-check {
|
||||
color: var(--input-focus-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Spinner animation */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.spin-icon) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,303 @@
|
||||
<script lang="ts">
|
||||
import { unifi } from "$lib/optima-api/modules/unifi";
|
||||
|
||||
export let isOpen = false;
|
||||
export let accessToken: string;
|
||||
export let companyId: string;
|
||||
export let onSuccess: () => void = () => {};
|
||||
|
||||
let siteName = "";
|
||||
let isSubmitting = false;
|
||||
let submitError = "";
|
||||
|
||||
function reset() {
|
||||
siteName = "";
|
||||
isSubmitting = false;
|
||||
submitError = "";
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen = false;
|
||||
reset();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!siteName.trim() || !accessToken) return;
|
||||
isSubmitting = true;
|
||||
submitError = "";
|
||||
try {
|
||||
// Create the site on the UniFi controller
|
||||
const result = await unifi.createSite(accessToken, siteName.trim());
|
||||
const newSiteId = result?.data?.id;
|
||||
if (newSiteId && companyId) {
|
||||
// Link it to this company
|
||||
await unifi.linkSite(accessToken, newSiteId, companyId);
|
||||
}
|
||||
close();
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
submitError =
|
||||
err instanceof Error ? err.message : "Failed to create UniFi site";
|
||||
console.error("Failed to create UniFi site:", err);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="modal-overlay" on:click={close}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="modal-container" on:click|stopPropagation>
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="18"
|
||||
height="18"
|
||||
>
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
New UniFi Site
|
||||
</h3>
|
||||
<button
|
||||
class="modal-close"
|
||||
on:click={close}
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-field">
|
||||
<label class="modal-label" for="unifi-site-name">Site Name</label>
|
||||
<input
|
||||
id="unifi-site-name"
|
||||
class="modal-input"
|
||||
type="text"
|
||||
bind:value={siteName}
|
||||
placeholder="e.g. Main Office"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
{#if submitError}
|
||||
<div class="modal-error">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" /><line
|
||||
x1="12"
|
||||
y1="8"
|
||||
x2="12"
|
||||
y2="12"
|
||||
/><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
{submitError}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
class="modal-btn modal-btn-cancel"
|
||||
on:click={close}
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="modal-btn modal-btn-primary"
|
||||
on:click={handleSubmit}
|
||||
type="button"
|
||||
disabled={isSubmitting || !siteName.trim()}
|
||||
>
|
||||
{#if isSubmitting}
|
||||
Creating…
|
||||
{:else}
|
||||
Create Site
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: var(--nav-hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modal-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.modal-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.modal-input:focus {
|
||||
border-color: var(--input-focus-border);
|
||||
box-shadow: 0 0 0 2px
|
||||
color-mix(in srgb, var(--input-focus-border) 15%, transparent);
|
||||
}
|
||||
|
||||
.modal-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--status-error) 10%, transparent);
|
||||
color: var(--status-error);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
padding: 7px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.modal-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-btn-cancel {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.modal-btn-cancel:hover:not(:disabled) {
|
||||
background: var(--nav-hover-bg);
|
||||
}
|
||||
|
||||
.modal-btn-primary {
|
||||
background: var(--accent-color, #0066cc);
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modal-btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user