restructure and reorganize

This commit is contained in:
2026-02-16 07:47:08 -06:00
parent 561aef8ee3
commit 6d046e90ed
29 changed files with 156 additions and 1797 deletions
+3 -2
View File
@@ -5,8 +5,9 @@ declare global {
// interface Error {} // interface Error {}
interface Locals { interface Locals {
session?: { session?: {
accessToken: string; accessToken: string | null;
refreshToken: string; refreshToken: string | null;
set(accessToken: string, refreshToken: string): Promise<void>;
}; };
} }
// interface PageData {} // interface PageData {}
-27
View File
@@ -1,27 +0,0 @@
import axios, { AxiosInstance } from "axios";
export async function fetchAuthRedirectUri(api_url: string): Promise<{
uri: string;
callbackKey: string;
}> {
const client: AxiosInstance = axios.create({
baseURL: api_url || "",
timeout: 5000,
});
try {
const res = await client.get("/v1/auth/uri");
const d = res.data ?? {};
const uri = d.data.uri;
const callbackKey = d.data.callbackKey;
if (typeof uri !== "string" || !uri)
throw new Error("redirect uri missing from response");
return {
uri,
callbackKey,
};
} catch (e) {
throw new Error(
`Failed to fetch auth redirect uri: ${(e as Error).message}`,
);
}
}
+9 -4
View File
@@ -1,10 +1,15 @@
// place files you want to import through the `$lib` alias in this folder. // place files you want to import through the `$lib` alias in this folder.
export * from "./axios"; import { user } from "./optima-api/modules/user";
export * from "./user";
export * from "./companies";
export * from "./credentialTypes";
export const optima = {
auth: (await import("./optima-api/modules/auth")).auth,
company: (await import("./optima-api/modules/companies")).company,
credential: (await import("./optima-api/modules/credentials")).credential,
credentialType: (await import("./optima-api/modules/credentialTypes"))
.credentialType,
user,
};
/** /**
* @TODO * @TODO
* *
+29
View File
@@ -0,0 +1,29 @@
import axios, { AxiosInstance } from "axios";
export const auth = {
async fetchAuthRedirectUri(api_url: string): Promise<{
uri: string;
callbackKey: string;
}> {
const client: AxiosInstance = axios.create({
baseURL: api_url || "",
timeout: 5000,
});
try {
const res = await client.get("/v1/auth/uri");
const d = res.data ?? {};
const uri = d.data.uri;
const callbackKey = d.data.callbackKey;
if (typeof uri !== "string" || !uri)
throw new Error("redirect uri missing from response");
return {
uri,
callbackKey,
};
} catch (e) {
throw new Error(
`Failed to fetch auth redirect uri: ${(e as Error).message}`,
);
}
},
};
@@ -1,4 +1,4 @@
import api from "./axios"; import api from "../axios";
export const company = { export const company = {
async fetch(accessToken: string, id: string) { async fetch(accessToken: string, id: string) {
@@ -1,4 +1,4 @@
import api from "./axios"; import api from "../axios";
export interface CredentialTypeField { export interface CredentialTypeField {
id: string; id: string;
@@ -1,4 +1,4 @@
import api from "./axios"; import api from "../axios";
export interface CredentialField { export interface CredentialField {
id: string; id: string;
@@ -1,11 +1,11 @@
import { user } from "$lib";
import { Actions, redirect } from "@sveltejs/kit"; import { Actions, redirect } from "@sveltejs/kit";
import { optima } from "$lib";
export const actions: Actions = { export const actions: Actions = {
login: async (event) => { login: async (event) => {
const data = await event.request.formData(); const data = await event.request.formData();
const tokens = await user.awaitAuthCallback( const tokens = await optima.user.awaitAuthCallback(
data.get("callbackKey") as string, data.get("callbackKey") as string,
); );
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { fetchAuthRedirectUri } from "$lib/authUri"; import { optima } from "$lib";
import { PUBLIC_API_URL } from "$env/static/public"; import { PUBLIC_API_URL } from "$env/static/public";
import { enhance } from "$app/forms"; import { enhance } from "$app/forms";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import LoadingSpinner from "../../components/LoadingSpinner.svelte"; import LoadingSpinner from "../../../components/LoadingSpinner.svelte";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
const uriData = await fetchAuthRedirectUri(PUBLIC_API_URL); const uriData = await fetchAuthRedirectUri(PUBLIC_API_URL);
+80
View File
@@ -0,0 +1,80 @@
// src/routes/(secure)/+layout.server.ts
import { redirect } from "@sveltejs/kit";
import type { LayoutServerLoad } from "./$types";
type Session = {
accessToken: string | null;
refreshToken: string | null;
};
type JwtPayload = {
exp?: number; // seconds since epoch
[key: string]: unknown;
};
const parseJwt = (token: string): JwtPayload | null => {
try {
const [, payload] = token.split(".");
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
} catch {
return null;
}
};
export const load: LayoutServerLoad = async (event) => {
const { locals, url } = event;
const session = locals.session as Session | undefined;
const accessToken = session?.accessToken ?? null;
const refreshToken = session?.refreshToken ?? null;
if (!accessToken && !refreshToken) {
throw redirect(302, `/login?redirectTo=${url.pathname}`);
}
// Decide if access token is expired/near expiry from its exp claim
let needsRefresh = false;
if (accessToken) {
const payload = parseJwt(accessToken);
if (!payload?.exp) {
needsRefresh = true; // malformed or no exp, play safe
} else {
const nowSec = Math.floor(Date.now() / 1000);
const thresholdSec = 60; // refresh if < 60s remaining
if (payload.exp - nowSec < thresholdSec) {
needsRefresh = true;
}
}
} else if (refreshToken) {
// No access token but we have a refresh token
needsRefresh = true;
}
if (needsRefresh && refreshToken) {
const refreshed = await refreshTokens(refreshToken);
if (!refreshed) {
throw redirect(302, `/login?redirectTo=${url.pathname}`);
}
await locals.session!.set(
refreshed.accessToken,
refreshed.refreshToken ?? refreshToken,
);
}
// Expose only what secure pages need; they can decode again or call backend
return {
accessToken: locals.session!.accessToken,
};
};
// Your backend-specific implementation
async function refreshTokens(refreshToken: string): Promise<{
accessToken: string;
refreshToken?: string;
} | null> {
// e.g. call /auth/refresh and return new tokens
return null;
}
+1 -12
View File
@@ -1,12 +1 @@
import { LayoutServerLoad } from "./$types"; // Layout server code removed for fresh start
export const load: LayoutServerLoad = async ({ locals }) => {
// WARNING: returning tokens to the client exposes them to JavaScript.
// Prefer keeping tokens httpOnly and proxying requests via server endpoints.
return {
session: {
accessToken: locals.session?.accessToken ?? null,
refreshToken: locals.session?.refreshToken ?? null,
},
};
};
+1 -1
View File
@@ -1 +1 @@
import "../app.css"; import "..styles/app.css";
+5 -113
View File
@@ -1,120 +1,12 @@
<script> <script lang="ts">
import { goto } from "$app/navigation"; // You can add any JavaScript logic here if needed
function signOut() {
goto("/logout");
}
</script> </script>
<svelte:head> <svelte:head>
<title>Home — App</title> <title>Home — App</title>
</svelte:head> </svelte:head>
<header class="header container"> <main>
<h1>App Home</h1> <h1>Welcome</h1>
<nav> <p>Your new landing page. Ready to build.</p>
<a href="/companies">Companies</a>
<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>
<main class="container">
<section class="hero">
<h2>Welcome back</h2>
<p>
This is your protected home page. Quick links and recent activity appear
below.
</p>
</section>
<section class="grid">
<article class="card">
<h3>Quick actions</h3>
<ul>
<li><a href="/projects/new">Create project</a></li>
<li><a href="/profile/edit">Edit profile</a></li>
</ul>
</article>
<article class="card">
<h3>Recent activity</h3>
<p>No recent activity.</p>
</article>
</section>
</main> </main>
<footer class="container">
<small>© {new Date().getFullYear()} Your App</small>
</footer>
<style>
:global(body) {
margin: 0;
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial;
background: #f7f7f8;
color: #111;
}
.container {
max-width: 960px;
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 {
color: #0366d6;
text-decoration: none;
padding: 0.25rem 0.5rem;
}
nav button {
background: #ef4444;
color: #fff;
border: 0;
padding: 0.4rem 0.6rem;
border-radius: 6px;
cursor: pointer;
}
main {
padding: 1.5rem 0;
}
.hero h2 {
margin: 0 0 0.5rem 0;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.card {
background: #fff;
border: 1px solid #e5e7eb;
padding: 1rem;
border-radius: 8px;
}
footer {
text-align: center;
padding: 1rem 0;
color: #6b7280;
}
</style>
-8
View File
@@ -1,8 +0,0 @@
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ params, parent }) => {
const { session } = await parent();
return {
accessToken: session.accessToken,
};
};
-49
View File
@@ -1,49 +0,0 @@
<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>
@@ -1,11 +0,0 @@
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,
};
};
@@ -1,617 +0,0 @@
<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>
-559
View File
@@ -1,559 +0,0 @@
<script lang="ts">
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;
interface Company {
id: string;
name: string;
cw_CompanyId: number;
cw_Identifier: string;
createdAt: string;
updatedAt: string;
}
interface ApiResponse {
status: number;
message: string;
data: Company[];
successful: boolean;
meta: {
timestamp: number;
pagination: {
previousPage: number | null;
currentPage: number;
nextPage: number | null;
totalPages: number;
totalRecords: number;
listedRecords: number;
};
};
}
let companies: Company[] = [];
let totalPages = 0;
let currentPage = 1;
let totalRecords = 0;
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, 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 {
if (!data.session.accessToken) {
throw new Error("No access token available. Please log in again.");
}
const response = await company.fetchMany(
data.session.accessToken,
page,
search,
);
if (response && response.data && Array.isArray(response.data)) {
companies = response.data;
totalRecords =
response.meta?.pagination?.totalRecords || response.data.length;
totalPages =
response.meta?.pagination?.totalPages ||
Math.ceil(companies.length / itemsPerPage);
currentPage = page;
} else {
throw new Error(
response?.message ||
"Failed to load companies: Invalid response format",
);
}
} catch (err) {
console.error("Failed to fetch companies:", err);
errorDetails = err;
if (err instanceof Error) {
if (
err.message.includes("401") ||
err.message.includes("Unauthorized")
) {
error = "Your session has expired. Please log in again.";
} else if (
err.message.includes("403") ||
err.message.includes("Forbidden")
) {
error = "You don't have permission to view companies.";
} else if (err.message.includes("Network")) {
error = "Network error. Please check your connection and try again.";
} else {
error = err.message || "Error loading companies. Please try again.";
}
} else {
error = "An unexpected error occurred. Please try again.";
}
} finally {
isLoading = false;
isResultsLoading = false;
}
}
$: displayedCompanies =
searchQuery.trim().length > 0
? companies
: companies.filter(
(c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.cw_Identifier.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.cw_CompanyId.toString().includes(searchQuery),
);
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
loadCompanies(page, searchQuery.trim() || undefined);
}
}
function signOut() {
goto("/logout");
}
function retryLoad() {
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>
<title>Companies — App</title>
</svelte:head>
<header class="header container">
<h1>Companies</h1>
<nav>
<a href="/">Home</a>
<a href="/settings">Settings</a>
<a href="/profile">Profile</a>
<button on:click={signOut}>Sign out</button>
</nav>
</header>
<main class="container">
<section class="hero">
<h2>Company Directory</h2>
{#if isLoading}
<LoadingSpinner loading={true} />
{:else if error}
<ErrorBoundary
title="Failed to Load Companies"
message={error}
details={errorDetails}
/>
{:else}
<p>Browse all companies. Total: {totalRecords} companies</p>
{/if}
</section>
{#if !isLoading && !error}
<section class="search-section">
<input
type="text"
placeholder="Search companies by name, ID, or identifier..."
bind:value={searchQuery}
on:input={onSearchInput}
class="search-bar"
/>
{#if searchQuery}
<p class="search-results">
Found {displayedCompanies.length} company/companies matching "{searchQuery}"
</p>
{/if}
</section>
{#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)}
<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>
<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>
{:else}
<section class="companies-grid">
<p class="no-results">No companies found</p>
</section>
{/if}
{#if totalPages > 1 && !isResultsLoading}
<section class="pagination">
<button
on:click={() => goToPage(1)}
disabled={currentPage === 1}
class="pagination-btn"
>
⟨⟨ First
</button>
<button
on:click={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
class="pagination-btn"
>
← Previous
</button>
<div class="page-numbers">
{#each Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
const startPage = Math.max(1, currentPage - 2);
return startPage + i;
}) as page}
{#if page <= totalPages}
<button
on:click={() => goToPage(page)}
class="page-number {page === currentPage ? 'active' : ''}"
>
{page}
</button>
{/if}
{/each}
</div>
<button
on:click={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
class="pagination-btn"
>
Next →
</button>
<button
on:click={() => goToPage(totalPages)}
disabled={currentPage === totalPages}
class="pagination-btn"
>
Last ⟩⟩
</button>
<span class="page-info">
Page {currentPage} of {totalPages}
</span>
</section>
{/if}
{/if}
</main>
<footer class="container">
<small>© {new Date().getFullYear()} Your App</small>
</footer>
<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 {
color: #0366d6;
text-decoration: none;
padding: 0.25rem 0.5rem;
}
nav button {
background: #ef4444;
color: #fff;
border: 0;
padding: 0.4rem 0.6rem;
border-radius: 6px;
cursor: pointer;
}
main {
padding: 1.5rem 0;
}
.hero h2 {
margin: 0 0 0.5rem 0;
}
.hero p {
margin: 0;
color: #6b7280;
}
.search-results {
color: #6b7280;
margin-top: 0.5rem;
font-size: 0.9rem;
}
.no-results {
color: #9ca3af;
text-align: center;
padding: 2rem;
grid-column: 1 / -1;
}
.search-section {
margin: 1.5rem 0;
}
.search-bar {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fff;
color: #111;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.search-bar:focus {
outline: none;
border-color: #0366d6;
box-shadow: 0 0 0 3px rgba(3, 102, 214, 0.1);
}
.search-bar::placeholder {
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);
gap: 1.5rem;
margin: 2rem 0;
}
.company-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.5rem;
transition:
box-shadow 0.2s,
border-color 0.2s;
cursor: pointer;
}
.company-card:hover {
border-color: #0366d6;
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;
font-size: 1.1rem;
}
.company-card dl {
margin: 0 0 1rem 0;
font-size: 0.9rem;
}
.company-card dt {
font-weight: 600;
color: #6b7280;
margin-top: 0.5rem;
}
.company-card dd {
margin: 0.25rem 0 0 0;
color: #111;
font-family: monospace;
}
.view-link {
display: inline-block;
color: #0366d6;
text-decoration: none;
padding: 0.5rem 0;
font-weight: 500;
border-bottom: 1px solid transparent;
transition: border-color 0.2s;
}
.view-link:hover {
border-bottom-color: #0366d6;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin: 2rem 0;
padding: 1rem;
background: #fff;
border-radius: 8px;
border: 1px solid #e5e7eb;
flex-wrap: wrap;
}
.pagination-btn {
background: #fff;
border: 1px solid #e5e7eb;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
color: #0366d6;
font-weight: 500;
transition: all 0.2s;
}
.pagination-btn:hover:not(:disabled) {
background: #0366d6;
color: #fff;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-numbers {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.page-number {
background: #fff;
border: 1px solid #e5e7eb;
padding: 0.5rem 0.75rem;
border-radius: 4px;
cursor: pointer;
color: #0366d6;
font-weight: 500;
transition: all 0.2s;
min-width: 2.5rem;
}
.page-number:hover {
background: #f3f4f6;
}
.page-number.active {
background: #0366d6;
color: #fff;
border-color: #0366d6;
}
.page-info {
color: #6b7280;
font-size: 0.9rem;
white-space: nowrap;
}
footer {
text-align: center;
padding: 1rem 0;
color: #6b7280;
}
@media (max-width: 768px) {
.companies-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.companies-grid {
grid-template-columns: 1fr;
}
.pagination {
flex-direction: column;
}
}
</style>
-72
View File
@@ -1,72 +0,0 @@
import { company } from "$lib/companies";
import { credential } from "$lib/credentials";
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,
);
}
// attempt to load credentials but don't fail the whole page if it errors
let credentials = null;
let credentialsError = null;
try {
credentials = await credential.fetchByCompany(
session.accessToken,
params.id,
);
} catch (credErr) {
console.error("Failed to fetch credentials:", credErr);
credentialsError = String(
credErr instanceof Error ? credErr.message : credErr,
);
}
return {
company: companyData,
configurations,
configurationsError,
credentials,
credentialsError,
session,
companyId: params.id,
};
} 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.");
}
};
-215
View File
@@ -1,215 +0,0 @@
<script>
import { goto } from "$app/navigation";
import ErrorBoundary from "$lib/../components/ErrorBoundary.svelte";
import CreateCredentialModal from "$lib/../components/CreateCredentialModal.svelte";
export let data;
export let error;
let showCreateModal = false;
function signOut() {
goto("/logout");
}
function handleOpenCreateModal() {
showCreateModal = true;
}
function handleCredentialCreated() {
// Refresh the page to show the new credential
location.reload();
}
</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>
<section style="margin-top:1.5rem;">
<div
style="display: flex; justify-content: space-between; align-items: center;"
>
<h2 style="margin: 0;">Credentials</h2>
<button class="create-button" on:click={handleOpenCreateModal}>
Create Credential
</button>
</div>
{#if data.credentialsError}
<ErrorBoundary
title="Failed to Load Credentials"
message={data.credentialsError}
details={data.credentialsError}
/>
{:else if data.credentials && data.credentials.data && data.credentials.data.length > 0}
<details class="json-collapse" open>
<summary>Show credentials JSON</summary>
<pre><code>{JSON.stringify(data.credentials, null, 2)}</code></pre>
</details>
{:else}
<p>No credentials available for this company.</p>
{/if}
</section>
{/if}
</main>
<CreateCredentialModal
isOpen={showCreateModal}
companyId={data.companyId}
accessToken={data.session?.accessToken || ""}
onSuccess={handleCredentialCreated}
/>
<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;
}
.create-button {
padding: 0.5rem 1rem;
background-color: #0066cc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
transition: background-color 0.2s;
}
.create-button:hover:not(:disabled) {
background-color: #0052a3;
}
.create-button:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
</style>
-12
View File
@@ -1,12 +0,0 @@
<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>
-52
View File
@@ -1,52 +0,0 @@
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.");
}
};
-25
View File
@@ -1,25 +0,0 @@
<script>
import { onMount } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
// 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>
<meta http-equiv="refresh" content="0;url=/companies" />
<title>Redirecting...</title>
</svelte:head>
<p>Redirecting to <a href="/companies">/companies</a></p>
+21
View File
@@ -0,0 +1,21 @@
// src/hooks.server.ts
import type { Handle } from "@sveltejs/kit";
export const handle: Handle = async ({ event, resolve }) => {
const accessToken = event.cookies.get("acceessToken") || null;
const refreshToken = event.cookies.get("refreshToken") || null;
const setTokens = async (accessToken: string, refreshToken: string) => {
event.cookies.set("accessToken", accessToken, {} as any);
event.cookies.set("refreshToken", refreshToken, {} as any);
event.locals.session = { accessToken, refreshToken, set: setTokens };
return;
};
event.locals.session = { accessToken, refreshToken, set: setTokens };
const response = await resolve(event);
return response;
};
-11
View File
@@ -1,11 +0,0 @@
import { describe, test, expect } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/svelte';
import Page from './+page.svelte';
describe('/+page.svelte', () => {
test('should render h1', () => {
render(Page);
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
});
});
View File
View File