diff --git a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx index e544a82e..5a2306c3 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx @@ -113,14 +113,30 @@ function InviteRow({ invite }: InviteRowProps) { ); } -const EXPIRY_PRESETS: ReadonlyArray<{ label: string; ms: number | null }> = [ - { label: '1 hour', ms: 3_600_000 }, - { label: '24 hours', ms: 86_400_000 }, - { label: '7 days', ms: 7 * 86_400_000 }, - { label: '30 days', ms: 30 * 86_400_000 }, - { label: 'Never', ms: null }, +type ExpiryPresetId = '1h' | '24h' | '7d' | '30d' | 'never' | 'custom'; + +const EXPIRY_PRESETS: ReadonlyArray<{ id: ExpiryPresetId; label: string; ms: number | null }> = [ + { id: '1h', label: '1 hour', ms: 3_600_000 }, + { id: '24h', label: '24 hours', ms: 86_400_000 }, + { id: '7d', label: '7 days', ms: 7 * 86_400_000 }, + { id: '30d', label: '30 days', ms: 30 * 86_400_000 }, + { id: 'never', label: 'Never', ms: null }, + // Custom uses a free-form datetime input rendered below the preset row; + // ms is intentionally null and ignored for this id. + { id: 'custom', label: 'Custom…', ms: null }, ]; +/** + * Format a millisecond timestamp as a value suitable for ``. + * The input expects local-wall-clock time in `YYYY-MM-DDTHH:mm` format (no timezone suffix); + * the browser then interprets it in the user's local timezone on read-back via `new Date(value)`. + */ +function toDatetimeLocalValue(ms: number): string { + const d = new Date(ms); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + interface CreateInviteModalProps { onClose: () => void; onCreated: (created: InviteLinkSummary) => void; @@ -131,7 +147,8 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) { const [name, setName] = useState(''); const [unlimited, setUnlimited] = useState(true); const [maxUses, setMaxUses] = useState('1'); - const [expiryPreset, setExpiryPreset] = useState(7 * 86_400_000); + const [expiryId, setExpiryId] = useState('7d'); + const [customDateTime, setCustomDateTime] = useState(''); const [submitting, setSubmitting] = useState(false); const nameInputRef = useRef(null); @@ -167,7 +184,32 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) { } maxUsesNum = parsed; } - const expiresAt = expiryPreset === null ? null : Date.now() + expiryPreset; + + // Resolve expiresAt from the preset selection. Custom requires a non-empty, + // strictly-future datetime; everything else is derived from the preset's ms offset. + let expiresAt: number | null; + if (expiryId === 'never') { + expiresAt = null; + } else if (expiryId === 'custom') { + if (customDateTime === '') { + addToast('Pick a future date & time', 'warning'); + return; + } + const ts = new Date(customDateTime).getTime(); + if (!Number.isFinite(ts) || ts <= Date.now()) { + addToast('Pick a future date & time', 'warning'); + return; + } + expiresAt = ts; + } else { + const preset = EXPIRY_PRESETS.find((p) => p.id === expiryId); + if (!preset || preset.ms === null) { + // Unreachable: only 'never'/'custom' have null ms, and both are handled above. + addToast('Invalid expiry selection', 'warning'); + return; + } + expiresAt = Date.now() + preset.ms; + } setSubmitting(true); try { @@ -271,12 +313,12 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) {
{EXPIRY_PRESETS.map((p) => ( ))}
+ {expiryId === 'custom' && ( + setCustomDateTime(e.target.value)} + min={toDatetimeLocalValue(Date.now() + 60_000)} + disabled={submitting} + className="input-standard w-full px-3 py-2 text-sm mt-2" + /> + )} {/* Actions */}