CREDENTIAL TYPE MANAGEMENT WORKS
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let message: string = "No results found";
|
||||||
|
export let size: number = 160;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="monkey" style="width: {size}px">
|
||||||
|
<svg viewBox="0 0 120 120" width="100%" height="100%" aria-hidden="true">
|
||||||
|
<!-- head -->
|
||||||
|
<circle cx="60" cy="60" r="44" fill="#8B5E3C" />
|
||||||
|
<!-- face -->
|
||||||
|
<ellipse cx="60" cy="70" rx="30" ry="24" fill="#E8C9A1" />
|
||||||
|
<!-- ears -->
|
||||||
|
<circle cx="26" cy="56" r="12" fill="#8B5E3C" />
|
||||||
|
<circle cx="94" cy="56" r="12" fill="#8B5E3C" />
|
||||||
|
<circle cx="26" cy="56" r="6" fill="#E8C9A1" />
|
||||||
|
<circle cx="94" cy="56" r="6" fill="#E8C9A1" />
|
||||||
|
<!-- eyes -->
|
||||||
|
<circle cx="48" cy="64" r="6" fill="#2b2b2b" />
|
||||||
|
<circle cx="72" cy="64" r="6" fill="#2b2b2b" />
|
||||||
|
<!-- smile -->
|
||||||
|
<path
|
||||||
|
d="M48 78 Q60 88 72 78"
|
||||||
|
stroke="#2b2b2b"
|
||||||
|
stroke-width="3"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<!-- eyebrow accents -->
|
||||||
|
<path
|
||||||
|
d="M42 58 Q48 54 54 58"
|
||||||
|
stroke="#6b4a33"
|
||||||
|
stroke-width="2"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
opacity="0.6"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M66 58 Q72 54 78 58"
|
||||||
|
stroke="#6b4a33"
|
||||||
|
stroke-width="2"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
opacity="0.6"
|
||||||
|
/>
|
||||||
|
<!-- tiny tuft -->
|
||||||
|
<path
|
||||||
|
d="M60 28 Q58 34 62 36"
|
||||||
|
stroke="#6b4a33"
|
||||||
|
stroke-width="3"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="msg">{message}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.monkey {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let size: number = 36;
|
||||||
|
export let color: string = "#0366d6";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rs" aria-hidden="true" style="width: {size}px; height: {size}px;">
|
||||||
|
<svg viewBox="0 0 50 50" class="spin" width="100%" height="100%">
|
||||||
|
<circle
|
||||||
|
class="bg"
|
||||||
|
cx="25"
|
||||||
|
cy="25"
|
||||||
|
r="20"
|
||||||
|
fill="none"
|
||||||
|
stroke="#e6eef6"
|
||||||
|
stroke-width="6"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
class="arc"
|
||||||
|
d="M25 5 a20 20 0 0 1 0 40"
|
||||||
|
stroke-linecap="round"
|
||||||
|
fill="none"
|
||||||
|
stroke-width="6"
|
||||||
|
stroke={color}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rs {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
transform-origin: center;
|
||||||
|
animation: rs-spin 0.9s linear infinite;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arc {
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rs-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+18
-6
@@ -2,22 +2,34 @@ import api from "./axios";
|
|||||||
|
|
||||||
export const company = {
|
export const company = {
|
||||||
async fetch(accessToken: string, id: string) {
|
async fetch(accessToken: string, id: string) {
|
||||||
const company = await api.get(`/v1/company/${id}`, {
|
const company = await api.get(`/v1/company/companies/${id}`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return company.data;
|
return company.data;
|
||||||
},
|
},
|
||||||
async fetchMany(accessToken: string, page: number = 1) {
|
async fetchMany(accessToken: string, page: number = 1, search?: string) {
|
||||||
const companies = await api.get("/v1/companies", {
|
const params: Record<string, unknown> = { page };
|
||||||
params: {
|
if (search && search.length > 0) params.search = search;
|
||||||
page,
|
|
||||||
},
|
const companies = await api.get("/v1/company/companies", {
|
||||||
|
params,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return companies.data;
|
return companies.data;
|
||||||
},
|
},
|
||||||
|
async fetchConfigurations(accessToken: string, id: string) {
|
||||||
|
const configurations = await api.get(
|
||||||
|
`/v1/company/companies/${id}/configurations`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return configurations.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import api from "./axios";
|
||||||
|
|
||||||
|
export interface CredentialTypeField {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
required: boolean;
|
||||||
|
secure: boolean;
|
||||||
|
valueType: "plain_text" | "password" | "number" | "email" | "url";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
permissionScope: string;
|
||||||
|
icon?: string;
|
||||||
|
fields: CredentialTypeField[];
|
||||||
|
credentialCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const credentialType = {
|
||||||
|
async fetchMany(accessToken: string) {
|
||||||
|
const response = await api.get("/v1/credential-type", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetch(accessToken: string, identifier: string) {
|
||||||
|
const response = await api.get(`/v1/credential-type/${identifier}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(
|
||||||
|
accessToken: string,
|
||||||
|
credentialType: Omit<
|
||||||
|
CredentialType,
|
||||||
|
"id" | "credentialCount" | "createdAt" | "updatedAt"
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
const response = await api.post("/v1/credential-type", credentialType, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(
|
||||||
|
accessToken: string,
|
||||||
|
id: string,
|
||||||
|
updates: Partial<
|
||||||
|
Omit<CredentialType, "id" | "credentialCount" | "createdAt" | "updatedAt">
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
const response = await api.patch(`/v1/credential-type/${id}`, updates, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(accessToken: string, id: string) {
|
||||||
|
const response = await api.delete(`/v1/credential-type/${id}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchCredentials(accessToken: string, id: string) {
|
||||||
|
const response = await api.get(`/v1/credential-type/${id}/credentials`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
export * from "./axios";
|
export * from "./axios";
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
export * from "./companies";
|
export * from "./companies";
|
||||||
|
export * from "./credentialTypes";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @TODO
|
* @TODO
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
function signOut() {
|
function signOut() {
|
||||||
// replace with your auth sign-out logic
|
|
||||||
goto("/logout");
|
goto("/logout");
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -18,6 +17,7 @@
|
|||||||
<a href="/projects">Projects</a>
|
<a href="/projects">Projects</a>
|
||||||
<a href="/settings">Settings</a>
|
<a href="/settings">Settings</a>
|
||||||
<a href="/profile">Profile</a>
|
<a href="/profile">Profile</a>
|
||||||
|
<a href="/admin">Admin</a>
|
||||||
<button on:click={signOut}>Sign out</button>
|
<button on:click={signOut}>Sign out</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { LayoutServerLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ params, parent }) => {
|
||||||
|
const { session } = await parent();
|
||||||
|
return {
|
||||||
|
accessToken: session.accessToken,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script>
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Admin Dashboard — App</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<header class="header container">
|
||||||
|
<h1>Admin Dashboard</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/admin/credential-types">Credential Types</a>
|
||||||
|
<button on:click={() => goto("/")}>Back</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<section class="hero">
|
||||||
|
<h2>Administration</h2>
|
||||||
|
<p>Manage system settings and configurations.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<article class="card">
|
||||||
|
<h3>Credential Types</h3>
|
||||||
|
<p>Create and manage credential type definitions.</p>
|
||||||
|
<button on:click={() => goto("/admin/credential-types")}>
|
||||||
|
Manage Credential Types
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
button {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: #0066cc;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: #0052a3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { PageServerLoad } from "./$types";
|
||||||
|
import { credentialType } from "$lib/credentialTypes";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, parent }) => {
|
||||||
|
const { session } = await parent();
|
||||||
|
const response = await credentialType.fetchMany(session.accessToken ?? "");
|
||||||
|
return {
|
||||||
|
credentialTypes: response.data || [],
|
||||||
|
accessToken: session.accessToken,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { credentialType } from "$lib/credentialTypes";
|
||||||
|
import LoadingSpinner from "$lib/../components/LoadingSpinner.svelte";
|
||||||
|
import ErrorBoundary from "$lib/../components/ErrorBoundary.svelte";
|
||||||
|
import type {
|
||||||
|
CredentialType,
|
||||||
|
CredentialTypeField,
|
||||||
|
} from "$lib/credentialTypes";
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
|
||||||
|
let credentialTypes: CredentialType[] = data.credentialTypes;
|
||||||
|
let isLoading = false;
|
||||||
|
let error: string | null = null;
|
||||||
|
let errorDetails: unknown = null;
|
||||||
|
let showForm = false;
|
||||||
|
let editingId: string | null = null;
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let formData = {
|
||||||
|
name: "",
|
||||||
|
permissionScope: "",
|
||||||
|
icon: "",
|
||||||
|
fields: [] as CredentialTypeField[],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FormField {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
required: boolean;
|
||||||
|
secure: boolean;
|
||||||
|
valueType: "plain_text" | "password" | "number" | "email" | "url";
|
||||||
|
}
|
||||||
|
|
||||||
|
let formFields: FormField[] = [];
|
||||||
|
|
||||||
|
function toSnakeCase(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/\s+/g, "_")
|
||||||
|
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFieldNameBlur(index: number) {
|
||||||
|
const field = formFields[index];
|
||||||
|
// Only auto-populate ID if it's empty or still the temporary field_* format
|
||||||
|
if (!field.id || field.id.startsWith("field_")) {
|
||||||
|
field.id = toSnakeCase(field.name);
|
||||||
|
formFields = formFields;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
formData = {
|
||||||
|
name: "",
|
||||||
|
permissionScope: "",
|
||||||
|
icon: "",
|
||||||
|
fields: [],
|
||||||
|
};
|
||||||
|
formFields = [];
|
||||||
|
editingId = null;
|
||||||
|
showForm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addField() {
|
||||||
|
formFields = [
|
||||||
|
...formFields,
|
||||||
|
{
|
||||||
|
id: "",
|
||||||
|
name: "",
|
||||||
|
required: false,
|
||||||
|
secure: false,
|
||||||
|
valueType: "plain_text",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeField(index: number) {
|
||||||
|
formFields = formFields.filter((_, i) => i !== index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openForm(ct?: CredentialType) {
|
||||||
|
if (ct) {
|
||||||
|
editingId = ct.id;
|
||||||
|
formData = {
|
||||||
|
name: ct.name,
|
||||||
|
permissionScope: ct.permissionScope,
|
||||||
|
icon: ct.icon || "",
|
||||||
|
fields: ct.fields,
|
||||||
|
};
|
||||||
|
formFields = ct.fields.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
name: f.name,
|
||||||
|
required: f.required,
|
||||||
|
secure: f.secure,
|
||||||
|
valueType: f.valueType,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
resetForm();
|
||||||
|
addField();
|
||||||
|
}
|
||||||
|
showForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (
|
||||||
|
!formData.name ||
|
||||||
|
!formData.permissionScope ||
|
||||||
|
formFields.length === 0
|
||||||
|
) {
|
||||||
|
error = "Please fill in all required fields";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all fields have names
|
||||||
|
if (formFields.some((f) => !f.name)) {
|
||||||
|
error = "All credential fields must have a name";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
errorDetails = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: formData.name,
|
||||||
|
permissionScope: formData.permissionScope,
|
||||||
|
icon: formData.icon || undefined,
|
||||||
|
fields: formFields.map((f) => ({
|
||||||
|
id: f.id.startsWith("field_") ? f.id.replace("field_", "") : f.id,
|
||||||
|
name: f.name,
|
||||||
|
required: f.required,
|
||||||
|
secure: f.secure,
|
||||||
|
valueType: f.valueType,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
const response = await credentialType.update(
|
||||||
|
data.accessToken ?? "",
|
||||||
|
editingId,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
if (response.successful) {
|
||||||
|
credentialTypes = credentialTypes.map((ct) =>
|
||||||
|
ct.id === editingId
|
||||||
|
? { ...ct, ...payload, fields: payload.fields }
|
||||||
|
: ct,
|
||||||
|
);
|
||||||
|
resetForm();
|
||||||
|
} else {
|
||||||
|
error = response.message || "Failed to update credential type";
|
||||||
|
errorDetails = response;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const response = await credentialType.create(
|
||||||
|
data.accessToken ?? "",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
if (response.successful) {
|
||||||
|
credentialTypes = [...credentialTypes, response.data];
|
||||||
|
resetForm();
|
||||||
|
} else {
|
||||||
|
error = response.message || "Failed to create credential type";
|
||||||
|
errorDetails = response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error = "An error occurred while saving the credential type";
|
||||||
|
errorDetails = err;
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
if (!confirm("Are you sure you want to delete this credential type?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
errorDetails = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await credentialType.delete(data.accessToken ?? "", id);
|
||||||
|
if (response.successful) {
|
||||||
|
credentialTypes = credentialTypes.filter((ct) => ct.id !== id);
|
||||||
|
} else {
|
||||||
|
error = response.message || "Failed to delete credential type";
|
||||||
|
errorDetails = response;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error = "An error occurred while deleting the credential type";
|
||||||
|
errorDetails = err;
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Credential Types — Admin</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<header class="header container">
|
||||||
|
<h1>Credential Types</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/admin">Admin Dashboard</a>
|
||||||
|
<button on:click={() => openForm()}>New Credential Type</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
{#if isLoading && !showForm}
|
||||||
|
<LoadingSpinner loading={true} />
|
||||||
|
{:else}
|
||||||
|
{#if error}
|
||||||
|
<ErrorBoundary message={error} details={errorDetails} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showForm}
|
||||||
|
<section class="form-section card">
|
||||||
|
<h2>{editingId ? "Edit" : "Create"} Credential Type</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Name *</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., AWS, Azure, GitHub"
|
||||||
|
bind:value={formData.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="permissionScope">Permission Scope *</label>
|
||||||
|
<input
|
||||||
|
id="permissionScope"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., aws.credentials"
|
||||||
|
bind:value={formData.permissionScope}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="icon">Icon URL</label>
|
||||||
|
<input
|
||||||
|
id="icon"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://..."
|
||||||
|
bind:value={formData.icon}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fields-section">
|
||||||
|
<h3>Credential Fields *</h3>
|
||||||
|
{#each formFields as field, index (field.id)}
|
||||||
|
<div class="field-item card">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="field-name-{index}">Field Name *</label>
|
||||||
|
<input
|
||||||
|
id="field-name-{index}"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., Access Key ID"
|
||||||
|
bind:value={field.name}
|
||||||
|
on:blur={() => handleFieldNameBlur(index)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="field-id-{index}">Field ID *</label>
|
||||||
|
<input
|
||||||
|
id="field-id-{index}"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., accessKeyId"
|
||||||
|
bind:value={field.id}
|
||||||
|
disabled={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="field-type-{index}">Value Type</label>
|
||||||
|
<select id="field-type-{index}" bind:value={field.valueType}>
|
||||||
|
<option value="plain_text">Plain Text</option>
|
||||||
|
<option value="password">Password</option>
|
||||||
|
<option value="number">Number</option>
|
||||||
|
<option value="email">Email</option>
|
||||||
|
<option value="url">URL</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" bind:checked={field.required} />
|
||||||
|
Required
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" bind:checked={field.secure} />
|
||||||
|
Secure (encrypted)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn-danger"
|
||||||
|
on:click={() => removeField(index)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Remove Field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<button class="btn-secondary" on:click={addField} type="button">
|
||||||
|
+ Add Field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
on:click={handleSubmit}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? "Saving..." : editingId ? "Update" : "Create"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-secondary"
|
||||||
|
on:click={resetForm}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="types-list">
|
||||||
|
<h2>Credential Types ({credentialTypes.length})</h2>
|
||||||
|
|
||||||
|
{#if credentialTypes.length === 0}
|
||||||
|
<p class="empty-state">
|
||||||
|
No credential types yet. Create one to get started.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="types-grid">
|
||||||
|
{#each credentialTypes as ct (ct.id)}
|
||||||
|
<article class="type-card card">
|
||||||
|
{#if ct.icon}
|
||||||
|
<img src={ct.icon} alt={ct.name} class="type-icon" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h3>{ct.name}</h3>
|
||||||
|
<p class="scope">{ct.permissionScope}</p>
|
||||||
|
|
||||||
|
<div class="fields-info">
|
||||||
|
<strong>Fields ({ct.fields.length}):</strong>
|
||||||
|
<ul>
|
||||||
|
{#each ct.fields as field}
|
||||||
|
<li>
|
||||||
|
{field.name}
|
||||||
|
{#if field.secure}
|
||||||
|
<span class="badge">Secure</span>
|
||||||
|
{/if}
|
||||||
|
{#if field.required}
|
||||||
|
<span class="badge">Required</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="usage">
|
||||||
|
<strong>Used by {ct.credentialCount} credential(s)</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timestamps">
|
||||||
|
<small
|
||||||
|
>Created: {new Date(ct.createdAt).toLocaleDateString()}</small
|
||||||
|
>
|
||||||
|
<small
|
||||||
|
>Updated: {new Date(ct.updatedAt).toLocaleDateString()}</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn-primary" on:click={() => openForm(ct)}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-danger"
|
||||||
|
on:click={() => handleDelete(ct.id)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.form-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:disabled {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input {
|
||||||
|
width: auto;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields-section {
|
||||||
|
margin: 2rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields-section h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-item {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #0066cc;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background-color: #0052a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background-color: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background-color: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.types-list {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.types-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields-info {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields-info ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0.5rem 0 0 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields-info li {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #e7f3ff;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { company } from "$lib";
|
import { company } from "$lib";
|
||||||
import LoadingSpinner from "$lib/../components/LoadingSpinner.svelte";
|
import LoadingSpinner from "$lib/../components/LoadingSpinner.svelte";
|
||||||
|
import ResultsSpinner from "$lib/../components/ResultsSpinner.svelte";
|
||||||
import ErrorBoundary from "$lib/../components/ErrorBoundary.svelte";
|
import ErrorBoundary from "$lib/../components/ErrorBoundary.svelte";
|
||||||
|
|
||||||
export let data;
|
export let data;
|
||||||
@@ -40,12 +41,21 @@
|
|||||||
let isLoading = true;
|
let isLoading = true;
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
let errorDetails: unknown = null;
|
let errorDetails: unknown = null;
|
||||||
|
let isResultsLoading = false;
|
||||||
let searchQuery = "";
|
let searchQuery = "";
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const itemsPerPage = 30;
|
const itemsPerPage = 30;
|
||||||
|
|
||||||
async function loadCompanies(page: number = 1) {
|
async function loadCompanies(page: number = 1, search?: string) {
|
||||||
isLoading = true;
|
// If caller provided a `search` argument (even empty string), treat this
|
||||||
|
// as a results-only refresh and show the inline results loader. Only
|
||||||
|
// show the full-page loader when `search` is not provided (initial load).
|
||||||
|
if (search !== undefined) {
|
||||||
|
isResultsLoading = true;
|
||||||
|
} else {
|
||||||
|
isLoading = true;
|
||||||
|
}
|
||||||
error = null;
|
error = null;
|
||||||
errorDetails = null;
|
errorDetails = null;
|
||||||
try {
|
try {
|
||||||
@@ -53,7 +63,11 @@
|
|||||||
throw new Error("No access token available. Please log in again.");
|
throw new Error("No access token available. Please log in again.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await company.fetchMany(data.session.accessToken, page);
|
const response = await company.fetchMany(
|
||||||
|
data.session.accessToken,
|
||||||
|
page,
|
||||||
|
search,
|
||||||
|
);
|
||||||
|
|
||||||
if (response && response.data && Array.isArray(response.data)) {
|
if (response && response.data && Array.isArray(response.data)) {
|
||||||
companies = response.data;
|
companies = response.data;
|
||||||
@@ -94,19 +108,23 @@
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
|
isResultsLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: displayedCompanies = companies.filter(
|
$: displayedCompanies =
|
||||||
(c) =>
|
searchQuery.trim().length > 0
|
||||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
? companies
|
||||||
c.cw_Identifier.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
: companies.filter(
|
||||||
c.cw_CompanyId.toString().includes(searchQuery),
|
(c) =>
|
||||||
);
|
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
c.cw_Identifier.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
c.cw_CompanyId.toString().includes(searchQuery),
|
||||||
|
);
|
||||||
|
|
||||||
function goToPage(page: number) {
|
function goToPage(page: number) {
|
||||||
if (page >= 1 && page <= totalPages) {
|
if (page >= 1 && page <= totalPages) {
|
||||||
loadCompanies(page);
|
loadCompanies(page, searchQuery.trim() || undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,11 +133,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function retryLoad() {
|
function retryLoad() {
|
||||||
loadCompanies(currentPage);
|
loadCompanies(currentPage, searchQuery.trim() || undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load companies on component mount
|
// Load companies on component mount
|
||||||
loadCompanies();
|
loadCompanies();
|
||||||
|
|
||||||
|
function onSearchInput() {
|
||||||
|
// Show inline spinner immediately while the user is typing.
|
||||||
|
isResultsLoading = true;
|
||||||
|
|
||||||
|
if (searchTimeout) clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
// Pass an explicit `search` arg (may be empty string) so loadCompanies
|
||||||
|
// treats this as a results-only refresh instead of a full-page reload.
|
||||||
|
loadCompanies(1, searchQuery.trim());
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -158,6 +188,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Search companies by name, ID, or identifier..."
|
placeholder="Search companies by name, ID, or identifier..."
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
|
on:input={onSearchInput}
|
||||||
class="search-bar"
|
class="search-bar"
|
||||||
/>
|
/>
|
||||||
{#if searchQuery}
|
{#if searchQuery}
|
||||||
@@ -169,20 +200,37 @@
|
|||||||
|
|
||||||
{#if displayedCompanies.length > 0}
|
{#if displayedCompanies.length > 0}
|
||||||
<section class="companies-grid">
|
<section class="companies-grid">
|
||||||
{#each displayedCompanies as comp (comp.id)}
|
{#if isResultsLoading}
|
||||||
<article class="company-card">
|
<div class="results-loader">
|
||||||
<h3>{comp.name}</h3>
|
<ResultsSpinner size={40} />
|
||||||
<dl>
|
</div>
|
||||||
<dt>CW Company ID</dt>
|
{:else}
|
||||||
<dd>{comp.cw_CompanyId}</dd>
|
{#each displayedCompanies as comp (comp.id)}
|
||||||
<dt>CW Identifier</dt>
|
<div
|
||||||
<dd>{comp.cw_Identifier}</dd>
|
class="company-card"
|
||||||
<dt>Created</dt>
|
role="link"
|
||||||
<dd>{new Date(comp.createdAt).toLocaleDateString()}</dd>
|
tabindex="0"
|
||||||
</dl>
|
on:click={() => goto(`/companies/${comp.id}`)}
|
||||||
<a href="/company/{comp.id}" class="view-link">View Details</a>
|
on:keydown={(e) => {
|
||||||
</article>
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
{/each}
|
e.preventDefault();
|
||||||
|
goto(`/companies/${comp.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3>{comp.name}</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>CW Company ID</dt>
|
||||||
|
<dd>{comp.cw_CompanyId}</dd>
|
||||||
|
<dt>CW Identifier</dt>
|
||||||
|
<dd>{comp.cw_Identifier}</dd>
|
||||||
|
<dt>Created</dt>
|
||||||
|
<dd>{new Date(comp.createdAt).toLocaleDateString()}</dd>
|
||||||
|
</dl>
|
||||||
|
<span class="view-link">View Details</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<section class="companies-grid">
|
<section class="companies-grid">
|
||||||
@@ -190,7 +238,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if totalPages > 1 && !searchQuery}
|
{#if totalPages > 1 && !isResultsLoading}
|
||||||
<section class="pagination">
|
<section class="pagination">
|
||||||
<button
|
<button
|
||||||
on:click={() => goToPage(1)}
|
on:click={() => goToPage(1)}
|
||||||
@@ -306,14 +354,6 @@
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #dc2626;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #fee2e2;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-results {
|
.search-results {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
@@ -354,6 +394,13 @@
|
|||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.results-loader {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.companies-grid {
|
.companies-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
@@ -369,6 +416,7 @@
|
|||||||
transition:
|
transition:
|
||||||
box-shadow 0.2s,
|
box-shadow 0.2s,
|
||||||
border-color 0.2s;
|
border-color 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.company-card:hover {
|
.company-card:hover {
|
||||||
@@ -376,6 +424,12 @@
|
|||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.company-card:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0366d6;
|
||||||
|
box-shadow: 0 0 0 4px rgba(3, 102, 214, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.company-card h3 {
|
.company-card h3 {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
color: #111;
|
color: #111;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { company } from "$lib/companies";
|
||||||
|
import type { PageServerLoad } from "./$types";
|
||||||
|
import { error } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, parent }) => {
|
||||||
|
const { session } = await parent();
|
||||||
|
|
||||||
|
if (!session.accessToken) {
|
||||||
|
throw error(401, "Unauthorized: Access token required");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const companyData = await company.fetch(session.accessToken, params.id);
|
||||||
|
|
||||||
|
if (!companyData) {
|
||||||
|
throw error(404, `Company with ID ${params.id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// attempt to load configurations but don't fail the whole page if it errors
|
||||||
|
let configurations = null;
|
||||||
|
let configurationsError = null;
|
||||||
|
try {
|
||||||
|
configurations = await company.fetchConfigurations(
|
||||||
|
session.accessToken,
|
||||||
|
params.id,
|
||||||
|
);
|
||||||
|
} catch (cfgErr) {
|
||||||
|
console.error("Failed to fetch configurations:", cfgErr);
|
||||||
|
configurationsError = String(
|
||||||
|
cfgErr instanceof Error ? cfgErr.message : cfgErr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
company: companyData,
|
||||||
|
configurations,
|
||||||
|
configurationsError,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch company:", err);
|
||||||
|
|
||||||
|
if (err instanceof Error && err.message.includes("404")) {
|
||||||
|
throw error(404, `Company with ID ${params.id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof Error && err.message.includes("401")) {
|
||||||
|
throw error(401, "Your session has expired. Please log in again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error(500, "Failed to load company details. Please try again.");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<script>
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import ErrorBoundary from "$lib/../components/ErrorBoundary.svelte";
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
export let error;
|
||||||
|
|
||||||
|
function signOut() {
|
||||||
|
goto("/logout");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Company Detail — App</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<header class="header container">
|
||||||
|
<h1>Company Detail</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/companies">Companies</a>
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<button on:click={signOut}>Sign out</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
{#if error}
|
||||||
|
<ErrorBoundary
|
||||||
|
title="Failed to Load Company"
|
||||||
|
message={error.message ||
|
||||||
|
"We couldn't load the company details. Please try again."}
|
||||||
|
details={error}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<section>
|
||||||
|
<h2>API Response</h2>
|
||||||
|
<details class="json-collapse" open>
|
||||||
|
<summary>Show company JSON</summary>
|
||||||
|
<pre><code>{JSON.stringify(data.company, null, 2)}</code></pre>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
<section style="margin-top:1.5rem;">
|
||||||
|
<h2>Configurations</h2>
|
||||||
|
{#if data.configurationsError}
|
||||||
|
<ErrorBoundary
|
||||||
|
title="Failed to Load Configurations"
|
||||||
|
message={data.configurationsError}
|
||||||
|
details={data.configurationsError}
|
||||||
|
/>
|
||||||
|
{:else if data.configurations}
|
||||||
|
<details class="json-collapse" open>
|
||||||
|
<summary>Show configurations JSON</summary>
|
||||||
|
<pre><code>{JSON.stringify(data.configurations, null, 2)}</code></pre>
|
||||||
|
</details>
|
||||||
|
{:else}
|
||||||
|
<p>No configurations available for this company.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
margin: 0;
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
"Segoe UI",
|
||||||
|
Roboto,
|
||||||
|
"Helvetica Neue",
|
||||||
|
Arial;
|
||||||
|
background: #f7f7f8;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a,
|
||||||
|
nav button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #0066cc;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a:hover,
|
||||||
|
nav button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-collapse summary {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0066cc;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-collapse pre {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script>
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
// Redirect legacy /company to /companies
|
||||||
|
goto("/companies");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<meta http-equiv="refresh" content="0;url=/companies" />
|
||||||
|
<title>Redirecting...</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<p>Redirecting to <a href="/companies">/companies</a>…</p>
|
||||||
|
|||||||
@@ -16,8 +16,25 @@ export const load: PageServerLoad = async ({ params, parent }) => {
|
|||||||
throw error(404, `Company with ID ${params.id} not found`);
|
throw error(404, `Company with ID ${params.id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attempt to load configurations but don't fail the whole page if it errors
|
||||||
|
let configurations = null;
|
||||||
|
let configurationsError = null;
|
||||||
|
try {
|
||||||
|
configurations = await company.fetchConfigurations(
|
||||||
|
session.accessToken,
|
||||||
|
params.id,
|
||||||
|
);
|
||||||
|
} catch (cfgErr) {
|
||||||
|
console.error("Failed to fetch configurations:", cfgErr);
|
||||||
|
configurationsError = String(
|
||||||
|
cfgErr instanceof Error ? cfgErr.message : cfgErr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
company: companyData,
|
company: companyData,
|
||||||
|
configurations,
|
||||||
|
configurationsError,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch company:", err);
|
console.error("Failed to fetch company:", err);
|
||||||
|
|||||||
@@ -1,119 +1,25 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { page } from "$app/stores";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import ErrorBoundary from "$lib/../components/ErrorBoundary.svelte";
|
|
||||||
|
|
||||||
export let data;
|
// client-side redirect from legacy /company/:id to /companies/:id
|
||||||
export let error;
|
onMount(() => {
|
||||||
|
const unsubscribe = page.subscribe(($page) => {
|
||||||
function signOut() {
|
const id = $page.params?.id;
|
||||||
goto("/logout");
|
if (id) {
|
||||||
}
|
goto(`/companies/${id}`, { replaceState: true });
|
||||||
|
} else {
|
||||||
|
goto("/companies", { replaceState: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Company Detail — App</title>
|
<meta http-equiv="refresh" content="0;url=/companies" />
|
||||||
|
<title>Redirecting...</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<header class="header container">
|
<p>Redirecting to <a href="/companies">/companies</a>…</p>
|
||||||
<h1>Company Detail</h1>
|
|
||||||
<nav>
|
|
||||||
<a href="/companies">Companies</a>
|
|
||||||
<a href="/">Home</a>
|
|
||||||
<button on:click={signOut}>Sign out</button>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="container">
|
|
||||||
{#if error}
|
|
||||||
<ErrorBoundary
|
|
||||||
title="Failed to Load Company"
|
|
||||||
message={error.message ||
|
|
||||||
"We couldn't load the company details. Please try again."}
|
|
||||||
details={error}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<section>
|
|
||||||
<h2>API Response</h2>
|
|
||||||
<pre><code>{JSON.stringify(data.company, null, 2)}</code></pre>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:global(body) {
|
|
||||||
margin: 0;
|
|
||||||
font-family:
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
"Segoe UI",
|
|
||||||
Roboto,
|
|
||||||
"Helvetica Neue",
|
|
||||||
Arial;
|
|
||||||
background: #f7f7f8;
|
|
||||||
color: #111;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a,
|
|
||||||
nav button {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
text-decoration: none;
|
|
||||||
color: #0066cc;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a:hover,
|
|
||||||
nav button:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
padding: 2rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
section {
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow-x: auto;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-family: "Courier New", monospace;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<form action="?/login" method="POST" on:submit={handleSubmit} use:enhance>
|
<form action="?/login" method="POST" onsubmit={handleSubmit} use:enhance>
|
||||||
<input type="hidden" name="callbackKey" value={uriData.callbackKey} />
|
<input type="hidden" name="callbackKey" value={uriData.callbackKey} />
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
Reference in New Issue
Block a user