fix: remove nested .git folders, re-add as normal directories
This commit is contained in:
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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()} (filtered){/if}</span
|
||||
>
|
||||
</h3>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div class="ct-search-wrap">
|
||||
<svg
|
||||
class="ct-search-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
class="ct-search-input"
|
||||
placeholder="Search types…"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
{#if canCreate}
|
||||
<button
|
||||
type="button"
|
||||
class="create-ct-btn"
|
||||
on:click={() => (isCreateModalOpen = true)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
Create Type
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Permission Scope</th>
|
||||
<th>Fields</th>
|
||||
<th>Credentials</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filteredTypes as ct (ct.id)}
|
||||
<tr
|
||||
class="ct-row"
|
||||
class:expanded={expandedTypeId === ct.id}
|
||||
on:click={() => toggleType(ct.id)}
|
||||
>
|
||||
<td>
|
||||
<div class="ct-name-cell">
|
||||
<svg
|
||||
class="ct-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<span class="ct-name">{ct.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="ct-scope">{ct.permissionScope}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="ct-field-count">
|
||||
{ct.fields.length} field{ct.fields.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="ct-cred-count">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="13"
|
||||
height="13"
|
||||
>
|
||||
<path
|
||||
d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"
|
||||
/>
|
||||
<line x1="8" y1="12" x2="16" y2="12" />
|
||||
</svg>
|
||||
{ct.credentialCount} credential{ct.credentialCount === 1
|
||||
? ""
|
||||
: "s"}
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatDate(ct.createdAt)}</td>
|
||||
<td>{formatDate(ct.updatedAt)}</td>
|
||||
<td class="row-end-cell">
|
||||
<div class="row-end-content">
|
||||
{#if canEdit || canDelete}
|
||||
<div class="menu-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="menu-btn"
|
||||
aria-label="Credential type actions"
|
||||
on:click|stopPropagation={() => toggleMenu(ct.id)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<circle cx="8" cy="2.5" r="1.5" />
|
||||
<circle cx="8" cy="8" r="1.5" />
|
||||
<circle cx="8" cy="13.5" r="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if openMenuId === ct.id}
|
||||
<div class="ct-menu" use:positionMenu>
|
||||
{#if canEdit}
|
||||
<button
|
||||
type="button"
|
||||
class="ct-menu-item"
|
||||
on:click|stopPropagation={() => openEdit(ct)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="13"
|
||||
height="13"
|
||||
>
|
||||
<path
|
||||
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
|
||||
/>
|
||||
<path
|
||||
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
|
||||
/>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
{/if}
|
||||
{#if canEdit && canDelete}
|
||||
<div class="ct-menu-sep"></div>
|
||||
{/if}
|
||||
{#if canDelete}
|
||||
<button
|
||||
type="button"
|
||||
class="ct-menu-item ct-menu-item-danger"
|
||||
on:click|stopPropagation={() =>
|
||||
openDeleteConfirm(ct)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="13"
|
||||
height="13"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path
|
||||
d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"
|
||||
/>
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<svg
|
||||
class="row-chevron"
|
||||
class:open={expandedTypeId === ct.id}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{#if expandedTypeId === ct.id}
|
||||
<tr class="ct-detail-row">
|
||||
<td colspan="7">
|
||||
<div class="ct-detail-content">
|
||||
<div class="ct-detail-grid">
|
||||
<!-- Info section -->
|
||||
<div class="ct-detail-section">
|
||||
<h4 class="ct-detail-heading">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
Details
|
||||
</h4>
|
||||
<div class="ct-detail-fields">
|
||||
<div class="ct-detail-field">
|
||||
<span class="detail-label">ID</span>
|
||||
<span class="detail-value detail-mono">{ct.id}</span>
|
||||
</div>
|
||||
<div class="ct-detail-field">
|
||||
<span class="detail-label">Permission Scope</span>
|
||||
<span class="detail-value detail-mono"
|
||||
>{ct.permissionScope}</span
|
||||
>
|
||||
</div>
|
||||
{#if ct.icon}
|
||||
<div class="ct-detail-field">
|
||||
<span class="detail-label">Icon</span>
|
||||
<span class="detail-value">{ct.icon}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="ct-detail-field">
|
||||
<span class="detail-label">Credentials</span>
|
||||
<span class="detail-value"
|
||||
>{ct.credentialCount} credential{ct.credentialCount ===
|
||||
1
|
||||
? ""
|
||||
: "s"}</span
|
||||
>
|
||||
</div>
|
||||
<div class="ct-detail-field">
|
||||
<span class="detail-label">Created</span>
|
||||
<span class="detail-value"
|
||||
>{formatDate(ct.createdAt)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="ct-detail-field">
|
||||
<span class="detail-label">Updated</span>
|
||||
<span class="detail-value"
|
||||
>{formatDate(ct.updatedAt)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fields section -->
|
||||
<div class="ct-detail-section">
|
||||
<h4 class="ct-detail-heading">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<path
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"
|
||||
/>
|
||||
<rect x="9" y="3" width="6" height="4" rx="1" />
|
||||
</svg>
|
||||
Fields
|
||||
<span class="detail-count">{ct.fields.length}</span>
|
||||
</h4>
|
||||
{#if ct.fields.length === 0}
|
||||
<p class="ct-detail-empty">No fields defined</p>
|
||||
{:else}
|
||||
<div class="ct-field-list">
|
||||
{#each ct.fields as field (field.id)}
|
||||
<div class="ct-field-card">
|
||||
<div class="ct-field-icon">
|
||||
{#if field.valueType === "multi_credential"}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<rect
|
||||
x="2"
|
||||
y="3"
|
||||
width="20"
|
||||
height="6"
|
||||
rx="1"
|
||||
/>
|
||||
<rect
|
||||
x="2"
|
||||
y="15"
|
||||
width="20"
|
||||
height="6"
|
||||
rx="1"
|
||||
/>
|
||||
<path d="M12 9v6" />
|
||||
</svg>
|
||||
{:else if field.secure}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
ry="2"
|
||||
/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<polyline points="4 7 4 4 20 4 20 7" />
|
||||
<line x1="9" y1="20" x2="15" y2="20" />
|
||||
<line x1="12" y1="4" x2="12" y2="20" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="ct-field-info">
|
||||
<span class="ct-field-name">{field.name}</span>
|
||||
<div class="ct-field-meta">
|
||||
<span>{valueTypeLabel(field.valueType)}</span>
|
||||
{#if field.required}
|
||||
<span class="ct-field-badge required"
|
||||
>Required</span
|
||||
>
|
||||
{/if}
|
||||
{#if field.secure}
|
||||
<span class="ct-field-badge secure"
|
||||
>Secure</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if field.valueType === "multi_credential" && field.subFields && field.subFields.length > 0}
|
||||
<div class="ct-subfield-group">
|
||||
<div class="ct-subfield-connector"></div>
|
||||
<div class="ct-subfield-list">
|
||||
{#each field.subFields as subField (subField.id)}
|
||||
<div class="ct-field-card ct-subfield-card">
|
||||
<div
|
||||
class="ct-field-icon ct-subfield-icon"
|
||||
>
|
||||
{#if subField.secure}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="12"
|
||||
height="12"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
ry="2"
|
||||
/>
|
||||
<path
|
||||
d="M7 11V7a5 5 0 0 1 10 0v4"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="12"
|
||||
height="12"
|
||||
>
|
||||
<polyline
|
||||
points="4 7 4 4 20 4 20 7"
|
||||
/>
|
||||
<line
|
||||
x1="9"
|
||||
y1="20"
|
||||
x2="15"
|
||||
y2="20"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
y1="4"
|
||||
x2="12"
|
||||
y2="20"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="ct-field-info">
|
||||
<span class="ct-field-name"
|
||||
>{subField.name}</span
|
||||
>
|
||||
<div class="ct-field-meta">
|
||||
<span
|
||||
>{valueTypeLabel(
|
||||
subField.valueType,
|
||||
)}</span
|
||||
>
|
||||
{#if subField.required}
|
||||
<span
|
||||
class="ct-field-badge required"
|
||||
>Required</span
|
||||
>
|
||||
{/if}
|
||||
{#if subField.secure}
|
||||
<span class="ct-field-badge secure"
|
||||
>Secure</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if filteredTypes.length === 0 && searchQuery.trim()}
|
||||
<div class="admin-tab-empty">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<h3>No Results</h3>
|
||||
<p>
|
||||
No credential types match “{searchQuery}”. Try a different
|
||||
search.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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()}
|
||||
(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 “{searchQuery}”. 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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user