CREDENTIAL TYPE MANAGEMENT WORKS

This commit is contained in:
2026-02-14 15:16:06 -06:00
parent 51db9de171
commit 140e6c416a
17 changed files with 1251 additions and 153 deletions
+71
View File
@@ -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>
+49
View File
@@ -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
View File
@@ -2,22 +2,34 @@ import api from "./axios";
export const company = {
async fetch(accessToken: string, id: string) {
const company = await api.get(`/v1/company/${id}`, {
const company = await api.get(`/v1/company/companies/${id}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return company.data;
},
async fetchMany(accessToken: string, page: number = 1) {
const companies = await api.get("/v1/companies", {
params: {
page,
},
async fetchMany(accessToken: string, page: number = 1, search?: string) {
const params: Record<string, unknown> = { page };
if (search && search.length > 0) params.search = search;
const companies = await api.get("/v1/company/companies", {
params,
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
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;
},
};
+88
View File
@@ -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;
},
};
+1
View File
@@ -3,6 +3,7 @@
export * from "./axios";
export * from "./user";
export * from "./companies";
export * from "./credentialTypes";
/**
* @TODO
+1 -1
View File
@@ -2,7 +2,6 @@
import { goto } from "$app/navigation";
function signOut() {
// replace with your auth sign-out logic
goto("/logout");
}
</script>
@@ -18,6 +17,7 @@
<a href="/projects">Projects</a>
<a href="/settings">Settings</a>
<a href="/profile">Profile</a>
<a href="/admin">Admin</a>
<button on:click={signOut}>Sign out</button>
</nav>
</header>
+8
View File
@@ -0,0 +1,8 @@
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ params, parent }) => {
const { session } = await parent();
return {
accessToken: session.accessToken,
};
};
+49
View File
@@ -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>
+71 -17
View File
@@ -2,6 +2,7 @@
import { goto } from "$app/navigation";
import { company } from "$lib";
import LoadingSpinner from "$lib/../components/LoadingSpinner.svelte";
import ResultsSpinner from "$lib/../components/ResultsSpinner.svelte";
import ErrorBoundary from "$lib/../components/ErrorBoundary.svelte";
export let data;
@@ -40,12 +41,21 @@
let isLoading = true;
let error: string | null = null;
let errorDetails: unknown = null;
let isResultsLoading = false;
let searchQuery = "";
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
const itemsPerPage = 30;
async function loadCompanies(page: number = 1) {
async function loadCompanies(page: number = 1, search?: string) {
// 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;
errorDetails = null;
try {
@@ -53,7 +63,11 @@
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)) {
companies = response.data;
@@ -94,10 +108,14 @@
}
} finally {
isLoading = false;
isResultsLoading = false;
}
}
$: displayedCompanies = companies.filter(
$: displayedCompanies =
searchQuery.trim().length > 0
? companies
: companies.filter(
(c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.cw_Identifier.toLowerCase().includes(searchQuery.toLowerCase()) ||
@@ -106,7 +124,7 @@
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
loadCompanies(page);
loadCompanies(page, searchQuery.trim() || undefined);
}
}
@@ -115,11 +133,23 @@
}
function retryLoad() {
loadCompanies(currentPage);
loadCompanies(currentPage, searchQuery.trim() || undefined);
}
// Load companies on component mount
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>
<svelte:head>
@@ -158,6 +188,7 @@
type="text"
placeholder="Search companies by name, ID, or identifier..."
bind:value={searchQuery}
on:input={onSearchInput}
class="search-bar"
/>
{#if searchQuery}
@@ -169,8 +200,24 @@
{#if displayedCompanies.length > 0}
<section class="companies-grid">
{#if isResultsLoading}
<div class="results-loader">
<ResultsSpinner size={40} />
</div>
{:else}
{#each displayedCompanies as comp (comp.id)}
<article class="company-card">
<div
class="company-card"
role="link"
tabindex="0"
on:click={() => goto(`/companies/${comp.id}`)}
on:keydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
goto(`/companies/${comp.id}`);
}
}}
>
<h3>{comp.name}</h3>
<dl>
<dt>CW Company ID</dt>
@@ -180,9 +227,10 @@
<dt>Created</dt>
<dd>{new Date(comp.createdAt).toLocaleDateString()}</dd>
</dl>
<a href="/company/{comp.id}" class="view-link">View Details</a>
</article>
<span class="view-link">View Details</span>
</div>
{/each}
{/if}
</section>
{:else}
<section class="companies-grid">
@@ -190,7 +238,7 @@
</section>
{/if}
{#if totalPages > 1 && !searchQuery}
{#if totalPages > 1 && !isResultsLoading}
<section class="pagination">
<button
on:click={() => goToPage(1)}
@@ -306,14 +354,6 @@
color: #6b7280;
}
.error {
color: #dc2626;
padding: 1rem;
background: #fee2e2;
border-radius: 6px;
margin: 0;
}
.search-results {
color: #6b7280;
margin-top: 0.5rem;
@@ -354,6 +394,13 @@
color: #9ca3af;
}
.results-loader {
grid-column: 1 / -1;
display: flex;
justify-content: center;
padding: 2rem 0;
}
.companies-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
@@ -369,6 +416,7 @@
transition:
box-shadow 0.2s,
border-color 0.2s;
cursor: pointer;
}
.company-card:hover {
@@ -376,6 +424,12 @@
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 {
margin: 0 0 1rem 0;
color: #111;
+52
View File
@@ -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.");
}
};
+151
View File
@@ -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>
+12
View File
@@ -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>
+17
View File
@@ -16,8 +16,25 @@ export const load: PageServerLoad = async ({ params, parent }) => {
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);
+16 -110
View File
@@ -1,119 +1,25 @@
<script>
import { onMount } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import ErrorBoundary from "$lib/../components/ErrorBoundary.svelte";
export let data;
export let error;
function signOut() {
goto("/logout");
// client-side redirect from legacy /company/:id to /companies/:id
onMount(() => {
const unsubscribe = page.subscribe(($page) => {
const id = $page.params?.id;
if (id) {
goto(`/companies/${id}`, { replaceState: true });
} else {
goto("/companies", { replaceState: true });
}
});
unsubscribe();
});
</script>
<svelte:head>
<title>Company Detail — App</title>
<meta http-equiv="refresh" content="0;url=/companies" />
<title>Redirecting...</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>
<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>
<p>Redirecting to <a href="/companies">/companies</a></p>
+1 -1
View File
@@ -35,7 +35,7 @@
</script>
<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} />
<button
type="submit"