SPB Git

Repository Public

RequestDialog.tsx 19.9 KB 228 lines tsx

1// Create a deposit portal on a folder, or edit an existing one. The public link exists only at creation or after a2// rotation: the server stores a hash, so it can never be shown again.3import { type CustomField, DEFAULT_FIELDS, type FieldMode } from '@spd/shared';4import { useQueryClient } from '@tanstack/react-query';5import { type FormEvent, useId, useRef, useState } from 'react';6import { ApiError, patch, post } from '../api.ts';7import { Glyph } from '../glyphs.tsx';8import { errorText, useT } from '../i18n.ts';9import { BLOCKED_EXT, inkOn, safeColor } from '../public-pages/util.ts';10import type { Portal } from '../types.ts';11import { Button, ConfirmDialog, cx, Dialog, Field, Select, Switch, TextArea, TextField, useToast } from '../ui.tsx';12import { fromLocalInput, inputCls, LinkOnce, NumberField, Section, toLocalInput } from './common.tsx';1314const MIB = 1024 ** 2, GIB = 1024 ** 3;15const PRESETS: Record<string, string[]> = {16  documents: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'txt', 'rtf', 'csv'],17  images: ['jpg', 'jpeg', 'png', 'heic', 'webp', 'gif', 'tif', 'tiff'],18  pdf: ['pdf'],19  any: [],20};21const SWATCHES = ['#2563eb', '#0f766e', '#15803d', '#b45309', '#b91c1c', '#7c3aed', '#be185d', '#111827'];22const sameSet = (a: string[], b: string[]) => a.length === b.length && a.every((x) => b.includes(x));23/** Field key the API accepts ([a-z0-9_]{1,30}), derived from the label and unique within the form. */24function keyFor(label: string, taken: Set<string>): string {25  const stem = label.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 30) || 'champ';26  let k = stem;27  for (let i = 2; taken.has(k); i++) k = `${stem.slice(0, 27)}_${i}`;28  taken.add(k);29  return k;30}31const num = (n: number) => String(Math.round(n * 1000) / 1000);3233export function RequestDialog({ folder, portal, onClose }: { folder: { id: string; name: string }; portal?: Portal; onClose: () => void }) {34  const t = useT();35  const toast = useToast();36  const qc = useQueryClient();37  const formId = useId();38  const form = useRef<HTMLFormElement>(null);39  const [s, setS] = useState(() => ({40    title: portal?.title ?? '', description: portal?.description ?? '', instructions: portal?.instructions ?? '', brandName: portal?.brandName ?? '', brandColor: safeColor(portal?.brandColor),41    password: '', dropPassword: false, expires: toLocalInput(portal?.expiresAt), active: portal?.active ?? true,42    maxFileMb: num((portal?.maxFileBytes ?? 200 * MIB) / MIB), maxFiles: String(portal?.maxFilesPerSubmission ?? 20), maxTotalGb: num((portal?.maxTotalBytes ?? 5 * GIB) / GIB),43    maxSubmissions: portal?.maxSubmissions?.toString() ?? '', perHour: String(portal?.submissionsPerHour ?? 20), extensions: portal?.allowedExtensions ?? [],44    name: (portal?.fields ?? DEFAULT_FIELDS).name, email: (portal?.fields ?? DEFAULT_FIELDS).email, reference: (portal?.fields ?? DEFAULT_FIELDS).reference,45    custom: (portal?.fields.custom ?? []).map((c) => ({ ...c })) as (Omit<CustomField, 'key'> & { key?: string })[],46    requireConsent: portal?.requireConsent ?? false, consentText: portal?.consentText ?? '', retentionNote: portal?.retentionNote ?? '',47    subfolder: portal?.subfolderPerSubmission ?? true, notify: portal?.notifyOwner ?? true, confirmation: portal?.confirmationMessage ?? '',48  }));49  const [ext, setExt] = useState('');50  const [errors, setErrors] = useState<Record<string, string>>({});51  const [busy, setBusy] = useState(false);52  const [url, setUrl] = useState<string | null>(null);53  const [rotated, setRotated] = useState(false);54  const [confirmRotate, setConfirmRotate] = useState(false);55  const up = <K extends keyof typeof s>(k: K, v: (typeof s)[K]) => { setS((cur) => ({ ...cur, [k]: v })); if (errors[k]) setErrors(({ [k]: _, ...rest }) => rest); };5657  const addExt = (raw: string) => {58    const list = raw.split(/[\s,;]+/).map((x) => x.trim().replace(/^\./, '').toLowerCase()).filter(Boolean);59    if (!list.length) return;60    const bad = list.find((x) => !/^[a-z0-9]{1,10}$/.test(x)), blocked = list.find((x) => BLOCKED_EXT.has(x));61    if (bad) { setErrors((e) => ({ ...e, ext: t('sharing.portal.extBad', { ext: bad }) })); return; }62    if (blocked) { setErrors((e) => ({ ...e, ext: t('sharing.portal.extBlocked', { ext: blocked }) })); return; }63    if (new Set([...s.extensions, ...list]).size > 60) { setErrors((e) => ({ ...e, ext: t('sharing.portal.extMany') })); return; }64    up('extensions', [...new Set([...s.extensions, ...list])]); setExt(''); setErrors(({ ext: _, ...rest }) => rest);65  };6667  const submit = async (e: FormEvent) => {68    e.preventDefault();69    const errs: Record<string, string> = {};70    const range = (v: string, k: string, min: number, max: number, int = true) => { const n = Number(v); if (!v.trim() || Number.isNaN(n) || n < min || n > max || (int && !Number.isInteger(n))) { errs[k] = t('sharing.portal.range', { min, max }); return min; } return n; };71    if (!s.title.trim()) errs.title = t('sharing.portal.titleRequired');72    if (s.password && s.password.length < 4) errs.password = t('sharing.form.passwordShort');73    const expiresAt = fromLocalInput(s.expires);74    const expiryChanged = toLocalInput(portal?.expiresAt) !== s.expires;75    if (expiresAt && expiryChanged && new Date(expiresAt) <= new Date()) errs.expires = t('common.error.bad_expiry');76    const mb = range(s.maxFileMb, 'maxFileMb', 1, 2048, false), files = range(s.maxFiles, 'maxFiles', 1, 200), gb = range(s.maxTotalGb, 'maxTotalGb', 0.001, 100_000, false), perHour = range(s.perHour, 'perHour', 1, 1000);77    const maxSubmissions = s.maxSubmissions.trim() ? range(s.maxSubmissions, 'maxSubmissions', 1, 1_000_000) : null;78    const taken = new Set(s.custom.map((c) => c.key).filter((k): k is string => !!k));79    const custom: CustomField[] = [];80    s.custom.forEach((c, i) => { if (!c.label.trim()) errs[`custom${i}`] = t('sharing.portal.customLabelRequired'); else custom.push({ key: c.key ?? keyFor(c.label, taken), label: c.label.trim(), required: c.required }); });81    setErrors(errs);82    if (Object.keys(errs).length) { requestAnimationFrame(() => form.current?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()); return; }83    const body = {84      title: s.title.trim(), description: s.description, instructions: s.instructions, confirmationMessage: s.confirmation, brandName: s.brandName, brandColor: s.brandColor,85      password: s.password ? s.password : portal ? (s.dropPassword ? null : undefined) : undefined, expiresAt: portal && !expiryChanged ? undefined : expiresAt, active: s.active,86      maxFileBytes: Math.min(2048 * MIB, Math.round(mb * MIB)), maxTotalBytes: Math.round(gb * GIB), maxFilesPerSubmission: files, maxSubmissions, submissionsPerHour: perHour, allowedExtensions: s.extensions,87      fields: { name: s.name, email: s.email, reference: s.reference, custom }, requireConsent: s.requireConsent, consentText: s.consentText, retentionNote: s.retentionNote, subfolderPerSubmission: s.subfolder, notifyOwner: s.notify,88    };89    setBusy(true);90    try {91      if (portal) { await patch(`/requests/${portal.id}`, body); await qc.invalidateQueries({ queryKey: ['requests'] }); toast(t('sharing.saved'), { tone: 'ok' }); onClose(); }92      else { const r = await post<{ request: Portal; url: string }>('/requests', { ...body, folderId: folder.id }); void qc.invalidateQueries({ queryKey: ['requests'] }); setUrl(r.url); }93    } catch (x) {94      setBusy(false);95      if (x instanceof ApiError && x.code === 'bad_expiry') setErrors({ expires: errorText(x) }); else setErrors({ form: errorText(x) });96    }97  };9899  if (url) return (100    <Dialog title={rotated ? t('sharing.link.newTitle') : t('sharing.portal.createdTitle')} onClose={onClose} footer={<Button variant="primary" onClick={onClose}>{t('sharing.link.done')}</Button>}>101      <div className="space-y-4" data-testid="portal-created">102        <p className="flex items-center gap-2 text-sm font-medium text-ok"><Glyph name="check" size={18} />{rotated ? t('sharing.portal.rotated') : t('sharing.portal.created', { folder: folder.name })}</p>103        <LinkOnce url={url} title={s.title} qr />104      </div>105    </Dialog>106  );107108  const ink = inkOn(s.brandColor);109  const modes: FieldMode[] = ['off', 'optional', 'required'];110  const preset = Object.keys(PRESETS).find((k) => sameSet(PRESETS[k]!, s.extensions));111  return (112    <Dialog title={portal ? t('sharing.portal.editTitle') : t('sharing.portal.newTitle')} onClose={onClose} wide113      footer={<><Button onClick={onClose}>{t('common.cancel')}</Button><Button type="submit" form={formId} variant="primary" busy={busy} icon={portal ? 'check' : 'inbox'}>{portal ? t('common.save') : t('sharing.portal.create')}</Button></>}>114      <form id={formId} ref={form} onSubmit={submit} noValidate className="space-y-5" data-testid="portal-form">115        <p className="flex items-center gap-2 rounded-xl bg-hover px-3 py-2 text-sm text-ink-2"><Glyph name="folder" size={18} className="shrink-0 text-accent" /><span className="min-w-0 truncate">{t('sharing.portal.target', { folder: folder.name })}</span></p>116117        <Section title={t('sharing.portal.sPresentation')}>118          <TextField label={t('sharing.portal.title')} value={s.title} maxLength={120} onChange={(e) => up('title', e.target.value)} error={errors.title} required data-autofocus placeholder={t('sharing.portal.titlePh')} />119          <TextArea label={t('sharing.portal.description')} hint={t('sharing.portal.descriptionHint')} value={s.description} maxLength={2000} rows={2} onChange={(e) => up('description', e.target.value)} />120          <TextArea label={t('sharing.portal.instructions')} hint={t('sharing.portal.instructionsHint')} value={s.instructions} maxLength={4000} rows={4} onChange={(e) => up('instructions', e.target.value)} />121        </Section>122123        <Section title={t('sharing.portal.sAppearance')}>124          <TextField label={t('sharing.portal.brandName')} hint={t('sharing.portal.brandNameHint')} value={s.brandName} maxLength={80} onChange={(e) => up('brandName', e.target.value)} />125          <Field label={t('sharing.portal.brandColor')}>{(id) => (126            <div className="flex flex-wrap items-center gap-2">127              <input id={id} type="color" value={s.brandColor} onChange={(e) => up('brandColor', e.target.value)} className="h-10 w-14 cursor-pointer rounded-xl border border-line-strong bg-surface p-1" />128              {SWATCHES.map((c) => <button key={c} type="button" aria-label={c} aria-pressed={s.brandColor.toLowerCase() === c} onClick={() => up('brandColor', c)} style={{ background: c }} className={cx('h-8 w-8 rounded-full border-2', s.brandColor.toLowerCase() === c ? 'border-ink' : 'border-transparent')} />)}129            </div>130          )}</Field>131          <div aria-label={t('sharing.portal.preview')} role="img" className="overflow-hidden rounded-2xl border border-line" data-testid="brand-preview">132            <div className="px-4 py-4" style={{ background: s.brandColor, color: ink }}>133              {s.brandName ? <p className="text-xs font-semibold opacity-90">{s.brandName}</p> : null}134              <p className="text-lg leading-tight font-semibold break-words">{s.title || t('sharing.portal.titlePh')}</p>135              {s.description ? <p className="mt-1 line-clamp-2 text-xs opacity-95">{s.description}</p> : null}136            </div>137            <p className="bg-surface px-4 py-2 text-xs text-ink-3">{t('sharing.portal.previewHint')}</p>138          </div>139        </Section>140141        <Section title={t('sharing.portal.sAccess')}>142          <Switch label={t('sharing.portal.active')} hint={s.active ? t('sharing.portal.activeOn') : t('sharing.portal.activeOff')} checked={s.active} onChange={(v) => up('active', v)} />143          <TextField label={portal?.hasPassword ? t('sharing.form.passwordChange') : t('sharing.form.password')} hint={portal?.hasPassword ? t('sharing.form.passwordKeep') : t('sharing.portal.passwordHint')} value={s.password} maxLength={200}144            onChange={(e) => up('password', e.target.value)} error={errors.password} autoComplete="off" autoCapitalize="none" spellCheck={false} disabled={s.dropPassword} />145          {portal?.hasPassword ? <Switch label={t('sharing.form.passwordRemove')} checked={s.dropPassword} onChange={(v) => setS((c) => ({ ...c, dropPassword: v, password: v ? '' : c.password }))} /> : null}146          <Field label={t('sharing.portal.expires')} hint={t('sharing.portal.expiresHint')} error={errors.expires}>{(id, d) => (147            <div className="flex gap-2">148              <input id={id} aria-describedby={d} aria-invalid={!!errors.expires || undefined} type="datetime-local" value={s.expires} onChange={(e) => up('expires', e.target.value)} className={inputCls} />149              {s.expires ? <Button onClick={() => up('expires', '')}>{t('sharing.portal.noExpiry')}</Button> : null}150            </div>151          )}</Field>152          {portal && !portal.revokedAt ? (153            <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-line px-3 py-2.5">154              <p className="min-w-0 flex-1 text-xs text-ink-2">{t('sharing.portal.linkHidden')}</p>155              <Button size="sm" icon="refresh" onClick={() => setConfirmRotate(true)}>{t('sharing.portal.rotate')}</Button>156            </div>157          ) : null}158        </Section>159160        <Section title={t('sharing.portal.sLimits')}>161          <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">162            <NumberField label={t('sharing.portal.maxFileMb')} hint={t('sharing.portal.maxFileMbHint')} value={s.maxFileMb} onChange={(v) => up('maxFileMb', v)} max={2048} step="any" suffix={t('sharing.portal.mb')} error={errors.maxFileMb} />163            <NumberField label={t('sharing.portal.maxFiles')} value={s.maxFiles} onChange={(v) => up('maxFiles', v)} max={200} error={errors.maxFiles} />164            <NumberField label={t('sharing.portal.maxTotalGb')} hint={t('sharing.portal.maxTotalGbHint')} value={s.maxTotalGb} onChange={(v) => up('maxTotalGb', v)} min={0.001} step="any" suffix={t('sharing.portal.gb')} error={errors.maxTotalGb} />165            <NumberField label={t('sharing.portal.maxSubmissions')} placeholder={t('sharing.form.unlimited')} value={s.maxSubmissions} onChange={(v) => up('maxSubmissions', v)} max={1_000_000} error={errors.maxSubmissions} />166            <NumberField label={t('sharing.portal.perHour')} hint={t('sharing.portal.perHourHint')} value={s.perHour} onChange={(v) => up('perHour', v)} max={1000} error={errors.perHour} />167          </div>168        </Section>169170        <Section title={t('sharing.portal.sTypes')} hint={t('sharing.portal.typesHint')}>171          <div role="group" aria-label={t('sharing.portal.presets')} className="flex flex-wrap gap-1.5">172            {Object.keys(PRESETS).map((k) => (173              <button key={k} type="button" aria-pressed={preset === k} onClick={() => { up('extensions', PRESETS[k]!); setErrors(({ ext: _, ...rest }) => rest); }}174                className={cx('h-9 rounded-full border px-3 text-sm transition', preset === k ? 'border-accent bg-accent-soft font-medium text-accent' : 'border-line-strong text-ink-2 hover:bg-hover')}>{t(`sharing.portal.preset.${k}`)}</button>175            ))}176          </div>177          {s.extensions.length ? (178            <ul className="flex flex-wrap gap-1.5" aria-label={t('sharing.portal.extList')} data-testid="ext-chips">179              {s.extensions.map((x) => (180                <li key={x} className="inline-flex items-center gap-1 rounded-full bg-hover py-1 pr-1 pl-2.5 text-xs font-medium text-ink">.{x}181                  <button type="button" aria-label={t('sharing.portal.extRemove', { ext: x })} onClick={() => up('extensions', s.extensions.filter((y) => y !== x))} className="inline-flex h-5 w-5 items-center justify-center rounded-full text-ink-3 hover:bg-line hover:text-ink"><Glyph name="x" size={12} /></button>182                </li>183              ))}184            </ul>185          ) : <p className="text-sm text-ink-2">{t('sharing.portal.extAny')}</p>}186          <Field label={t('sharing.portal.extAdd')} hint={t('sharing.portal.extAddHint')} error={errors.ext}>{(id, d) => (187            <div className="flex gap-2">188              <input id={id} aria-describedby={d} value={ext} onChange={(e) => setExt(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addExt(ext); } }} placeholder="pdf, docx" autoCapitalize="none" spellCheck={false} className={inputCls} />189              <Button icon="plus" onClick={() => addExt(ext)}>{t('sharing.portal.extAddBtn')}</Button>190            </div>191          )}</Field>192        </Section>193194        <Section title={t('sharing.portal.sForm')} hint={t('sharing.portal.formHint')}>195          <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">196            {(['name', 'email', 'reference'] as const).map((k) => (197              <Select key={k} label={t(`sharing.portal.field.${k}`)} value={s[k]} onChange={(e) => up(k, e.target.value as FieldMode)}>{modes.map((m) => <option key={m} value={m}>{t(`sharing.portal.mode.${m}`)}</option>)}</Select>198            ))}199          </div>200          {s.custom.map((c, i) => (201            <div key={i} className="flex items-end gap-2" data-testid="custom-field">202              <div className="min-w-0 flex-1"><TextField label={t('sharing.portal.customLabel', { n: i + 1 })} value={c.label} maxLength={80} error={errors[`custom${i}`]} onChange={(e) => { const v = e.target.value; setS((cur) => ({ ...cur, custom: cur.custom.map((x, j) => (j === i ? { ...x, label: v } : x)) })); }} /></div>203              <label className="flex h-10 shrink-0 items-center gap-2 text-sm"><input type="checkbox" className="h-4 w-4" checked={c.required} onChange={(e) => { const v = e.target.checked; setS((cur) => ({ ...cur, custom: cur.custom.map((x, j) => (j === i ? { ...x, required: v } : x)) })); }} />{t('sharing.portal.mode.required')}</label>204              <Button variant="ghost" icon="trash" aria-label={t('sharing.portal.customRemove', { n: i + 1 })} className="px-2.5" onClick={() => setS((cur) => ({ ...cur, custom: cur.custom.filter((_, j) => j !== i) }))} />205            </div>206          ))}207          {s.custom.length < 10 ? <Button icon="plus" onClick={() => setS((cur) => ({ ...cur, custom: [...cur.custom, { label: '', required: false }] }))}>{t('sharing.portal.customAdd')}</Button> : null}208        </Section>209210        <Section title={t('sharing.portal.sConsent')}>211          <Switch label={t('sharing.portal.requireConsent')} hint={t('sharing.portal.requireConsentHint')} checked={s.requireConsent} onChange={(v) => up('requireConsent', v)} />212          {s.requireConsent ? <TextArea label={t('sharing.portal.consentText')} hint={t('sharing.portal.consentTextHint')} value={s.consentText} maxLength={2000} rows={3} onChange={(e) => up('consentText', e.target.value)} /> : null}213          <TextArea label={t('sharing.portal.retentionNote')} hint={t('sharing.portal.retentionNoteHint')} value={s.retentionNote} maxLength={1000} rows={2} onChange={(e) => up('retentionNote', e.target.value)} />214        </Section>215216        <Section title={t('sharing.portal.sAfter')}>217          <Switch label={t('sharing.portal.subfolder')} hint={s.subfolder ? t('sharing.portal.subfolderOn') : t('sharing.portal.subfolderOff')} checked={s.subfolder} onChange={(v) => up('subfolder', v)} />218          <Switch label={t('sharing.portal.notify')} hint={t('sharing.portal.notifyHint')} checked={s.notify} onChange={(v) => up('notify', v)} />219          <TextArea label={t('sharing.portal.confirmation')} hint={t('sharing.portal.confirmationHint')} value={s.confirmation} maxLength={1000} rows={2} onChange={(e) => up('confirmation', e.target.value)} />220        </Section>221        {errors.form ? <p role="alert" className="rounded-xl bg-danger-soft px-3 py-2 text-sm text-danger">{errors.form}</p> : null}222      </form>223      {confirmRotate && portal ? <ConfirmDialog title={t('sharing.portal.rotate')} text={t('sharing.portal.rotateText')} confirmLabel={t('sharing.item.rotateConfirm')} onClose={() => setConfirmRotate(false)}224        onConfirm={async () => { const r = await post<{ url: string }>(`/requests/${portal.id}/rotate`); setRotated(true); setUrl(r.url); }} /> : null}225    </Dialog>226  );227}228
Files main
Menu

Theme