project-bifrost-platform/src/admin/components/ResourceEditPanel.astro
Jonathan Hvid 096c9bc297 feat(auth): self-service password change + admin password reset
- /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>
2026-06-17 15:42:45 +02:00

330 lines
12 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
/* ---------------------------------------------------------------------------
* 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>