fix: remove nested .git folders, re-add as normal directories

This commit is contained in:
2026-03-22 17:50:47 -05:00
parent f55c7e47c9
commit 6b7eec67b8
1870 changed files with 4170168 additions and 3 deletions
+47
View File
@@ -0,0 +1,47 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions, type PermissionMap } from "$lib/permissions";
import { redirect } from "@sveltejs/kit";
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ locals, parent }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
throw redirect(303, "/login");
}
try {
// Grab the root layout data to reuse the admin-view permission it already checked
const parentData = await parent();
// If the root layout already determined we can't view admin, redirect immediately
if (parentData?.canViewAdmin === false) {
throw redirect(303, "/");
}
// Fetch sub-tab permissions and user info in parallel
const [permissions, userInfo] = await Promise.all([
checkPermissions(accessToken, [
"admin.users.view",
"admin.roles.view",
"admin.credential-types.view",
"ui.navigation.reports.view",
]),
optima.user.fetchInfo(accessToken),
]);
return {
user: userInfo?.data ?? null,
permissions: {
"ui.navigation.admin.view": true, // Already verified via parent
...permissions,
},
};
} catch (err) {
// Re-throw redirects so SvelteKit handles them
if (err && typeof err === "object" && "status" in err) {
throw err;
}
handleApiError(err);
}
};
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import { page } from "$app/stores";
import "../../styles/admin.css";
import type { PermissionMap } from "$lib/permissions";
export let data: {
user: {
id: string;
name?: string;
email?: string;
[key: string]: unknown;
} | null;
permissions: PermissionMap;
};
$: permissions = data.permissions;
$: userName = data.user?.name || "Admin";
// Tab definitions — each gated by a permission
const allTabs = [
{ label: "Overview", href: "/admin", exact: true, permission: null },
{
label: "Users",
href: "/admin/users",
exact: false,
permission: "admin.users.view",
},
{
label: "Roles",
href: "/admin/roles",
exact: false,
permission: "admin.roles.view",
},
{
label: "Credential Types",
href: "/admin/credential-types",
exact: false,
permission: "admin.credential-types.view",
},
{
label: "Reports",
href: "/admin/reports",
exact: false,
permission: "ui.navigation.reports.view",
},
] as const;
// Only show tabs the user has permission for
$: visibleTabs = allTabs.filter(
(t) => t.permission === null || permissions[t.permission] === true,
);
function isActive(
tab: { href: string; exact?: boolean },
pathname: string,
): boolean {
if (tab.exact) return pathname === tab.href;
return pathname.startsWith(tab.href);
}
</script>
<svelte:head>
<title>Admin — Project Optima</title>
</svelte:head>
<div class="admin-page">
<div class="admin-pane">
<!-- Pane header -->
<div class="admin-header">
<div class="admin-header-left">
<svg
class="admin-header-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
<h2 class="admin-title">Administration</h2>
<span class="admin-subtitle">Welcome back, {userName}</span>
</div>
</div>
<!-- Tab bar -->
<div class="tab-bar" role="tablist">
{#each visibleTabs as tab}
<a
href={tab.href}
class="tab-btn"
class:active={isActive(tab, $page.url.pathname)}
role="tab"
aria-selected={isActive(tab, $page.url.pathname)}
>
{tab.label}
</a>
{/each}
</div>
<!-- Tab content -->
<div class="admin-body">
<slot />
</div>
</div>
</div>
+20
View File
@@ -0,0 +1,20 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return { companyCount: null };
}
try {
const companyCount = await optima.company.count(accessToken);
return {
companyCount: companyCount ?? null,
};
} catch (err) {
handleApiError(err);
}
};
+136
View File
@@ -0,0 +1,136 @@
<script lang="ts">
import { goto } from "$app/navigation";
export let data: {
companyCount: number | null;
};
$: companyCount = data.companyCount;
const quickActions = [
{
href: "/companies",
name: "Companies",
desc: "View and manage companies",
icon: '<path d="M3 21h18"></path><path d="M5 21V7l8-4v18"></path><path d="M19 21V11l-6-4"></path><path d="M9 9h1"></path><path d="M9 13h1"></path><path d="M9 17h1"></path>',
},
{
href: "/admin/users",
name: "Manage Users",
desc: "View, edit, and assign user roles",
icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>',
},
{
href: "/admin/roles",
name: "Manage Roles",
desc: "Configure roles and permissions",
icon: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>',
},
{
href: "/admin/credential-types",
name: "Credential Types",
desc: "Configure credential type definitions",
icon: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path>',
},
];
</script>
<!-- Stats overview -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
<path d="M3 21h18" />
<path d="M5 21V7l8-4v18" />
<path d="M19 21V11l-6-4" />
</svg>
</div>
<div class="stat-info">
<span class="stat-value">{companyCount ?? "—"}</span>
<span class="stat-label">Companies</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<div class="stat-info">
<span class="stat-value"></span>
<span class="stat-label">Users</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<div class="stat-info">
<span class="stat-value"></span>
<span class="stat-label">Credentials</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
</div>
<div class="stat-info">
<span class="stat-value"></span>
<span class="stat-label">Activity Today</span>
</div>
</div>
</div>
<!-- Quick actions -->
<h3 class="section-heading">Quick Actions</h3>
<div class="actions-grid">
{#each quickActions as action}
<a
href={action.href}
class="action-card"
on:click|preventDefault={() => goto(action.href)}
>
<div class="action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
{@html action.icon}
</svg>
</div>
<div class="action-text">
<span class="action-name">{action.name}</span>
<span class="action-desc">{action.desc}</span>
</div>
<svg
class="action-arrow"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</a>
{/each}
</div>
<!-- Recent activity placeholder -->
<h3 class="section-heading">Recent Activity</h3>
<div class="activity-section">
<div class="activity-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span>Activity feed coming soon</span>
</div>
</div>
@@ -0,0 +1,179 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions } from "$lib/permissions";
import { fail } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import { AxiosError } from "axios";
export const load: PageServerLoad = async ({ locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return { credentialTypes: [], permissions: {}, valueTypes: [] };
}
try {
const [typesResult, permissions, valueTypesResult] = await Promise.all([
optima.credentialType.fetchMany(accessToken),
checkPermissions(accessToken, [
"admin.credential-types.view",
"admin.credential-types.create",
"admin.credential-types.edit",
"admin.credential-types.delete",
]),
optima.credential.fetchValueTypes(accessToken).catch((err) => {
console.error(
"Failed to fetch value types:",
err?.response?.data ?? err?.message ?? err,
);
return { data: [] };
}),
]);
const credentialTypes = typesResult?.data ?? [];
const valueTypes: string[] = valueTypesResult?.data ?? [];
return {
credentialTypes,
permissions,
valueTypes,
};
} catch (err) {
handleApiError(err);
}
};
export const actions: Actions = {
createCredentialType: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const name = (formData.get("name") as string)?.trim();
const permissionScope = (formData.get("permissionScope") as string)?.trim();
const icon = (formData.get("icon") as string)?.trim() || undefined;
const fieldsJson = (formData.get("fields") as string)?.trim();
if (!name || !permissionScope) {
return fail(400, { message: "Name and permission scope are required." });
}
let fields: Array<
import("$lib/optima-api/modules/credentialTypes").CredentialTypeField
> = [];
if (fieldsJson) {
try {
fields = JSON.parse(fieldsJson);
} catch {
return fail(400, { message: "Invalid fields data." });
}
}
try {
await optima.credentialType.create(accessToken, {
name,
permissionScope,
icon,
fields,
});
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error
? err.message
: "Failed to create credential type.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
updateCredentialType: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
const name = (formData.get("name") as string)?.trim();
const permissionScope = (formData.get("permissionScope") as string)?.trim();
const icon = (formData.get("icon") as string)?.trim() || undefined;
const fieldsJson = (formData.get("fields") as string)?.trim();
if (!id || !name || !permissionScope) {
return fail(400, { message: "Required fields are missing." });
}
let fields:
| Array<
Omit<
import("$lib/optima-api/modules/credentialTypes").CredentialTypeField,
"id"
>
>
| undefined;
if (fieldsJson) {
try {
fields = JSON.parse(fieldsJson);
} catch {
return fail(400, { message: "Invalid fields data." });
}
}
try {
await optima.credentialType.update(accessToken, id, {
name,
permissionScope,
icon,
fields: fields as any,
});
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error
? err.message
: "Failed to update credential type.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
deleteCredentialType: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
if (!id) {
return fail(400, { message: "Credential type ID is required." });
}
try {
await optima.credentialType.delete(accessToken, id);
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error
? err.message
: "Failed to delete credential type.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
};
@@ -0,0 +1,682 @@
<script lang="ts">
import type { SubmitFunction } from "@sveltejs/kit";
import type { PermissionMap } from "$lib/permissions";
import type {
CredentialType,
CredentialTypeField,
} from "$lib/optima-api/modules/credentialTypes";
import { formatDate } from "$lib/utils";
import { positionMenu } from "$lib/actions";
import CreateCredentialTypeModal from "../../../components/CreateCredentialTypeModal.svelte";
import AccessDenied from "../../../components/AccessDenied.svelte";
import DeleteConfirmDialog from "../../../components/DeleteConfirmDialog.svelte";
import "../../../styles/admin/credential-types.css";
export let data: {
permissions: PermissionMap;
credentialTypes: CredentialType[];
valueTypes: string[];
};
$: hasAccess = data.permissions["admin.credential-types.view"] === true;
$: canCreate = data.permissions["admin.credential-types.create"] === true;
$: canEdit = data.permissions["admin.credential-types.edit"] === true;
$: canDelete = data.permissions["admin.credential-types.delete"] === true;
$: credentialTypes = data.credentialTypes;
$: valueTypes = data.valueTypes ?? [];
// Search / filter
let searchQuery = "";
$: filteredTypes = credentialTypes.filter((ct) => {
if (!searchQuery.trim()) return true;
const q = searchQuery.toLowerCase();
return (
ct.name.toLowerCase().includes(q) ||
ct.permissionScope.toLowerCase().includes(q)
);
});
// Create / edit modal state
let isCreateModalOpen = false;
let typeToEdit: CredentialType | null = null;
function openEdit(ct: CredentialType) {
typeToEdit = ct;
isCreateModalOpen = true;
openMenuId = null;
}
// Three-dot menu
let openMenuId: string | null = null;
function toggleMenu(id: string) {
openMenuId = openMenuId === id ? null : id;
}
// Delete confirmation
let typeToDelete: CredentialType | null = null;
let isDeleting = false;
let deleteError = "";
function openDeleteConfirm(ct: CredentialType) {
typeToDelete = ct;
openMenuId = null;
}
function cancelDelete() {
typeToDelete = null;
deleteError = "";
}
const handleDeleteEnhance: SubmitFunction = () => {
isDeleting = true;
deleteError = "";
return async ({ result, update }) => {
isDeleting = false;
if (result.type === "success") {
typeToDelete = null;
} else if (result.type === "failure") {
deleteError =
(result.data as { message?: string })?.message ??
"Failed to delete credential type.";
}
await update();
};
};
// Expanded row state
let expandedTypeId: string | null = null;
function toggleType(id: string) {
expandedTypeId = expandedTypeId === id ? null : id;
}
function valueTypeLabel(vt: string): string {
return vt
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
</script>
<svelte:window on:click={() => (openMenuId = null)} />
{#if !hasAccess}
<AccessDenied
message="You don't have permission to manage credential types. Contact your administrator to request access."
/>
{:else if credentialTypes.length === 0}
<CreateCredentialTypeModal
isOpen={isCreateModalOpen}
{typeToEdit}
{valueTypes}
onClose={() => {
isCreateModalOpen = false;
typeToEdit = null;
}}
onSuccess={() => {
isCreateModalOpen = false;
typeToEdit = null;
}}
/>
<div class="admin-tab-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<h3>No Credential Types Found</h3>
<p>
There are no credential types configured yet. Create your first credential
type to get started.
</p>
{#if canCreate}
<button
type="button"
class="create-ct-btn"
on:click={() => (isCreateModalOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Create Credential Type
</button>
{/if}
</div>
{:else}
<CreateCredentialTypeModal
isOpen={isCreateModalOpen}
{typeToEdit}
{valueTypes}
onClose={() => {
isCreateModalOpen = false;
typeToEdit = null;
}}
onSuccess={() => {
isCreateModalOpen = false;
typeToEdit = null;
}}
/>
<DeleteConfirmDialog
isOpen={!!typeToDelete}
title="Delete Credential Type"
idValue={typeToDelete?.id ?? ""}
formAction="?/deleteCredentialType"
confirmLabel="Delete"
{isDeleting}
error={deleteError}
onCancel={cancelDelete}
handleEnhance={handleDeleteEnhance}
>
Are you sure you want to delete <strong>{typeToDelete?.name}</strong>?
{#if typeToDelete && typeToDelete.credentialCount > 0}
This type has <strong>{typeToDelete.credentialCount}</strong>
credential{typeToDelete.credentialCount === 1 ? "" : "s"} associated with it.
{/if}
This action cannot be undone.
</DeleteConfirmDialog>
<div class="admin-table-header">
<h3>
Credential Types
<span class="result-count"
>{filteredTypes.length} type{filteredTypes.length === 1
? ""
: "s"}{#if searchQuery.trim()}&nbsp;(filtered){/if}</span
>
</h3>
<div style="display:flex;align-items:center;gap:10px;">
<div class="ct-search-wrap">
<svg
class="ct-search-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
class="ct-search-input"
placeholder="Search types…"
bind:value={searchQuery}
/>
</div>
{#if canCreate}
<button
type="button"
class="create-ct-btn"
on:click={() => (isCreateModalOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Create Type
</button>
{/if}
</div>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Permission Scope</th>
<th>Fields</th>
<th>Credentials</th>
<th>Created</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody>
{#each filteredTypes as ct (ct.id)}
<tr
class="ct-row"
class:expanded={expandedTypeId === ct.id}
on:click={() => toggleType(ct.id)}
>
<td>
<div class="ct-name-cell">
<svg
class="ct-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<span class="ct-name">{ct.name}</span>
</div>
</td>
<td>
<span class="ct-scope">{ct.permissionScope}</span>
</td>
<td>
<span class="ct-field-count">
{ct.fields.length} field{ct.fields.length === 1 ? "" : "s"}
</span>
</td>
<td>
<span class="ct-cred-count">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"
/>
<line x1="8" y1="12" x2="16" y2="12" />
</svg>
{ct.credentialCount} credential{ct.credentialCount === 1
? ""
: "s"}
</span>
</td>
<td>{formatDate(ct.createdAt)}</td>
<td>{formatDate(ct.updatedAt)}</td>
<td class="row-end-cell">
<div class="row-end-content">
{#if canEdit || canDelete}
<div class="menu-wrap">
<button
type="button"
class="menu-btn"
aria-label="Credential type actions"
on:click|stopPropagation={() => toggleMenu(ct.id)}
>
<svg
viewBox="0 0 16 16"
fill="currentColor"
width="14"
height="14"
>
<circle cx="8" cy="2.5" r="1.5" />
<circle cx="8" cy="8" r="1.5" />
<circle cx="8" cy="13.5" r="1.5" />
</svg>
</button>
{#if openMenuId === ct.id}
<div class="ct-menu" use:positionMenu>
{#if canEdit}
<button
type="button"
class="ct-menu-item"
on:click|stopPropagation={() => openEdit(ct)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
/>
<path
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
/>
</svg>
Edit
</button>
{/if}
{#if canEdit && canDelete}
<div class="ct-menu-sep"></div>
{/if}
{#if canDelete}
<button
type="button"
class="ct-menu-item ct-menu-item-danger"
on:click|stopPropagation={() =>
openDeleteConfirm(ct)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"
/>
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
Delete
</button>
{/if}
</div>
{/if}
</div>
{/if}
<svg
class="row-chevron"
class:open={expandedTypeId === ct.id}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M6 9l6 6 6-6" />
</svg>
</div>
</td>
</tr>
{#if expandedTypeId === ct.id}
<tr class="ct-detail-row">
<td colspan="7">
<div class="ct-detail-content">
<div class="ct-detail-grid">
<!-- Info section -->
<div class="ct-detail-section">
<h4 class="ct-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
Details
</h4>
<div class="ct-detail-fields">
<div class="ct-detail-field">
<span class="detail-label">ID</span>
<span class="detail-value detail-mono">{ct.id}</span>
</div>
<div class="ct-detail-field">
<span class="detail-label">Permission Scope</span>
<span class="detail-value detail-mono"
>{ct.permissionScope}</span
>
</div>
{#if ct.icon}
<div class="ct-detail-field">
<span class="detail-label">Icon</span>
<span class="detail-value">{ct.icon}</span>
</div>
{/if}
<div class="ct-detail-field">
<span class="detail-label">Credentials</span>
<span class="detail-value"
>{ct.credentialCount} credential{ct.credentialCount ===
1
? ""
: "s"}</span
>
</div>
<div class="ct-detail-field">
<span class="detail-label">Created</span>
<span class="detail-value"
>{formatDate(ct.createdAt)}</span
>
</div>
<div class="ct-detail-field">
<span class="detail-label">Updated</span>
<span class="detail-value"
>{formatDate(ct.updatedAt)}</span
>
</div>
</div>
</div>
<!-- Fields section -->
<div class="ct-detail-section">
<h4 class="ct-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"
/>
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
Fields
<span class="detail-count">{ct.fields.length}</span>
</h4>
{#if ct.fields.length === 0}
<p class="ct-detail-empty">No fields defined</p>
{:else}
<div class="ct-field-list">
{#each ct.fields as field (field.id)}
<div class="ct-field-card">
<div class="ct-field-icon">
{#if field.valueType === "multi_credential"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<rect
x="2"
y="3"
width="20"
height="6"
rx="1"
/>
<rect
x="2"
y="15"
width="20"
height="6"
rx="1"
/>
<path d="M12 9v6" />
</svg>
{:else if field.secure}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
ry="2"
/>
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<polyline points="4 7 4 4 20 4 20 7" />
<line x1="9" y1="20" x2="15" y2="20" />
<line x1="12" y1="4" x2="12" y2="20" />
</svg>
{/if}
</div>
<div class="ct-field-info">
<span class="ct-field-name">{field.name}</span>
<div class="ct-field-meta">
<span>{valueTypeLabel(field.valueType)}</span>
{#if field.required}
<span class="ct-field-badge required"
>Required</span
>
{/if}
{#if field.secure}
<span class="ct-field-badge secure"
>Secure</span
>
{/if}
</div>
</div>
</div>
{#if field.valueType === "multi_credential" && field.subFields && field.subFields.length > 0}
<div class="ct-subfield-group">
<div class="ct-subfield-connector"></div>
<div class="ct-subfield-list">
{#each field.subFields as subField (subField.id)}
<div class="ct-field-card ct-subfield-card">
<div
class="ct-field-icon ct-subfield-icon"
>
{#if subField.secure}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
ry="2"
/>
<path
d="M7 11V7a5 5 0 0 1 10 0v4"
/>
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<polyline
points="4 7 4 4 20 4 20 7"
/>
<line
x1="9"
y1="20"
x2="15"
y2="20"
/>
<line
x1="12"
y1="4"
x2="12"
y2="20"
/>
</svg>
{/if}
</div>
<div class="ct-field-info">
<span class="ct-field-name"
>{subField.name}</span
>
<div class="ct-field-meta">
<span
>{valueTypeLabel(
subField.valueType,
)}</span
>
{#if subField.required}
<span
class="ct-field-badge required"
>Required</span
>
{/if}
{#if subField.secure}
<span class="ct-field-badge secure"
>Secure</span
>
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{#if filteredTypes.length === 0 && searchQuery.trim()}
<div class="admin-tab-empty">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<h3>No Results</h3>
<p>
No credential types match &ldquo;{searchQuery}&rdquo;. Try a different
search.
</p>
</div>
{/if}
{/if}
@@ -0,0 +1,183 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockOptima, mockCheckPermissions, mockHandleApiError, mockFail } =
vi.hoisted(() => ({
mockOptima: {
credentialType: {
fetchMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
credential: { fetchValueTypes: vi.fn() },
},
mockCheckPermissions: vi.fn(),
mockHandleApiError: vi.fn(),
mockFail: vi.fn((status: number, data: any) => ({
status,
data,
})),
}));
vi.mock("$lib", () => ({ optima: mockOptima }));
vi.mock("$lib/permissions", () => ({
checkPermissions: mockCheckPermissions,
}));
vi.mock("$lib/optima-api/errorHandler", () => ({
handleApiError: mockHandleApiError,
}));
vi.mock("@sveltejs/kit", () => ({
fail: mockFail,
}));
import { load, actions } from "./+page.server";
describe("admin/credential-types +page.server.ts", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
});
describe("load", () => {
it("returns empty data when no token", async () => {
const result = await load({ locals: {} } as any);
expect(result).toEqual({
credentialTypes: [],
permissions: {},
valueTypes: [],
});
});
it("loads credential types with permissions and value types", async () => {
mockOptima.credentialType.fetchMany.mockResolvedValueOnce({
data: [{ id: "ct1", name: "SSH Key" }],
});
mockCheckPermissions.mockResolvedValueOnce({
"admin.credential-types.view": true,
});
mockOptima.credential.fetchValueTypes.mockResolvedValueOnce({
data: ["text", "password"],
});
const result = await load({
locals: { session: { accessToken: "tok" } },
} as any);
expect(result).toMatchObject({
credentialTypes: [{ id: "ct1", name: "SSH Key" }],
valueTypes: ["text", "password"],
});
});
});
describe("actions", () => {
function createFormData(entries: Record<string, string>) {
return {
get: (key: string) => entries[key] ?? null,
getAll: (key: string) => (entries[key] ? [entries[key]] : []),
};
}
describe("createCredentialType", () => {
it("returns 401 when no token", async () => {
await actions.createCredentialType({
locals: {},
request: {
formData: vi.fn().mockResolvedValue(createFormData({})),
},
} as any);
expect(mockFail).toHaveBeenCalledWith(401, {
message: "Not authenticated.",
});
});
it("returns 400 when required fields missing", async () => {
await actions.createCredentialType({
locals: { session: { accessToken: "tok" } },
request: {
formData: vi
.fn()
.mockResolvedValue(createFormData({ name: "SSH" })),
},
} as any);
expect(mockFail).toHaveBeenCalledWith(400, {
message: "Name and permission scope are required.",
});
});
it("creates credential type successfully", async () => {
mockOptima.credentialType.create.mockResolvedValueOnce({});
const result = await actions.createCredentialType({
locals: { session: { accessToken: "tok" } },
request: {
formData: vi.fn().mockResolvedValue(
createFormData({
name: "SSH Key",
permissionScope: "ssh",
fields: '[{"name":"key","type":"text"}]',
}),
),
},
} as any);
expect(mockOptima.credentialType.create).toHaveBeenCalledWith("tok", {
name: "SSH Key",
permissionScope: "ssh",
icon: undefined,
fields: [{ name: "key", type: "text" }],
});
expect(result).toEqual({});
});
it("returns 400 for invalid fields JSON", async () => {
await actions.createCredentialType({
locals: { session: { accessToken: "tok" } },
request: {
formData: vi.fn().mockResolvedValue(
createFormData({
name: "SSH Key",
permissionScope: "ssh",
fields: "bad json",
}),
),
},
} as any);
expect(mockFail).toHaveBeenCalledWith(400, {
message: "Invalid fields data.",
});
});
});
describe("deleteCredentialType", () => {
it("returns 400 when id missing", async () => {
await actions.deleteCredentialType({
locals: { session: { accessToken: "tok" } },
request: {
formData: vi.fn().mockResolvedValue(createFormData({})),
},
} as any);
expect(mockFail).toHaveBeenCalledWith(400, {
message: "Credential type ID is required.",
});
});
it("deletes credential type", async () => {
mockOptima.credentialType.delete.mockResolvedValueOnce({});
const result = await actions.deleteCredentialType({
locals: { session: { accessToken: "tok" } },
request: {
formData: vi.fn().mockResolvedValue(createFormData({ id: "ct1" })),
},
} as any);
expect(mockOptima.credentialType.delete).toHaveBeenCalledWith(
"tok",
"ct1",
);
expect(result).toEqual({});
});
});
});
});
+77
View File
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockOptima, mockCheckPermissions, mockHandleApiError, mockRedirect } =
vi.hoisted(() => ({
mockOptima: {
user: { fetchInfo: vi.fn() },
},
mockCheckPermissions: vi.fn(),
mockHandleApiError: vi.fn(),
mockRedirect: vi.fn((status: number, location: string) => {
throw { status, location };
}),
}));
vi.mock("$lib", () => ({ optima: mockOptima }));
vi.mock("$lib/permissions", () => ({
checkPermissions: mockCheckPermissions,
}));
vi.mock("$lib/optima-api/errorHandler", () => ({
handleApiError: mockHandleApiError,
}));
vi.mock("@sveltejs/kit", () => ({
redirect: mockRedirect,
}));
import { load } from "./+layout.server";
describe("admin +layout.server.ts load", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("redirects to /login when no access token", async () => {
await expect(
load({
locals: {},
parent: vi.fn().mockResolvedValue({}),
} as any),
).rejects.toEqual(
expect.objectContaining({ status: 303, location: "/login" }),
);
});
it("redirects to / when canViewAdmin is false", async () => {
await expect(
load({
locals: { session: { accessToken: "tok" } },
parent: vi.fn().mockResolvedValue({ canViewAdmin: false }),
} as any),
).rejects.toEqual(expect.objectContaining({ status: 303, location: "/" }));
});
it("returns user and permissions when authorized", async () => {
mockCheckPermissions.mockResolvedValueOnce({
"admin.users.view": true,
"admin.roles.view": true,
"admin.credential-types.view": true,
"ui.navigation.reports.view": true,
});
mockOptima.user.fetchInfo.mockResolvedValueOnce({
data: { id: "u1", name: "Admin" },
});
const result = await load({
locals: { session: { accessToken: "tok" } },
parent: vi.fn().mockResolvedValue({ canViewAdmin: true }),
} as any);
expect(result).toMatchObject({
user: { id: "u1", name: "Admin" },
permissions: expect.objectContaining({
"ui.navigation.admin.view": true,
"admin.users.view": true,
}),
});
});
});
+50
View File
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockOptima, mockHandleApiError } = vi.hoisted(() => ({
mockOptima: {
company: { count: vi.fn() },
},
mockHandleApiError: vi.fn(),
}));
vi.mock("$lib", () => ({ optima: mockOptima }));
vi.mock("$lib/optima-api/errorHandler", () => ({
handleApiError: mockHandleApiError,
}));
import { load } from "./+page.server";
describe("admin +page.server.ts load", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns null companyCount when no token", async () => {
const result = await load({
locals: {},
} as any);
expect(result).toEqual({ companyCount: null });
});
it("returns company count on success", async () => {
mockOptima.company.count.mockResolvedValueOnce(42);
const result = await load({
locals: { session: { accessToken: "tok" } },
} as any);
expect(result).toEqual({ companyCount: 42 });
});
it("calls handleApiError on failure", async () => {
const err = new Error("fail");
mockOptima.company.count.mockRejectedValueOnce(err);
await load({
locals: { session: { accessToken: "tok" } },
} as any);
expect(mockHandleApiError).toHaveBeenCalledWith(err);
});
});
@@ -0,0 +1,22 @@
import { checkPermissions } from "$lib/permissions";
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals, parent }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
throw redirect(303, "/login");
}
const parentData = await parent();
const permissions = parentData?.permissions ?? {};
// Gate access to reports
if (permissions["ui.navigation.reports.view"] === false) {
throw redirect(303, "/admin");
}
return {
accessToken,
};
};
+244
View File
@@ -0,0 +1,244 @@
<script lang="ts">
const reportTabs = [
{
id: "internal-review",
label: "Internal Review",
icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4",
description:
"View opportunities currently in internal review, with reviewer assignments and time in review.",
},
{
id: "loss",
label: "Loss Report",
icon: "M13 17h8m0 0V9m0 8l-8-8-4 4-6-6",
description:
"Analyze lost opportunities by reason, rep, time period, and revenue impact.",
},
{
id: "cancelled",
label: "Cancelled",
icon: "M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636",
description:
"Track cancelled opportunities with cancellation reasons and patterns.",
},
{
id: "won",
label: "Won Report",
icon: "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z",
description:
"Review won opportunities, win rates, revenue captured, and sales cycle metrics.",
},
{
id: "follow-up-lateness",
label: "Follow-Up Lateness",
icon: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z",
description:
"Monitor overdue follow-up activities, stale opportunities, and SLA compliance.",
},
] as const;
type ReportTab = (typeof reportTabs)[number]["id"];
let activeReport: ReportTab = "internal-review";
</script>
<div class="reports-page">
<div class="reports-header">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<h3 class="reports-title">Workflow Reports</h3>
</div>
<!-- Report sub-tabs -->
<div class="reports-subtabs" role="tablist">
{#each reportTabs as tab}
<button
class="reports-subtab"
class:active={activeReport === tab.id}
role="tab"
aria-selected={activeReport === tab.id}
on:click={() => (activeReport = tab.id)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d={tab.icon} />
</svg>
{tab.label}
</button>
{/each}
</div>
<!-- Report content -->
<div class="reports-content">
{#each reportTabs as tab}
{#if activeReport === tab.id}
<div class="report-panel">
<div class="report-placeholder">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="48"
height="48"
class="report-placeholder-icon"
>
<path d={tab.icon} />
</svg>
<h4 class="report-placeholder-title">{tab.label}</h4>
<p class="report-placeholder-desc">{tab.description}</p>
<p class="report-placeholder-note">
Report endpoints are being built. This view will populate
automatically when the API is ready.
</p>
</div>
</div>
{/if}
{/each}
</div>
</div>
<style>
.reports-page {
padding: 0;
}
.reports-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: var(--text-primary);
}
.reports-title {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.reports-subtabs {
display: flex;
gap: 4px;
padding: 4px;
background: var(--nav-hover-bg);
border-radius: 8px;
margin-bottom: 20px;
overflow-x: auto;
}
.reports-subtab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border: none;
border-radius: 6px;
background: transparent;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
transition:
background 0.15s,
color 0.15s;
}
.reports-subtab:hover {
background: var(--nav-active-bg);
color: var(--text-primary);
}
.reports-subtab.active {
background: var(--card-bg);
color: var(--text-primary);
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.reports-content {
min-height: 300px;
}
.report-panel {
animation: reportFadeIn 0.2s ease;
}
@keyframes reportFadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.report-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.report-placeholder-icon {
color: var(--text-muted);
opacity: 0.5;
margin-bottom: 16px;
}
.report-placeholder-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 8px;
}
.report-placeholder-desc {
font-size: 13px;
color: var(--text-secondary);
margin: 0 0 16px;
max-width: 400px;
line-height: 1.5;
}
.report-placeholder-note {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
margin: 0;
padding: 8px 16px;
background: var(--bg-inset);
border-radius: 6px;
}
@media (max-width: 768px) {
.reports-subtabs {
flex-wrap: nowrap;
}
.reports-subtab {
font-size: 11px;
padding: 6px 10px;
}
}
</style>
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockCheckPermissions, mockRedirect } = vi.hoisted(() => ({
mockCheckPermissions: vi.fn(),
mockRedirect: vi.fn((status: number, location: string) => {
throw { status, location };
}),
}));
vi.mock("$lib/permissions", () => ({
checkPermissions: mockCheckPermissions,
}));
vi.mock("@sveltejs/kit", () => ({
redirect: mockRedirect,
}));
import { load } from "./+page.server";
describe("admin/reports +page.server.ts load", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("redirects to /login when no access token", async () => {
await expect(
load({
locals: {},
parent: vi.fn().mockResolvedValue({}),
} as any),
).rejects.toEqual(
expect.objectContaining({ status: 303, location: "/login" }),
);
});
it("redirects to /admin when reports permission is false", async () => {
await expect(
load({
locals: { session: { accessToken: "tok" } },
parent: vi.fn().mockResolvedValue({
permissions: { "ui.navigation.reports.view": false },
}),
} as any),
).rejects.toEqual(
expect.objectContaining({ status: 303, location: "/admin" }),
);
});
it("returns accessToken when permitted", async () => {
const result = await load({
locals: { session: { accessToken: "tok" } },
parent: vi.fn().mockResolvedValue({
permissions: { "ui.navigation.reports.view": true },
}),
} as any);
expect(result).toEqual({ accessToken: "tok" });
});
});
+148
View File
@@ -0,0 +1,148 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions } from "$lib/permissions";
import { fail } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import { AxiosError } from "axios";
export const load: PageServerLoad = async ({ locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return { roles: [], permissions: {}, permissionNodes: {} };
}
try {
const [rolesResult, permissions, permNodesResult] = await Promise.all([
optima.role.fetchMany(accessToken),
checkPermissions(accessToken, [
"admin.roles.view",
"admin.roles.create",
"admin.roles.edit",
"admin.roles.delete",
]),
optima.permission
.fetchCategorized(accessToken)
.catch(() => ({ data: {} })),
]);
const roles = rolesResult?.data ?? [];
// Fetch users for each role in parallel
const rolesWithUsers = await Promise.all(
roles.map(async (role: Record<string, unknown>) => {
try {
const usersResult = await optima.role.fetchUsers(
accessToken,
role.id as string,
);
return { ...role, users: usersResult?.data ?? [] };
} catch {
return { ...role, users: [] };
}
}),
);
return {
roles: rolesWithUsers,
permissions,
permissionNodes: permNodesResult?.data ?? {},
};
} catch (err) {
handleApiError(err);
}
};
export const actions: Actions = {
createRole: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const title = (formData.get("title") as string)?.trim();
const moniker = (formData.get("moniker") as string)?.trim();
const permissions = formData.getAll("permissions") as string[];
if (!title || !moniker) {
return fail(400, { message: "Title and moniker are required." });
}
try {
await optima.role.create(accessToken, { title, moniker, permissions });
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error ? err.message : "Failed to create role.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
updateRole: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
const title = (formData.get("title") as string)?.trim();
const moniker = (formData.get("moniker") as string)?.trim();
const permissions = formData.getAll("permissions") as string[];
if (!id || !title || !moniker) {
return fail(400, { message: "Required fields are missing." });
}
try {
await optima.role.update(accessToken, id, {
title,
moniker,
permissions,
});
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error ? err.message : "Failed to update role.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
deleteRole: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
if (!id) {
return fail(400, { message: "Role ID is required." });
}
try {
await optima.role.delete(accessToken, id);
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error ? err.message : "Failed to delete role.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
};
+463
View File
@@ -0,0 +1,463 @@
<script lang="ts">
import type { SubmitFunction } from "@sveltejs/kit";
import type { PermissionMap } from "$lib/permissions";
import type { Role } from "$lib/optima-api/modules/roles";
import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions";
import { formatDate } from "$lib/utils";
import { positionMenu } from "$lib/actions";
import CreateRoleModal from "../../../components/CreateRoleModal.svelte";
import AccessDenied from "../../../components/AccessDenied.svelte";
import DeleteConfirmDialog from "../../../components/DeleteConfirmDialog.svelte";
import "../../../styles/admin/roles.css";
interface RoleUser {
id: string;
name: string;
login: string;
roles: string[];
createdAt: string;
updatedAt: string;
}
type RoleWithUsers = Role & { users: RoleUser[] };
export let data: {
permissions: PermissionMap;
roles: RoleWithUsers[];
permissionNodes: PermissionsCategorized;
};
$: hasAccess = data.permissions["admin.roles.view"] === true;
$: canCreate = data.permissions["admin.roles.create"] === true;
$: canEdit = data.permissions["admin.roles.edit"] === true;
$: canDelete = data.permissions["admin.roles.delete"] === true;
$: roles = data.roles;
// Create/edit modal state
let isCreateModalOpen = false;
let roleToEdit: Role | null = null;
function openEdit(r: Role) {
roleToEdit = r;
isCreateModalOpen = true;
openMenuId = null;
}
// Three-dot menu
let openMenuId: string | null = null;
function toggleMenu(id: string) {
openMenuId = openMenuId === id ? null : id;
}
// Delete confirmation
let roleToDelete: Role | null = null;
let isDeleting = false;
let deleteError = "";
function openDeleteConfirm(r: Role) {
roleToDelete = r;
openMenuId = null;
}
function cancelDelete() {
roleToDelete = null;
deleteError = "";
}
const handleDeleteEnhance: SubmitFunction = () => {
isDeleting = true;
deleteError = "";
return async ({ result, update }) => {
isDeleting = false;
if (result.type === "success") {
roleToDelete = null;
} else if (result.type === "failure") {
deleteError =
(result.data as { message?: string })?.message ??
"Failed to delete role.";
}
await update();
};
};
// Expanded row state
let expandedRoleId: string | null = null;
function toggleRole(id: string) {
expandedRoleId = expandedRoleId === id ? null : id;
}
</script>
<svelte:window on:click={() => (openMenuId = null)} />
{#if !hasAccess}
<AccessDenied
message="You don't have permission to manage roles. Contact your administrator to request access."
/>
{:else if roles.length === 0}
<CreateRoleModal
isOpen={isCreateModalOpen}
permissionNodes={data.permissionNodes}
{roleToEdit}
onClose={() => {
isCreateModalOpen = false;
roleToEdit = null;
}}
onSuccess={() => {
isCreateModalOpen = false;
roleToEdit = null;
}}
/>
<div class="admin-tab-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
<h3>No Roles Found</h3>
<p>
There are no roles configured yet. Create your first role to get started.
</p>
{#if canCreate}
<button
type="button"
class="create-role-btn"
on:click={() => (isCreateModalOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Create Role
</button>
{/if}
</div>
{:else}
<CreateRoleModal
isOpen={isCreateModalOpen}
permissionNodes={data.permissionNodes}
{roleToEdit}
onClose={() => {
isCreateModalOpen = false;
roleToEdit = null;
}}
onSuccess={() => {
isCreateModalOpen = false;
roleToEdit = null;
}}
/>
<DeleteConfirmDialog
isOpen={!!roleToDelete}
title="Delete Role"
idValue={roleToDelete?.id ?? ""}
formAction="?/deleteRole"
confirmLabel="Delete Role"
{isDeleting}
error={deleteError}
onCancel={cancelDelete}
handleEnhance={handleDeleteEnhance}
>
Are you sure you want to delete <strong>{roleToDelete?.title}</strong>? This
action cannot be undone.
</DeleteConfirmDialog>
<div class="admin-table-header">
<h3>
Roles
<span class="result-count"
>{roles.length} role{roles.length === 1 ? "" : "s"}</span
>
</h3>
{#if canCreate}
<button
type="button"
class="create-role-btn"
on:click={() => (isCreateModalOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Create Role
</button>
{/if}
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Title</th>
<th>Moniker</th>
<th>Permissions</th>
<th>Users</th>
<th>Created</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody>
{#each roles as role (role.id)}
<tr
class="role-row"
class:expanded={expandedRoleId === role.id}
on:click={() => toggleRole(role.id)}
>
<td>
<div class="role-title-cell">
<svg
class="role-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
<span class="role-title">{role.title}</span>
</div>
</td>
<td>
<span class="role-moniker">{role.moniker}</span>
</td>
<td>
<span class="role-perm-count">
{role.permissions.length} permission{role.permissions.length ===
1
? ""
: "s"}
</span>
</td>
<td>
<span class="role-user-count">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87" />
<path d="M16 3.13a4 4 0 010 7.75" />
</svg>
{role.users.length} user{role.users.length === 1 ? "" : "s"}
</span>
</td>
<td>{formatDate(role.createdAt)}</td>
<td>{formatDate(role.updatedAt)}</td>
<td class="row-end-cell">
<div class="row-end-content">
{#if role.moniker === "administrator"}
<span class="system-badge">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="11"
height="11"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
System
</span>
{:else if canEdit || canDelete}
<div class="menu-wrap">
<button
type="button"
class="menu-btn"
aria-label="Role actions"
on:click|stopPropagation={() => toggleMenu(role.id)}
>
<svg
viewBox="0 0 16 16"
fill="currentColor"
width="14"
height="14"
>
<circle cx="8" cy="2.5" r="1.5" />
<circle cx="8" cy="8" r="1.5" />
<circle cx="8" cy="13.5" r="1.5" />
</svg>
</button>
{#if openMenuId === role.id}
<div class="role-menu" use:positionMenu>
{#if canEdit}
<button
type="button"
class="role-menu-item"
on:click|stopPropagation={() => openEdit(role)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
/>
<path
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
/>
</svg>
Edit
</button>
{/if}
{#if canEdit && canDelete}
<div class="role-menu-sep"></div>
{/if}
{#if canDelete}
<button
type="button"
class="role-menu-item role-menu-item-danger"
on:click|stopPropagation={() =>
openDeleteConfirm(role)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"
/>
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
Delete
</button>
{/if}
</div>
{/if}
</div>
{/if}
<svg
class="row-chevron"
class:open={expandedRoleId === role.id}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M6 9l6 6 6-6" />
</svg>
</div>
</td>
</tr>
{#if expandedRoleId === role.id}
<tr class="role-detail-row">
<td colspan="7">
<div class="role-detail-content">
<div class="role-detail-grid">
<div class="role-detail-section">
<h4 class="role-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"
/>
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
Permissions
<span class="detail-count"
>{role.permissions.length}</span
>
</h4>
{#if role.permissions.length === 0}
<p class="role-detail-empty">No permissions assigned</p>
{:else}
<div class="permission-tags">
{#each role.permissions as perm}
<span class="permission-tag">{perm}</span>
{/each}
</div>
{/if}
</div>
<div class="role-detail-section">
<h4 class="role-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87" />
<path d="M16 3.13a4 4 0 010 7.75" />
</svg>
Users
<span class="detail-count">{role.users.length}</span>
</h4>
{#if role.users.length === 0}
<p class="role-detail-empty">
No users assigned to this role
</p>
{:else}
<div class="user-list">
{#each role.users as user (user.id)}
<div class="user-card">
<div class="user-avatar">
{user.name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase()}
</div>
<div class="user-info">
<span class="user-name">{user.name}</span>
<span class="user-login">{user.login}</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
@@ -0,0 +1,176 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockOptima, mockCheckPermissions, mockHandleApiError, mockFail } =
vi.hoisted(() => ({
mockOptima: {
role: {
fetchMany: vi.fn(),
fetchUsers: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
permission: { fetchCategorized: vi.fn() },
},
mockCheckPermissions: vi.fn(),
mockHandleApiError: vi.fn(),
mockFail: vi.fn((status: number, data: any) => ({
status,
data,
})),
}));
vi.mock("$lib", () => ({ optima: mockOptima }));
vi.mock("$lib/permissions", () => ({
checkPermissions: mockCheckPermissions,
}));
vi.mock("$lib/optima-api/errorHandler", () => ({
handleApiError: mockHandleApiError,
}));
vi.mock("@sveltejs/kit", () => ({
fail: mockFail,
}));
import { load, actions } from "./+page.server";
describe("admin/roles +page.server.ts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("load", () => {
it("returns empty data when no token", async () => {
const result = await load({ locals: {} } as any);
expect(result).toEqual({
roles: [],
permissions: {},
permissionNodes: {},
});
});
it("loads roles with users and permissions", async () => {
mockOptima.role.fetchMany.mockResolvedValueOnce({
data: [{ id: "r1", title: "Admin" }],
});
mockCheckPermissions.mockResolvedValueOnce({
"admin.roles.view": true,
});
mockOptima.permission.fetchCategorized.mockResolvedValueOnce({
data: { category1: ["perm.a"] },
});
mockOptima.role.fetchUsers.mockResolvedValueOnce({
data: [{ id: "u1" }],
});
const result = await load({
locals: { session: { accessToken: "tok" } },
} as any);
expect(result).toMatchObject({
roles: [
expect.objectContaining({
id: "r1",
users: [{ id: "u1" }],
}),
],
permissionNodes: { category1: ["perm.a"] },
});
});
});
describe("actions", () => {
function createFormData(entries: Record<string, string | string[]>) {
const fd = new Map<string, string | string[]>();
return {
get: (key: string) => {
const val = entries[key];
return Array.isArray(val) ? val[0] : (val ?? null);
},
getAll: (key: string) => {
const val = entries[key];
return Array.isArray(val) ? val : val ? [val] : [];
},
};
}
describe("createRole", () => {
it("returns 401 when no token", async () => {
const result = await actions.createRole({
locals: {},
request: {
formData: vi.fn().mockResolvedValue(createFormData({})),
},
} as any);
expect(mockFail).toHaveBeenCalledWith(401, {
message: "Not authenticated.",
});
});
it("returns 400 when title is missing", async () => {
await actions.createRole({
locals: { session: { accessToken: "tok" } },
request: {
formData: vi
.fn()
.mockResolvedValue(createFormData({ moniker: "admin" })),
},
} as any);
expect(mockFail).toHaveBeenCalledWith(400, {
message: "Title and moniker are required.",
});
});
it("creates role successfully", async () => {
mockOptima.role.create.mockResolvedValueOnce({});
const result = await actions.createRole({
locals: { session: { accessToken: "tok" } },
request: {
formData: vi.fn().mockResolvedValue(
createFormData({
title: "Admin",
moniker: "admin",
permissions: ["perm.a", "perm.b"],
}),
),
},
} as any);
expect(mockOptima.role.create).toHaveBeenCalledWith("tok", {
title: "Admin",
moniker: "admin",
permissions: ["perm.a", "perm.b"],
});
expect(result).toEqual({});
});
});
describe("deleteRole", () => {
it("returns 400 when id is missing", async () => {
await actions.deleteRole({
locals: { session: { accessToken: "tok" } },
request: {
formData: vi.fn().mockResolvedValue(createFormData({})),
},
} as any);
expect(mockFail).toHaveBeenCalledWith(400, {
message: "Role ID is required.",
});
});
it("deletes role successfully", async () => {
mockOptima.role.delete.mockResolvedValueOnce({});
const result = await actions.deleteRole({
locals: { session: { accessToken: "tok" } },
request: {
formData: vi.fn().mockResolvedValue(createFormData({ id: "r1" })),
},
} as any);
expect(mockOptima.role.delete).toHaveBeenCalledWith("tok", "r1");
expect(result).toEqual({});
});
});
});
});
+143
View File
@@ -0,0 +1,143 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions } from "$lib/permissions";
import { fail } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import { AxiosError } from "axios";
export const load: PageServerLoad = async ({ locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return { users: [], roles: [], permissions: {} };
}
try {
const [usersResult, rolesResult, permissions, permNodesResult] =
await Promise.all([
optima.users.fetchAll(accessToken),
optima.role.fetchMany(accessToken),
checkPermissions(accessToken, [
"admin.users.view",
"admin.users.edit",
"admin.users.delete",
"user.roles.other",
"user.permissions.other",
]),
optima.permission
.fetchCategorized(accessToken)
.catch(() => ({ data: {} })),
]);
const allUsers = usersResult?.data ?? [];
const allRoles = rolesResult?.data ?? [];
// Fetch roles for each user in parallel
const usersWithRoles = await Promise.all(
allUsers.map(async (user) => {
try {
const rolesResult = await optima.users.fetchRoles(
accessToken,
user.id,
);
return { ...user, roleDetails: rolesResult?.data ?? [] };
} catch {
return { ...user, roleDetails: [] };
}
}),
);
return {
users: usersWithRoles,
roles: allRoles,
permissions,
permissionNodes: permNodesResult?.data ?? {},
};
} catch (err) {
handleApiError(err);
}
};
export const actions: Actions = {
updateUser: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
const name = (formData.get("name") as string)?.trim();
const image = (formData.get("image") as string)?.trim() || undefined;
const rolesJson = (formData.get("roles") as string)?.trim();
const permissionsJson = (formData.get("permissions") as string)?.trim();
if (!id || !name) {
return fail(400, { message: "User ID and name are required." });
}
const updates: {
name: string;
image?: string;
roles?: string[];
permissions?: string[];
} = { name, image };
if (rolesJson) {
try {
updates.roles = JSON.parse(rolesJson);
} catch {
return fail(400, { message: "Invalid roles data." });
}
}
if (permissionsJson) {
try {
updates.permissions = JSON.parse(permissionsJson);
} catch {
return fail(400, { message: "Invalid permissions data." });
}
}
try {
await optima.users.update(accessToken, id, updates);
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error ? err.message : "Failed to update user.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
deleteUser: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
if (!id) {
return fail(400, { message: "User ID is required." });
}
try {
await optima.users.delete(accessToken, id);
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error ? err.message : "Failed to delete user.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
};
+512
View File
@@ -0,0 +1,512 @@
<script lang="ts">
import type { SubmitFunction } from "@sveltejs/kit";
import EmailText from "../../../components/EmailText.svelte";
import type { PermissionMap } from "$lib/permissions";
import type { User } from "$lib/optima-api/modules/users";
import type { Role } from "$lib/optima-api/modules/roles";
import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions";
import { formatDate } from "$lib/utils";
import { positionMenu } from "$lib/actions";
import EditUserModal from "../../../components/EditUserModal.svelte";
import AccessDenied from "../../../components/AccessDenied.svelte";
import DeleteConfirmDialog from "../../../components/DeleteConfirmDialog.svelte";
import "../../../styles/admin/users.css";
type UserWithRoles = User & { roleDetails: Role[] };
export let data: {
permissions: PermissionMap;
users: UserWithRoles[];
roles: Role[];
permissionNodes: PermissionsCategorized;
};
$: hasAccess = data.permissions["admin.users.view"] === true;
$: canEdit = data.permissions["admin.users.edit"] === true;
$: canDelete = data.permissions["admin.users.delete"] === true;
$: canEditRoles = data.permissions["user.roles.other"] === true;
$: canEditPermissions = data.permissions["user.permissions.other"] === true;
$: users = data.users;
$: allRoles = data.roles;
// Search / filter
let searchQuery = "";
$: filteredUsers = users.filter((u) => {
if (!searchQuery.trim()) return true;
const q = searchQuery.toLowerCase();
return (
u.name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
u.login.toLowerCase().includes(q)
);
});
// Expanded row state
let expandedUserId: string | null = null;
function toggleUser(id: string) {
expandedUserId = expandedUserId === id ? null : id;
}
// Three-dot menu
let openMenuId: string | null = null;
function toggleMenu(id: string) {
openMenuId = openMenuId === id ? null : id;
}
// Edit modal state
let editingUser: UserWithRoles | null = null;
function openEdit(u: UserWithRoles) {
editingUser = u;
openMenuId = null;
}
function cancelEdit() {
editingUser = null;
}
// Delete confirmation
let userToDelete: UserWithRoles | null = null;
let isDeleting = false;
let deleteError = "";
function openDeleteConfirm(u: UserWithRoles) {
userToDelete = u;
openMenuId = null;
}
function cancelDelete() {
userToDelete = null;
deleteError = "";
}
const handleDeleteEnhance: SubmitFunction = () => {
isDeleting = true;
deleteError = "";
return async ({ result, update }) => {
isDeleting = false;
if (result.type === "success") {
userToDelete = null;
} else if (result.type === "failure") {
deleteError =
(result.data as { message?: string })?.message ??
"Failed to delete user.";
}
await update();
};
};
function initials(name: string): string {
return name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
</script>
<svelte:window on:click={() => (openMenuId = null)} />
{#if !hasAccess}
<AccessDenied
message="You don't have permission to manage users. Contact your administrator to request access."
/>
{:else if users.length === 0}
<div class="admin-tab-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<h3>No Users Found</h3>
<p>There are no users in the system yet.</p>
</div>
{:else}
<!-- Edit user modal -->
{#if editingUser}
<EditUserModal
user={editingUser}
{allRoles}
permissionNodes={data.permissionNodes}
{canEditRoles}
{canEditPermissions}
onClose={cancelEdit}
onSuccess={cancelEdit}
/>
{/if}
<!-- Delete confirmation dialog -->
<DeleteConfirmDialog
isOpen={!!userToDelete}
title="Delete User"
idValue={userToDelete?.id ?? ""}
formAction="?/deleteUser"
confirmLabel="Delete User"
{isDeleting}
error={deleteError}
onCancel={cancelDelete}
handleEnhance={handleDeleteEnhance}
>
Are you sure you want to delete <strong>{userToDelete?.name}</strong>? This
action cannot be undone.
</DeleteConfirmDialog>
<div class="admin-table-header">
<h3>
Users
<span class="result-count"
>{filteredUsers.length} user{filteredUsers.length === 1
? ""
: "s"}{#if searchQuery.trim()}
&nbsp;(filtered){/if}</span
>
</h3>
<div class="user-search-wrap">
<svg
class="user-search-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
class="user-search-input"
placeholder="Search users…"
bind:value={searchQuery}
/>
</div>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Login</th>
<th>Roles</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{#each filteredUsers as user (user.id)}
<tr
class="user-row"
class:expanded={expandedUserId === user.id}
on:click={() => toggleUser(user.id)}
>
<td>
<div class="user-name-cell">
{#if user.image}
<img
src={user.image}
alt={user.name}
class="user-table-avatar"
/>
{:else}
<div class="user-table-avatar user-table-avatar-initials">
{initials(user.name)}
</div>
{/if}
<span class="user-table-name">{user.name}</span>
</div>
</td>
<td>
<EmailText email={user.email} />
</td>
<td>
<span class="user-login-mono">{user.login}</span>
</td>
<td>
<span class="user-role-count">
{user.roleDetails.length} role{user.roleDetails.length === 1
? ""
: "s"}
</span>
</td>
<td>{formatDate(user.createdAt)}</td>
<td class="row-end-cell">
<div class="row-end-content">
{#if canEdit || canDelete}
<div class="menu-wrap">
<button
type="button"
class="menu-btn"
aria-label="User actions"
on:click|stopPropagation={() => toggleMenu(user.id)}
>
<svg
viewBox="0 0 16 16"
fill="currentColor"
width="14"
height="14"
>
<circle cx="8" cy="2.5" r="1.5" />
<circle cx="8" cy="8" r="1.5" />
<circle cx="8" cy="13.5" r="1.5" />
</svg>
</button>
{#if openMenuId === user.id}
<div class="user-menu" use:positionMenu>
{#if canEdit}
<button
type="button"
class="user-menu-item"
on:click|stopPropagation={() => openEdit(user)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
/>
<path
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
/>
</svg>
Edit
</button>
{/if}
{#if canEdit && canDelete}
<div class="user-menu-sep"></div>
{/if}
{#if canDelete}
<button
type="button"
class="user-menu-item user-menu-item-danger"
on:click|stopPropagation={() =>
openDeleteConfirm(user)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"
/>
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
Delete
</button>
{/if}
</div>
{/if}
</div>
{/if}
<svg
class="row-chevron"
class:open={expandedUserId === user.id}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M6 9l6 6 6-6" />
</svg>
</div>
</td>
</tr>
{#if expandedUserId === user.id}
<tr class="user-detail-row">
<td colspan="6">
<div class="user-detail-content">
<div class="user-detail-grid">
<!-- User info section -->
<div class="user-detail-section">
<h4 class="user-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
Details
</h4>
<div class="user-detail-fields">
<div class="user-detail-field">
<span class="detail-label">ID</span>
<span class="detail-value detail-mono">{user.id}</span
>
</div>
<div class="user-detail-field">
<span class="detail-label">Email</span>
<span class="detail-value">
<EmailText email={user.email} />
</span>
</div>
<div class="user-detail-field">
<span class="detail-label">Login</span>
<span class="detail-value detail-mono"
>{user.login}</span
>
</div>
<div class="user-detail-field">
<span class="detail-label">Created</span>
<span class="detail-value"
>{formatDate(user.createdAt)}</span
>
</div>
<div class="user-detail-field">
<span class="detail-label">Updated</span>
<span class="detail-value"
>{formatDate(user.updatedAt)}</span
>
</div>
</div>
</div>
<!-- Roles section -->
<div class="user-detail-section">
<h4 class="user-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"
/>
</svg>
Roles
<span class="detail-count"
>{user.roleDetails.length}</span
>
</h4>
{#if user.roleDetails.length === 0}
<p class="user-detail-empty">No roles assigned</p>
{:else}
<div class="user-role-list">
{#each user.roleDetails as role (role.id)}
<div class="user-role-card">
<div class="user-role-card-header">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"
/>
</svg>
<span class="user-role-title">{role.title}</span
>
<span class="user-role-moniker"
>{role.moniker}</span
>
</div>
{#if role.permissions.length > 0}
<div class="permission-tags">
{#each role.permissions.slice(0, 8) as perm}
<span class="permission-tag">{perm}</span>
{/each}
{#if role.permissions.length > 8}
<span
class="permission-tag permission-tag-more"
>+{role.permissions.length - 8} more</span
>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<!-- Additional Permissions section -->
<div class="user-detail-section">
<h4 class="user-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M9 12l2 2 4-4" />
<path
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"
/>
</svg>
Additional Permissions
{#if user.permissions?.length}
<span class="detail-count"
>{user.permissions.length}</span
>
{/if}
</h4>
{#if !user.permissions?.length}
<p class="user-detail-empty">
No additional permissions assigned
</p>
{:else}
<div class="permission-tags">
{#each user.permissions as perm}
<span class="permission-tag">{perm}</span>
{/each}
</div>
{/if}
</div>
</div>
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{#if filteredUsers.length === 0 && searchQuery.trim()}
<div class="admin-tab-empty">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<h3>No Results</h3>
<p>No users match &ldquo;{searchQuery}&rdquo;. Try a different search.</p>
</div>
{/if}
{/if}
@@ -0,0 +1,207 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockOptima, mockCheckPermissions, mockHandleApiError, mockFail } =
vi.hoisted(() => ({
mockOptima: {
users: {
fetchAll: vi.fn(),
fetchRoles: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
role: { fetchMany: vi.fn() },
permission: { fetchCategorized: vi.fn() },
},
mockCheckPermissions: vi.fn(),
mockHandleApiError: vi.fn(),
mockFail: vi.fn((status: number, data: any) => ({
status,
data,
})),
}));
vi.mock("$lib", () => ({ optima: mockOptima }));
vi.mock("$lib/permissions", () => ({
checkPermissions: mockCheckPermissions,
}));
vi.mock("$lib/optima-api/errorHandler", () => ({
handleApiError: mockHandleApiError,
}));
vi.mock("@sveltejs/kit", () => ({
fail: mockFail,
}));
import { load, actions } from "./+page.server";
describe("admin/users +page.server.ts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("load", () => {
it("returns empty data when no token", async () => {
const result = await load({ locals: {} } as any);
expect(result).toEqual({
users: [],
roles: [],
permissions: {},
});
});
it("loads users with roles", async () => {
mockOptima.users.fetchAll.mockResolvedValueOnce({
data: [{ id: "u1", name: "John" }],
});
mockOptima.role.fetchMany.mockResolvedValueOnce({
data: [{ id: "r1" }],
});
mockCheckPermissions.mockResolvedValueOnce({
"admin.users.view": true,
});
mockOptima.permission.fetchCategorized.mockResolvedValueOnce({
data: {},
});
mockOptima.users.fetchRoles.mockResolvedValueOnce({
data: [{ id: "r1" }],
});
const result = await load({
locals: { session: { accessToken: "tok" } },
} as any);
expect(result).toMatchObject({
users: [
expect.objectContaining({
id: "u1",
roleDetails: [{ id: "r1" }],
}),
],
roles: [{ id: "r1" }],
});
});
});
describe("actions", () => {
function createFormData(entries: Record<string, string>) {
return {
get: (key: string) => entries[key] ?? null,
getAll: (key: string) => (entries[key] ? [entries[key]] : []),
};
}
describe("updateUser", () => {
it("returns 401 when no token", async () => {
await actions.updateUser({
locals: {},
request: {
formData: vi.fn().mockResolvedValue(createFormData({})),
},
} as any);
expect(mockFail).toHaveBeenCalledWith(401, {
message: "Not authenticated.",
});
});
it("returns 400 when required fields are missing", async () => {
await actions.updateUser({
locals: { session: { accessToken: "tok" } },
request: {
formData: vi.fn().mockResolvedValue(createFormData({ id: "u1" })),
},
} as any);
expect(mockFail).toHaveBeenCalledWith(400, {
message: "User ID and name are required.",
});
});
it("updates user successfully", async () => {
mockOptima.users.update.mockResolvedValueOnce({});
const result = await actions.updateUser({
locals: { session: { accessToken: "tok" } },
request: {
formData: vi
.fn()
.mockResolvedValue(createFormData({ id: "u1", name: "Updated" })),
},
} as any);
expect(mockOptima.users.update).toHaveBeenCalledWith("tok", "u1", {
name: "Updated",
image: undefined,
});
expect(result).toEqual({});
});
it("parses roles JSON when provided", async () => {
mockOptima.users.update.mockResolvedValueOnce({});
await actions.updateUser({
locals: { session: { accessToken: "tok" } },
request: {
formData: vi.fn().mockResolvedValue(
createFormData({
id: "u1",
name: "Updated",
roles: '["r1","r2"]',
}),
),
},
} as any);
expect(mockOptima.users.update).toHaveBeenCalledWith("tok", "u1", {
name: "Updated",
image: undefined,
roles: ["r1", "r2"],
});
});
it("returns 400 for invalid roles JSON", async () => {
await actions.updateUser({
locals: { session: { accessToken: "tok" } },
request: {
formData: vi.fn().mockResolvedValue(
createFormData({
id: "u1",
name: "Updated",
roles: "bad json",
}),
),
},
} as any);
expect(mockFail).toHaveBeenCalledWith(400, {
message: "Invalid roles data.",
});
});
});
describe("deleteUser", () => {
it("returns 400 when id is missing", async () => {
await actions.deleteUser({
locals: { session: { accessToken: "tok" } },
request: {
formData: vi.fn().mockResolvedValue(createFormData({})),
},
} as any);
expect(mockFail).toHaveBeenCalledWith(400, {
message: "User ID is required.",
});
});
it("deletes user successfully", async () => {
mockOptima.users.delete.mockResolvedValueOnce({});
const result = await actions.deleteUser({
locals: { session: { accessToken: "tok" } },
request: {
formData: vi.fn().mockResolvedValue(createFormData({ id: "u1" })),
},
} as any);
expect(mockOptima.users.delete).toHaveBeenCalledWith("tok", "u1");
expect(result).toEqual({});
});
});
});
});