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