restructure and reorganize
This commit is contained in:
Vendored
+3
-2
@@ -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 {}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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 +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 @@
|
|||||||
import "../app.css";
|
import "..styles/app.css";
|
||||||
|
|||||||
+5
-113
@@ -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>
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import type { LayoutServerLoad } from "./$types";
|
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ params, parent }) => {
|
|
||||||
const { session } = await parent();
|
|
||||||
return {
|
|
||||||
accessToken: session.accessToken,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user