- /account gains a Change password form (verify current, 8+ char new, confirm match) backed by updateUserPassword + verifyPassword/hashPassword. - Admin users resource gains a "Reset password" action that generates a fresh temp password, sets it immediately, and reveals it once in the panel (new temp-password action-result, reusing the copy-box UI) for the admin to send to the user. - Backstage top-left logo now links to the portal (main menu). Temp passwords are generated + hashed at request time; never stored in git or logged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
330 lines
12 KiB
Text
330 lines
12 KiB
Text
---
|
||
/* ---------------------------------------------------------------------------
|
||
* ResourceEditPanel — right-slide panel for create + edit.
|
||
*
|
||
* Rendered alongside ResourceListView when the URL carries ?edit=<id> or
|
||
* ?new=1. POSTs back to the same URL; the route handler in step 7 reads
|
||
* _action (save | delete | <action.key>) and dispatches.
|
||
*
|
||
* Visible-when predicates are evaluated server-side against current values
|
||
* (existing item or form defaults). Live-toggle while editing is Phase 2.
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
import FieldRenderer from './FieldRenderer.astro';
|
||
import PulseSubForm from '../embeds/PulseSubForm.astro';
|
||
import type { Field, FieldContext, Resource } from '../resource-types';
|
||
|
||
interface Props {
|
||
resource: Resource;
|
||
/** The item being edited, or null when creating. */
|
||
item: Record<string, unknown> | null;
|
||
/** Pre-validated form values from a failed prior submission (re-fill). */
|
||
formValues?: Record<string, unknown>;
|
||
errors?: Record<string, string>;
|
||
actingUserId: number;
|
||
}
|
||
|
||
const { resource, item, formValues, errors = {}, actingUserId } = Astro.props;
|
||
|
||
const isCreate = item === null;
|
||
// Review mode = the panel is showing an existing item AND either the resource
|
||
// has no form, OR it has a summary that should be preferred over the form
|
||
// (e.g. invitations are write-once: form is for create, summary is for edit).
|
||
const isReviewMode =
|
||
!isCreate &&
|
||
(resource.form === null || resource.summary !== undefined);
|
||
const singular = resource.singularLabel.toLowerCase();
|
||
const title = isReviewMode
|
||
? `Review ${singular}`
|
||
: isCreate
|
||
? `New ${singular}`
|
||
: `Edit ${singular}`;
|
||
|
||
// In review mode, the resource MUST have a summary function — that's what
|
||
// fills the panel body when the form is suppressed.
|
||
if (isReviewMode && !resource.summary) {
|
||
throw new Error(
|
||
`ResourceEditPanel: ${resource.key} is in review mode but has no summary — define resource.summary.`,
|
||
);
|
||
}
|
||
|
||
// And the create path needs a form.
|
||
if (isCreate && !resource.form) {
|
||
throw new Error(
|
||
`ResourceEditPanel: cannot render create panel for ${resource.key} — form is null.`,
|
||
);
|
||
}
|
||
|
||
// Initial form values: prior failed submission > existing item > defaults
|
||
const seedValues: Record<string, unknown> = { ...(item ?? {}), ...(formValues ?? {}) };
|
||
|
||
const ctx: FieldContext = {
|
||
formValues: seedValues,
|
||
item,
|
||
actingUserId,
|
||
};
|
||
|
||
function resolveDefault(field: Field): unknown {
|
||
if (field.defaultValue === undefined) return undefined;
|
||
if (typeof field.defaultValue === 'function') {
|
||
return (field.defaultValue as (c: FieldContext) => unknown)(ctx);
|
||
}
|
||
return field.defaultValue;
|
||
}
|
||
|
||
function valueFor(field: Field): unknown {
|
||
if (field.key in seedValues) return seedValues[field.key];
|
||
return resolveDefault(field);
|
||
}
|
||
|
||
const visibleFields = resource.form
|
||
? resource.form.fields.filter((f) => !f.visibleWhen || f.visibleWhen(ctx))
|
||
: [];
|
||
|
||
const embeds = resource.form?.embeds ?? [];
|
||
|
||
// Review-mode summary
|
||
const summaryEntries = isReviewMode && item
|
||
? resource.summary!(item)
|
||
: [];
|
||
|
||
// One-shot invite link surfaced after create/action — read from URL
|
||
const inviteUrl = Astro.url.searchParams.get('invite_url');
|
||
// One-shot temp password surfaced after a password reset — read from URL
|
||
const tempPassword = Astro.url.searchParams.get('temp_password');
|
||
|
||
// Build the close URL — drop edit/new but keep filter/q/page
|
||
const closeUrl = (() => {
|
||
const next = new URLSearchParams(Astro.url.searchParams);
|
||
next.delete('edit');
|
||
next.delete('new');
|
||
const s = next.toString();
|
||
return s ? `${Astro.url.pathname}?${s}` : Astro.url.pathname;
|
||
})();
|
||
|
||
// Actions visible for this item — only when editing an existing item
|
||
const actions = isCreate
|
||
? []
|
||
: (resource.actions ?? []).filter(
|
||
(a) => !a.visibleWhen || a.visibleWhen(item!),
|
||
);
|
||
|
||
// Form action URL: keep the panel-state params so a re-render after a
|
||
// validation failure stays on the same item.
|
||
const formAction = Astro.url.pathname + Astro.url.search;
|
||
---
|
||
|
||
<div class="bs-panel-scrim">
|
||
<a href={closeUrl} class="bs-panel-scrim-link" aria-label="Close panel"></a>
|
||
|
||
<aside class="bs-panel" role="dialog" aria-modal="true" aria-label={title}>
|
||
<header class="bs-panel-head">
|
||
<h2 class="bs-panel-title">{title}</h2>
|
||
<a href={closeUrl} class="bs-panel-close" aria-label="Close">×</a>
|
||
</header>
|
||
|
||
<form method="POST" action={formAction} class="bs-panel-form" id="bs-panel-form">
|
||
<div class="bs-panel-body">
|
||
{inviteUrl && (
|
||
<section class="bs-invite-result" data-invite-block>
|
||
<p class="bs-invite-result-label">Magic link — copy and send now. It will not be shown again.</p>
|
||
<div class="bs-invite-link-row">
|
||
<code class="bs-invite-link" id="bs-invite-link">{inviteUrl}</code>
|
||
<button type="button" class="bs-copy-btn" data-copy-target="#bs-invite-link">Copy</button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{tempPassword && (
|
||
<section class="bs-invite-result" data-temp-password-block>
|
||
<p class="bs-invite-result-label">New temporary password — copy and send it to the user. They can change it from their account page. It will not be shown again.</p>
|
||
<div class="bs-invite-link-row">
|
||
<code class="bs-invite-link" id="bs-temp-password">{tempPassword}</code>
|
||
<button type="button" class="bs-copy-btn" data-copy-target="#bs-temp-password">Copy</button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{isReviewMode ? (
|
||
<dl class="bs-summary">
|
||
{summaryEntries.map((entry) => (
|
||
<div class="bs-summary-row">
|
||
<dt class="bs-summary-label">{entry.label}</dt>
|
||
<dd class="bs-summary-value">{entry.value}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
) : (
|
||
<>
|
||
{visibleFields.map((field) => (
|
||
<FieldRenderer
|
||
field={field}
|
||
value={valueFor(field)}
|
||
error={errors[field.key]}
|
||
item={item}
|
||
/>
|
||
))}
|
||
|
||
{embeds.length > 0 && embeds.map((embed) => {
|
||
const show = !embed.visibleWhen || embed.visibleWhen(ctx);
|
||
if (!show) return null;
|
||
return (
|
||
<section class="bs-embed" data-embed={embed.key}>
|
||
<h3 class="bs-embed-title">{embed.title}</h3>
|
||
{embed.component === 'pulse-sub-form' && (
|
||
<PulseSubForm item={item} />
|
||
)}
|
||
</section>
|
||
);
|
||
})}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<footer class="bs-panel-foot">
|
||
<div class="bs-panel-foot-left">
|
||
{!isReviewMode && (
|
||
<button type="submit" name="_action" value="save" class="bs-panel-save">
|
||
{isCreate ? `Create ${singular}` : 'Save'}
|
||
</button>
|
||
)}
|
||
{actions.map((a) => (
|
||
<button
|
||
type="submit"
|
||
name="_action"
|
||
value={a.key}
|
||
class:list={['bs-panel-secondary', { destructive: a.destructive }]}
|
||
data-confirm={a.confirmText ?? null}
|
||
>
|
||
{a.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{!isCreate && !isReviewMode && resource.ops.delete && (
|
||
<button
|
||
type="submit"
|
||
name="_action"
|
||
value="delete"
|
||
class="bs-panel-delete"
|
||
data-confirm={`Delete this ${singular}? This cannot be undone.`}
|
||
>
|
||
Delete
|
||
</button>
|
||
)}
|
||
</footer>
|
||
</form>
|
||
</aside>
|
||
</div>
|
||
|
||
<script>
|
||
// ── Confirm-before-submit for buttons with data-confirm ──────────────────
|
||
document.addEventListener('click', (e) => {
|
||
const btn = (e.target as HTMLElement | null)?.closest('button[data-confirm]');
|
||
if (!(btn instanceof HTMLButtonElement)) return;
|
||
const text = btn.getAttribute('data-confirm');
|
||
if (text && !window.confirm(text)) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}
|
||
});
|
||
|
||
// ── Escape to close ──────────────────────────────────────────────────────
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key !== 'Escape') return;
|
||
const closer = document.querySelector('.bs-panel-close') as HTMLAnchorElement | null;
|
||
if (closer) closer.click();
|
||
});
|
||
|
||
// ── Markdown Write/Preview toggle ────────────────────────────────────────
|
||
// Re-renders preview from the textarea's *current* value on every toggle.
|
||
// The server-side initial render is only valid for the seed value — once
|
||
// the admin types, only client-side rendering reflects what's there.
|
||
document.querySelectorAll<HTMLElement>('.bs-md').forEach((root) => {
|
||
const tabs = root.querySelectorAll<HTMLButtonElement>('.bs-md-tab');
|
||
const input = root.querySelector<HTMLTextAreaElement>('.bs-md-input');
|
||
const preview = root.querySelector<HTMLElement>('.bs-md-preview');
|
||
if (!input || !preview) return;
|
||
tabs.forEach((tab) => {
|
||
tab.addEventListener('click', async () => {
|
||
const mode = tab.getAttribute('data-md-mode');
|
||
tabs.forEach((t) => t.classList.toggle('is-active', t === tab));
|
||
if (mode === 'preview') {
|
||
const value = input.value;
|
||
if (value.trim() === '') {
|
||
preview.innerHTML = '<p class="bs-md-empty">Nothing to preview yet.</p>';
|
||
} else {
|
||
const { marked } = await import('marked');
|
||
marked.setOptions({ breaks: true, gfm: true });
|
||
preview.innerHTML = await marked.parse(value);
|
||
}
|
||
input.hidden = true;
|
||
preview.hidden = false;
|
||
} else {
|
||
input.hidden = false;
|
||
preview.hidden = true;
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── Copy-to-clipboard for invite-link blocks ─────────────────────────────
|
||
document.querySelectorAll<HTMLButtonElement>('.bs-copy-btn').forEach((btn) => {
|
||
btn.addEventListener('click', async () => {
|
||
const sel = btn.getAttribute('data-copy-target');
|
||
const target = sel ? document.querySelector(sel) : null;
|
||
const text = target?.textContent ?? '';
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
const orig = btn.textContent;
|
||
btn.textContent = 'Copied';
|
||
setTimeout(() => { btn.textContent = orig; }, 1400);
|
||
} catch {
|
||
// clipboard blocked — leave the link visible for manual copy
|
||
}
|
||
});
|
||
});
|
||
|
||
// ── MultiTextField add / remove ──────────────────────────────────────────
|
||
document.querySelectorAll<HTMLElement>('.bs-multitext').forEach((root) => {
|
||
const rows = root.querySelector<HTMLElement>('.bs-multitext-rows');
|
||
const addBtn = root.querySelector<HTMLButtonElement>('.bs-multitext-add');
|
||
if (!rows || !addBtn) return;
|
||
const fieldKey = root.dataset.multitext ?? 'option';
|
||
const min = Number(root.dataset.min ?? '1');
|
||
const max = Number(root.dataset.max ?? '10');
|
||
|
||
function updateButtons() {
|
||
const inputs = rows!.querySelectorAll<HTMLElement>('.bs-multitext-row');
|
||
addBtn!.disabled = inputs.length >= max;
|
||
inputs.forEach((row) => {
|
||
const rm = row.querySelector<HTMLButtonElement>('.bs-multitext-remove');
|
||
if (rm) rm.disabled = inputs.length <= min;
|
||
});
|
||
}
|
||
|
||
rows.addEventListener('click', (e) => {
|
||
const rm = (e.target as HTMLElement | null)?.closest('.bs-multitext-remove');
|
||
if (!rm) return;
|
||
const row = rm.closest('.bs-multitext-row');
|
||
if (row && rows.querySelectorAll('.bs-multitext-row').length > min) {
|
||
row.remove();
|
||
updateButtons();
|
||
}
|
||
});
|
||
|
||
addBtn.addEventListener('click', () => {
|
||
const inputs = rows.querySelectorAll('.bs-multitext-row');
|
||
if (inputs.length >= max) return;
|
||
const row = document.createElement('div');
|
||
row.className = 'bs-multitext-row';
|
||
row.innerHTML =
|
||
`<input type="text" name="${fieldKey}" class="bs-input" value="" placeholder="Option ${inputs.length + 1}" />` +
|
||
`<button type="button" class="bs-multitext-remove" aria-label="Remove option">×</button>`;
|
||
rows.appendChild(row);
|
||
updateButtons();
|
||
});
|
||
|
||
updateButtons();
|
||
});
|
||
</script>
|