Files
optima/src/components/CreateCredentialModal.svelte
T
2026-02-22 19:12:13 -06:00

1162 lines
31 KiB
Svelte

<script lang="ts">
import { credential } from "$lib/optima-api/modules/credentials";
import type {
CredentialType,
CredentialTypeField,
} from "$lib/optima-api/modules/credentialTypes";
export let isOpen = false;
export let companyId: string;
export let accessToken: string;
export let credentialTypes: CredentialType[] = [];
export let onSuccess: () => void = () => {};
let credentialName = "";
let credentialNotes = "";
let selectedTypeId = "";
let fieldValues: Record<string, string> = {};
let isSubmitting = false;
let submitError = "";
let isLoadingTypes = false;
$: selectedType = credentialTypes.find((ct) => ct.id === selectedTypeId);
$: if (selectedType && selectedType.fields) {
fieldValues = {};
subCredentials = {};
selectedType.fields.forEach((field: CredentialTypeField) => {
if (field.valueType === "multi_credential") {
subCredentials[field.id] = [];
} else {
fieldValues[field.id] = "";
}
});
}
// Sub-credentials state: keyed by multi_credential field ID
let subCredentials: Record<
string,
Array<{ name: string; fields: Record<string, string> }>
> = {};
function addSubCredentialEntry(
fieldId: string,
subFields: CredentialTypeField[],
) {
const entry: { name: string; fields: Record<string, string> } = {
name: "",
fields: {},
};
subFields.forEach((sf) => {
entry.fields[sf.id] = "";
});
subCredentials[fieldId] = [...(subCredentials[fieldId] ?? []), entry];
subCredentials = subCredentials;
}
function removeSubCredentialEntry(fieldId: string, index: number) {
subCredentials[fieldId] = subCredentials[fieldId].filter(
(_, i) => i !== index,
);
subCredentials = subCredentials;
}
$: isValid =
credentialName.trim().length > 0 &&
selectedTypeId.length > 0 &&
(!selectedType ||
selectedType.fields.every(
(f: CredentialTypeField) =>
f.valueType === "multi_credential" ||
!f.required ||
(fieldValues[f.id] && fieldValues[f.id].trim()),
));
async function loadCredentialTypes() {
if (credentialTypes.length > 0) return;
isLoadingTypes = true;
try {
const response = await fetch(
`${import.meta.env.VITE_API_URL || "http://localhost:3000"}/v1/credential-type`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
const result = await response.json();
if (result.data) {
credentialTypes = Array.isArray(result.data)
? result.data
: [result.data];
}
} catch (err) {
console.error("Failed to load credential types:", err);
submitError = "Failed to load credential types";
} finally {
isLoadingTypes = false;
}
}
async function handleSubmit() {
if (!isValid || !selectedType) return;
isSubmitting = true;
submitError = "";
try {
const fields = selectedType.fields
.filter(
(field: CredentialTypeField) =>
field.valueType !== "multi_credential",
)
.map((field: CredentialTypeField) => ({
fieldId: field.id,
value: fieldValues[field.id] || "",
}));
// Build subCredentials payload for multi_credential fields
const subCredsPayload: Record<
string,
Array<{
name: string;
fields: Array<{ fieldId: string; value: string }>;
}>
> = {};
for (const [fieldId, entries] of Object.entries(subCredentials)) {
if (entries.length > 0) {
subCredsPayload[fieldId] = entries.map((entry) => ({
name: entry.name,
fields: Object.entries(entry.fields).map(([fid, val]) => ({
fieldId: fid,
value: val,
})),
}));
}
}
const createPayload: Record<string, unknown> = {
name: credentialName,
notes: credentialNotes || undefined,
typeId: selectedTypeId,
companyId,
fields,
};
if (Object.keys(subCredsPayload).length > 0) {
createPayload.subCredentials = subCredsPayload;
}
await credential.create(
accessToken,
createPayload as unknown as Omit<
import("$lib/optima-api/modules/credentials").Credential,
"id" | "createdAt" | "updatedAt"
>,
);
reset();
onSuccess();
} catch (err) {
submitError =
err instanceof Error ? err.message : "Failed to create credential";
console.error("Failed to create credential:", err);
} finally {
isSubmitting = false;
}
}
function reset() {
credentialName = "";
credentialNotes = "";
selectedTypeId = "";
fieldValues = {};
subCredentials = {};
isSubmitting = false;
submitError = "";
}
function handleClose() {
reset();
isOpen = false;
}
function handleBackdropClick() {
handleClose();
}
function handleBackdropKeydown(e: KeyboardEvent) {
if (e.key === "Escape") handleClose();
}
$: if (isOpen) {
loadCredentialTypes();
}
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="modal-backdrop"
on:click={handleBackdropClick}
on:keydown={handleBackdropKeydown}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="modal"
role="dialog"
aria-modal="true"
aria-label="Create Credential"
tabindex="-1"
on:click|stopPropagation
>
<div class="modal-header">
<div class="modal-title-group">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<h2>Create Credential</h2>
</div>
<button
class="close-btn"
on:click={handleClose}
type="button"
aria-label="Close modal"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div class="modal-body">
{#if submitError}
<div class="error-banner">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
{submitError}
</div>
{/if}
<div class="form-row">
<div class="form-group">
<label for="credential-name">
Name <span class="req">*</span>
</label>
<input
id="credential-name"
type="text"
bind:value={credentialName}
placeholder="e.g. Production AWS Credentials"
disabled={isSubmitting}
/>
</div>
<div class="form-group">
<label for="credential-type">
Credential Type <span class="req">*</span>
</label>
{#if isLoadingTypes}
<div class="loading-wrap">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
class="spin-icon"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
fill="none"
opacity="0.25"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
/>
</svg>
<span>Loading types…</span>
</div>
{:else}
<select
id="credential-type"
bind:value={selectedTypeId}
disabled={isSubmitting}
>
<option value="">Select a type</option>
{#each credentialTypes as type (type.id)}
<option value={type.id}>{type.name}</option>
{/each}
</select>
{/if}
</div>
</div>
<div class="form-group">
<label for="credential-notes">Notes</label>
<textarea
id="credential-notes"
bind:value={credentialNotes}
placeholder="Optional notes about this credential"
disabled={isSubmitting}
rows="2"
class="notes-textarea"
></textarea>
</div>
{#if selectedType && selectedType.fields && selectedType.fields.length > 0}
<div class="fields-section">
<div class="fields-section-header">
<span class="fields-label">Credential Fields</span>
<span class="fields-badge">
{selectedType.fields.length} field{selectedType.fields
.length === 1
? ""
: "s"}
</span>
</div>
<div class="fields-list">
{#each selectedType.fields as field (field.id)}
<div class="field-card">
<div class="field-card-header">
<span class="field-name-label">{field.name}</span>
<div class="field-header-badges">
{#if field.valueType === "multi_credential"}
<span class="field-badge field-badge-multi">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="10"
height="10"
>
<rect x="2" y="3" width="20" height="6" rx="1" />
<rect x="2" y="15" width="20" height="6" rx="1" />
<path d="M12 9v6" />
</svg>
Multi
</span>
{/if}
{#if field.required}
<span class="field-badge field-badge-required"
>Required</span
>
{/if}
{#if field.secure}
<span class="field-badge field-badge-secure">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="10"
height="10"
>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
ry="2"
/>
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
Secure
</span>
{/if}
</div>
</div>
<div class="field-card-body">
{#if field.valueType === "multi_credential" && field.subFields}
<!-- Multi-credential entries -->
<div class="multi-cred-section">
{#if (subCredentials[field.id] ?? []).length === 0}
<p class="multi-cred-empty">
No entries yet. Add an entry to define
sub-credentials for this field.
</p>
{:else}
{#each subCredentials[field.id] as entry, entryIdx}
<div class="multi-cred-entry">
<div class="multi-cred-entry-header">
<span class="multi-cred-entry-num"
>#{entryIdx + 1}</span
>
<button
type="button"
class="multi-cred-entry-remove"
on:click={() =>
removeSubCredentialEntry(
field.id,
entryIdx,
)}
disabled={isSubmitting}
aria-label="Remove entry"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<line x1="18" y1="6" x2="6" y2="18" /><line
x1="6"
y1="6"
x2="18"
y2="18"
/>
</svg>
</button>
</div>
<div class="multi-cred-entry-body">
<div class="multi-cred-entry-field">
<label
for="sub-cred-name-{field.id}-{entryIdx}"
>Entry Name <span class="req">*</span
></label
>
<input
id="sub-cred-name-{field.id}-{entryIdx}"
type="text"
bind:value={entry.name}
placeholder="e.g. Tunnel 1"
disabled={isSubmitting}
/>
</div>
{#each field.subFields as subField (subField.id)}
<div class="multi-cred-entry-field">
<label
for="sub-cred-{field.id}-{entryIdx}-{subField.id}"
>
{subField.name}
{#if subField.required}<span class="req"
>*</span
>{/if}
{#if subField.secure}
<svg
class="sub-cred-lock"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="10"
height="10"
>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
ry="2"
/>
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
{/if}
</label>
<input
id="sub-cred-{field.id}-{entryIdx}-{subField.id}"
type="text"
bind:value={entry.fields[subField.id]}
placeholder="Enter {subField.name.toLowerCase()}"
disabled={isSubmitting}
/>
</div>
{/each}
</div>
</div>
{/each}
{/if}
<button
type="button"
class="multi-cred-add-btn"
on:click={() =>
addSubCredentialEntry(
field.id,
field.subFields ?? [],
)}
disabled={isSubmitting}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<line x1="12" y1="5" x2="12" y2="19" /><line
x1="5"
y1="12"
x2="19"
y2="12"
/>
</svg>
Add Entry
</button>
</div>
{:else}
<input
id="field-{field.id}"
type="text"
bind:value={fieldValues[field.id]}
placeholder="Enter {field.name.toLowerCase()}"
disabled={isSubmitting}
/>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
<div class="modal-footer">
<button
type="button"
class="btn-cancel"
on:click={handleClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
type="button"
class="btn-submit"
on:click={handleSubmit}
disabled={!isValid || isSubmitting}
>
{isSubmitting ? "Creating…" : "Create Credential"}
</button>
</div>
</div>
</div>
{/if}
<style>
/* ── Backdrop ── */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: backdropIn 0.12s ease;
}
/* ── Modal shell ── */
.modal {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 14px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.28);
width: 92%;
max-width: 540px;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
animation: modalIn 0.15s ease;
}
/* ── Header ── */
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px 14px;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.modal-title-group {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
}
.modal-title-group h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.modal-title-group svg {
color: var(--text-muted);
flex-shrink: 0;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
background: none;
border: 1px solid transparent;
border-radius: 7px;
cursor: pointer;
color: var(--text-muted);
transition:
background 0.12s,
border-color 0.12s,
color 0.12s;
}
.close-btn:hover {
background: var(--card-hover-bg);
border-color: var(--border-subtle);
color: var(--text-primary);
}
/* ── Body ── */
.modal-body {
padding: 18px 22px;
overflow-y: auto;
flex: 1;
}
/* ── Error banner ── */
.error-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 16px;
background: rgba(220, 38, 38, 0.08);
border: 1px solid rgba(220, 38, 38, 0.2);
border-radius: 8px;
font-size: 13px;
color: #dc2626;
}
/* ── Form layout ── */
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 14px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 14px;
}
.form-row .form-group {
margin-bottom: 0;
}
label {
font-size: 12.5px;
font-weight: 600;
color: var(--text-secondary);
}
.req {
color: #dc2626;
}
input[type="text"],
select {
width: 100%;
padding: 7px 10px;
border: 1px solid var(--border-subtle);
border-radius: 7px;
background: var(--bg-inset);
font-size: 13px;
color: var(--text-primary);
box-sizing: border-box;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
input[type="text"]:focus,
select:focus {
outline: none;
border-color: var(--input-focus-border);
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
input[type="text"]::placeholder {
color: var(--text-muted);
}
.notes-textarea {
width: 100%;
padding: 7px 10px;
border: 1px solid var(--border-subtle);
border-radius: 7px;
background: var(--bg-inset);
font-size: 13px;
color: var(--text-primary);
box-sizing: border-box;
resize: vertical;
font-family: inherit;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.notes-textarea:focus {
outline: none;
border-color: var(--input-focus-border);
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
.notes-textarea::placeholder {
color: var(--text-muted);
}
.notes-textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
input:disabled,
select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Loading indicator ── */
.loading-wrap {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border: 1px solid var(--border-subtle);
border-radius: 7px;
background: var(--bg-inset);
font-size: 13px;
color: var(--text-muted);
}
.spin-icon {
animation: spin 0.8s linear infinite;
color: var(--text-muted);
}
/* ── Fields section ── */
.fields-section {
margin-top: 4px;
}
.fields-section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.fields-label {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.fields-badge {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 8px;
background: var(--status-neutral-bg);
color: var(--text-secondary);
border: 1px solid var(--border-subtle);
}
.fields-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.field-card {
border: 1px solid var(--border-subtle);
border-radius: 9px;
background: var(--bg-inset);
overflow: hidden;
}
.field-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border-subtle);
}
.field-name-label {
font-size: 12.5px;
font-weight: 600;
color: var(--text-primary);
}
.field-header-badges {
display: flex;
align-items: center;
gap: 6px;
}
.field-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
font-weight: 600;
padding: 2px 7px;
border-radius: 6px;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.field-badge-required {
background: rgba(220, 38, 38, 0.08);
color: #dc2626;
border: 1px solid rgba(220, 38, 38, 0.15);
}
.field-badge-secure {
background: rgba(59, 130, 246, 0.08);
color: #3b82f6;
border: 1px solid rgba(59, 130, 246, 0.15);
}
.field-card-body {
padding: 10px 12px;
}
.field-card-body input {
margin: 0;
}
/* ── Multi-credential entries ── */
.multi-cred-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.multi-cred-empty {
margin: 0;
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
}
.multi-cred-entry {
border: 1px solid var(--border-subtle);
border-radius: 7px;
background: var(--bg-surface);
overflow: hidden;
}
.multi-cred-entry-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 10px;
background: var(--bg-inset);
border-bottom: 1px solid var(--border-subtle);
}
.multi-cred-entry-num {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
}
.multi-cred-entry-remove {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: none;
border: 1px solid transparent;
border-radius: 5px;
cursor: pointer;
color: var(--text-muted);
transition:
background 0.1s,
color 0.1s;
}
.multi-cred-entry-remove:hover:not(:disabled) {
background: rgba(220, 38, 38, 0.08);
color: #dc2626;
}
.multi-cred-entry-remove:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.multi-cred-entry-body {
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.multi-cred-entry-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.multi-cred-entry-field label {
display: flex;
align-items: center;
gap: 4px;
}
.sub-cred-lock {
color: var(--text-muted);
flex-shrink: 0;
}
.multi-cred-add-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 14px;
background: none;
border: 1px dashed var(--border-subtle);
border-radius: 6px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
transition:
background 0.12s,
border-color 0.12s,
color 0.12s;
align-self: flex-start;
}
.multi-cred-add-btn:hover:not(:disabled) {
background: var(--card-hover-bg);
border-color: var(--accent-color, #0066cc);
color: var(--text-primary);
}
.multi-cred-add-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.field-badge-multi {
background: rgba(139, 92, 246, 0.08);
color: #8b5cf6;
border: 1px solid rgba(139, 92, 246, 0.15);
}
/* ── Footer ── */
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 14px 22px;
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.btn-cancel {
padding: 7px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
background: var(--bg-inset);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
transition:
background 0.15s,
border-color 0.15s;
}
.btn-cancel:hover:not(:disabled) {
background: var(--card-hover-bg);
border-color: var(--border-default);
color: var(--text-primary);
}
.btn-cancel:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-submit {
padding: 7px 18px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
background: var(--accent-color, #0066cc);
border: 1px solid transparent;
color: #fff;
transition: filter 0.15s;
}
.btn-submit:hover:not(:disabled) {
filter: brightness(1.1);
}
.btn-submit:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ── Animations ── */
@keyframes backdropIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modalIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ── Mobile fixes ── */
@media (max-width: 768px) {
.modal-backdrop {
align-items: flex-end;
}
.modal {
width: 100%;
max-width: 100%;
max-height: 92vh;
border-radius: 14px 14px 0 0;
transform: none;
animation: modalSheetIn 0.3s cubic-bezier(0.32, 0.72, 0, 1);
}
@keyframes modalSheetIn {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-body {
padding: 14px 16px;
}
.modal-header {
padding: 14px 16px 12px;
}
.modal-footer {
padding: 12px 16px;
}
/* Prevent iOS zoom on focus — inputs/selects must be >= 16px */
input[type="text"],
select {
font-size: 16px;
padding: 10px 12px;
border-radius: 8px;
}
select {
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2.5'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 32px;
}
/* Stack name + type vertically on mobile */
.form-row {
grid-template-columns: 1fr;
gap: 4px;
}
label {
font-size: 13px;
}
.field-card-body input {
font-size: 16px;
padding: 10px 12px;
}
.field-name-label {
font-size: 13px;
}
.btn-cancel,
.btn-submit {
padding: 10px 18px;
font-size: 14px;
}
}
</style>