Add shared critical confirmation popup for destructive actions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 23s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-07-11 22:50:51 +03:00
parent 387cc273e8
commit 65bf88bf41
8 changed files with 256 additions and 127 deletions

View File

@@ -13,6 +13,7 @@ import {
import { formatBytes } from '../utils/format.js';
import { instructionBlocks } from '../instructions.js';
import { createLatestRequest, operationBlocked } from '../state/operations.js';
import { ConfirmationPopup } from './ConfirmationPopup.jsx';
import { canAppendRouteRule } from '../../shared/routingRules.js';
import {
HARBOR_VERSIONS,
@@ -326,7 +327,6 @@ function LocalRulesPanel({
blocked,
dirty,
restartPending,
confirmingClose,
error,
operations,
panelRef,
@@ -334,8 +334,6 @@ function LocalRulesPanel({
onChange,
onRemove,
onRemoveComplete,
onKeepEditing,
onDiscard,
onClose,
onSave,
}) {
@@ -373,16 +371,6 @@ function LocalRulesPanel({
</header>
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={onSave}>
{confirmingClose && (
<section className="client-local-rules-discard" role="alert">
<strong>Есть несохранённые настройки</strong>
<span>Закрыть редактор и потерять изменения?</span>
<div>
<button type="button" onClick={onKeepEditing}>Остаться</button>
<button type="button" className="is-danger" onClick={onDiscard}>Закрыть без сохранения</button>
</div>
</section>
)}
<section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
<span id="local-rules-list-title">Правила</span>
<div className="client-local-rules-list">
@@ -1014,7 +1002,9 @@ export function ClientOverviewPage({
}
return (
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
<div
className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}
>
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
<HarborBrand
isGateway={isGateway}
@@ -1200,28 +1190,12 @@ export function ClientOverviewPage({
)}
<div
className={`client-form${subscriptionWaiting ? ' is-waiting' : ''}${confirmingDelete ? ' is-confirming-delete' : ''}`}
className={`client-form${subscriptionWaiting ? ' is-waiting' : ''}`}
aria-hidden={subscriptionWaiting}
aria-disabled={gatewayDirect}
inert={subscriptionWaiting || gatewayDirect ? true : undefined}
>
{confirmingDelete && <section
className="client-delete-confirmation"
role="group"
aria-labelledby="delete-subscription-title"
aria-describedby="delete-subscription-description"
>
<span>Необратимое действие</span>
<h2 id="delete-subscription-title">Удалить подписку?</h2>
<p id="delete-subscription-description">
Harbor остановит VPN и удалит сохранённую подписку. Приложения, настроенные на локальный прокси, не смогут выходить в сеть до добавления новой подписки.
</p>
<div className="client-delete-actions">
<button type="button" onClick={() => setConfirmingDelete(false)}>Отмена</button>
<button className="is-danger" type="button" disabled={subscriptionDeleteBlocked} onClick={forgetSubscription}>Удалить</button>
</div>
</section>}
<div className="client-form-content" inert={confirmingDelete ? true : undefined}>
<div className="client-form-content">
<div
ref={subscriptionRef}
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
@@ -1434,7 +1408,6 @@ export function ClientOverviewPage({
blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)}
dirty={localRulesDirty}
restartPending={state?.route?.localRulesPendingRestart === true}
confirmingClose={confirmingLocalRulesClose}
error={error}
operations={operations}
panelRef={localRulesPanelRef}
@@ -1445,12 +1418,31 @@ export function ClientOverviewPage({
onChange={changeLocalRule}
onRemove={(index) => removeLocalRule(localRulesDraft[index]._key)}
onRemoveComplete={finishRemoveLocalRule}
onKeepEditing={() => setConfirmingLocalRulesClose(false)}
onDiscard={discardLocalRules}
onClose={requestCloseLocalRules}
onSave={saveLocalRules}
/>}
<ConfirmationPopup
open={confirmingLocalRulesClose}
id="discard-local-rules"
title="Есть несохранённые настройки"
description="Закрыть редактор и потерять изменения?"
cancelLabel="Остаться"
confirmLabel="Закрыть без сохранения"
onCancel={() => setConfirmingLocalRulesClose(false)}
onConfirm={discardLocalRules}
/>
<ConfirmationPopup
open={confirmingDelete}
id="delete-subscription"
kicker="Необратимое действие"
title="Удалить подписку?"
description="Harbor остановит VPN и удалит сохранённую подписку. Приложения с локальным прокси потеряют соединение до добавления новой подписки."
cancelLabel="Отмена"
confirmLabel="Удалить"
busy={subscriptionDeleteBlocked}
onCancel={() => setConfirmingDelete(false)}
onConfirm={forgetSubscription}
/>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
const FOCUSABLE = 'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])';
export function ConfirmationPopup({
open,
id,
kicker,
title,
description,
cancelLabel,
confirmLabel,
busy = false,
onCancel,
onConfirm,
}) {
const overlayRef = useRef(null);
const dialogRef = useRef(null);
const cancelRef = useRef(null);
const busyRef = useRef(busy);
const onCancelRef = useRef(onCancel);
busyRef.current = busy;
onCancelRef.current = onCancel;
useEffect(() => {
if (!open) return undefined;
const previousFocus = document.activeElement;
const background = [...(overlayRef.current?.parentElement?.children || [])]
.filter((element) => !element.classList.contains('client-confirmation-popup'))
.map((element) => [element, element.inert]);
background.forEach(([element]) => { element.inert = true; });
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const frame = requestAnimationFrame(() => cancelRef.current?.focus());
const onKeyDown = (event) => {
if (event.key === 'Escape' && !busyRef.current) {
event.preventDefault();
onCancelRef.current();
return;
}
if (event.key !== 'Tab') return;
const controls = [...(dialogRef.current?.querySelectorAll(FOCUSABLE) || [])];
if (!controls.length) return;
const first = controls[0];
const last = controls.at(-1);
if (!dialogRef.current?.contains(document.activeElement)) {
event.preventDefault();
first.focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('keydown', onKeyDown);
background.forEach(([element, inert]) => { element.inert = inert; });
document.body.style.overflow = previousOverflow;
requestAnimationFrame(() => previousFocus?.focus?.());
};
}, [open]);
return createPortal(
<div
ref={overlayRef}
className={`client-confirmation-popup${open ? ' is-open' : ''}`}
aria-hidden={!open}
inert={!open ? true : undefined}
onPointerDown={(event) => {
if (event.target === event.currentTarget && !busy) onCancel();
}}
>
<section
ref={dialogRef}
className="client-confirmation-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-description`}
aria-busy={busy}
>
{kicker && <span className="client-confirmation-kicker">{kicker}</span>}
<h2 id={`${id}-title`}>{title}</h2>
<p id={`${id}-description`}>{description}</p>
<div className="client-confirmation-actions">
<button ref={cancelRef} type="button" disabled={busy} onClick={onCancel}>{cancelLabel}</button>
<button className="is-danger" type="button" disabled={busy} onClick={onConfirm}>{confirmLabel}</button>
</div>
</section>
</div>,
document.querySelector('.app.client-app') || document.body,
);
}