Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions web/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export default tseslint.config(
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
// Warn on patterns that often indicate unbatched state updates (#3049)
// Encourages useReducer or single-object setState for related state
'no-restricted-globals': ['error',
{ name: 'alert', message: 'Use ConfirmDialog or Toast instead of browser alert().' },
{ name: 'confirm', message: 'Use ConfirmDialog instead of browser confirm().' },
{ name: 'prompt', message: 'Use a styled input modal instead of browser prompt().' },
],
Comment on lines +29 to +33
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new no-restricted-globals rule will block confirm()/alert()/prompt() as bare globals, but it will not catch the pattern previously used in this codebase (window.confirm(...)). To actually prevent regressions, add a restriction for window.confirm/alert/prompt as well (e.g., no-restricted-properties for window/globalThis, or a no-restricted-syntax selector targeting MemberExpression[property.name="confirm"]).

Copilot uses AI. Check for mistakes.
'no-restricted-syntax': ['warn',
{
selector: 'CallExpression[callee.name=/^set[A-Z]/] + CallExpression[callee.name=/^set[A-Z]/]',
Expand Down
21 changes: 19 additions & 2 deletions web/src/components/alerts/AlertRuleEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Trash2, Server, Bell, BellOff, Bot, Slack, Webhook, Siren, ShieldAlert } from 'lucide-react'
import { useClusters } from '../../hooks/useMCP'
import { BaseModal } from '../../lib/modals'
import { BaseModal, ConfirmDialog } from '../../lib/modals'
import type {
AlertRule,
AlertCondition,
Expand Down Expand Up @@ -80,10 +80,17 @@ export function AlertRuleEditor({ isOpen = true, rule, onSave, onCancel }: Alert

// Validation
const [errors, setErrors] = useState<Record<string, string>>({})
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)

const forceClose = () => {
setShowDiscardConfirm(false)
onCancel()
}

const handleClose = () => {
const hasChanges = name.trim() !== '' || description.trim() !== ''
if (hasChanges && !window.confirm(t('common:common.discardUnsavedChanges', 'Discard unsaved changes?'))) {
if (hasChanges) {
setShowDiscardConfirm(true)
return
}
onCancel()
Expand Down Expand Up @@ -176,6 +183,16 @@ export function AlertRuleEditor({ isOpen = true, rule, onSave, onCancel }: Alert

return (
<BaseModal isOpen={isOpen} onClose={handleClose} size="lg" closeOnBackdrop={false} closeOnEscape={true}>
<ConfirmDialog
isOpen={showDiscardConfirm}
onClose={() => setShowDiscardConfirm(false)}
onConfirm={forceClose}
title={t('common:common.discardUnsavedChanges', 'Discard unsaved changes?')}
message={t('common:common.discardUnsavedChangesMessage', 'You have unsaved changes that will be lost.')}
confirmLabel={t('common:common.discard', 'Discard')}
cancelLabel={t('common:common.keepEditing', 'Keep editing')}
Comment on lines +190 to +193
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The i18n keys used here (common.discardUnsavedChanges, common.discardUnsavedChangesMessage, common.discard, common.keepEditing) don't exist in web/src/locales/en/common.json, so the UI will always fall back to the default strings. Consider adding these keys to common.json (preferred) or switching to existing common action keys to keep translations consistent.

Suggested change
title={t('common:common.discardUnsavedChanges', 'Discard unsaved changes?')}
message={t('common:common.discardUnsavedChangesMessage', 'You have unsaved changes that will be lost.')}
confirmLabel={t('common:common.discard', 'Discard')}
cancelLabel={t('common:common.keepEditing', 'Keep editing')}
title={t('common:discardUnsavedChanges', 'Discard unsaved changes?')}
message={t('common:discardUnsavedChangesMessage', 'You have unsaved changes that will be lost.')}
confirmLabel={t('common:discard', 'Discard')}
cancelLabel={t('common:keepEditing', 'Keep editing')}

Copilot uses AI. Check for mistakes.
variant="warning"
/>
<BaseModal.Header
title={rule ? t('alerts.editRule') : t('alerts.createRule')}
icon={Bell}
Expand Down
39 changes: 28 additions & 11 deletions web/src/components/feedback/FeatureRequestModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { X, Bug, Sparkles, Loader2, ExternalLink, Bell, Check, Clock, GitPullRequest, GitMerge, Eye, Pencil, RefreshCw, MessageSquare, Settings, Github, Coins, Lightbulb, AlertCircle, AlertTriangle, Linkedin, Trophy, Monitor, BookOpen, ImagePlus, Trash2, Copy, Maximize2 } from 'lucide-react'
import { Button } from '../ui/Button'
import { StatusBadge } from '../ui/StatusBadge'
import { BaseModal } from '../../lib/modals'
import { BaseModal, ConfirmDialog } from '../../lib/modals'
import {
useFeatureRequests,
useNotifications,
Expand Down Expand Up @@ -116,6 +116,7 @@ export function FeatureRequestModal({ isOpen, onClose, initialTab, initialReques
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<{ issueUrl?: string } | null>(null)
const [confirmClose, setConfirmClose] = useState<string | null>(null) // request ID to confirm close
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
const [actionLoading, setActionLoading] = useState<string | null>(null) // request ID being acted on
const [actionError, setActionError] = useState<string | null>(null)
const [showLoginPrompt, setShowLoginPrompt] = useState(false)
Expand Down Expand Up @@ -370,25 +371,41 @@ export function FeatureRequestModal({ isOpen, onClose, initialTab, initialReques
}
}

const forceClose = () => {
setShowDiscardConfirm(false)
setDescription('')
setDescriptionTab('write')
setRequestType(initialRequestType || 'bug')
setTargetRepo('console')
setError(null)
setSuccess(null)
setScreenshots([])
setActiveTab(initialTab || 'submit')
onClose()
}

const handleClose = () => {
if (!isSubmitting) {
if (description.trim() !== '' && !window.confirm(t('common:common.discardUnsavedChanges', 'Discard unsaved changes?'))) {
if (description.trim() !== '') {
setShowDiscardConfirm(true)
return
}
setDescription('')
setDescriptionTab('write')
setRequestType(initialRequestType || 'bug')
setTargetRepo('console')
setError(null)
setSuccess(null)
setScreenshots([])
setActiveTab(initialTab || 'submit')
onClose()
forceClose()
}
}

return (
<BaseModal isOpen={isOpen} onClose={handleClose} size="lg" closeOnBackdrop={false} closeOnEscape={true} className="!h-[80vh]">
<ConfirmDialog
isOpen={showDiscardConfirm}
onClose={() => setShowDiscardConfirm(false)}
onConfirm={forceClose}
title={t('common:common.discardUnsavedChanges', 'Discard unsaved changes?')}
message={t('common:common.discardUnsavedChangesMessage', 'You have unsaved changes that will be lost.')}
confirmLabel={t('common:common.discard', 'Discard')}
cancelLabel={t('common:common.keepEditing', 'Keep editing')}
Comment on lines +403 to +406
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The i18n keys used here (common.discardUnsavedChanges, common.discardUnsavedChangesMessage, common.discard, common.keepEditing) don't exist in web/src/locales/en/common.json, so the UI will always fall back to the default strings. Consider adding these keys to common.json (preferred) or switching to existing common action keys to keep translations consistent.

Suggested change
title={t('common:common.discardUnsavedChanges', 'Discard unsaved changes?')}
message={t('common:common.discardUnsavedChangesMessage', 'You have unsaved changes that will be lost.')}
confirmLabel={t('common:common.discard', 'Discard')}
cancelLabel={t('common:common.keepEditing', 'Keep editing')}
title={t('feedback.discardUnsavedChangesTitle', 'Discard unsaved changes?')}
message={t('feedback.discardUnsavedChangesMessage', 'You have unsaved changes that will be lost.')}
confirmLabel={t('feedback.discard', 'Discard')}
cancelLabel={t('feedback.keepEditing', 'Keep editing')}

Copilot uses AI. Check for mistakes.
variant="warning"
/>
Comment on lines +399 to +408
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConfirmDialog uses BaseModal with z-[9999], but this component also renders the login prompt overlay at z-[10001]. If the user attempts to close with unsaved changes while the login prompt is open, the discard confirmation can render behind the login overlay and be non-interactive. Consider handling close by dismissing the login prompt first, or ensure the discard confirmation renders above any in-modal overlays (e.g., allow ConfirmDialog/BaseModal to take a higher z-index).

Copilot uses AI. Check for mistakes.
{/* Login Prompt Dialog */}
{showLoginPrompt && (
<>
Expand Down
26 changes: 22 additions & 4 deletions web/src/components/feedback/FeedbackModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { createPortal } from 'react-dom'
import { X, Bug, Lightbulb, Send, CheckCircle2, ExternalLink, Linkedin, ImagePlus, Trash2, Copy, Check, AlertTriangle, Loader2 } from 'lucide-react'
import { ConfirmDialog } from '../../lib/modals'
import { StatusBadge } from '../ui/StatusBadge'
import { useRewards, REWARD_ACTIONS } from '../../hooks/useRewards'
import { useToast } from '../ui/Toast'
Expand Down Expand Up @@ -54,6 +55,7 @@ export function FeedbackModal({ isOpen, onClose, initialType = 'feature' }: Feed
const [screenshots, setScreenshots] = useState<{ file: File; preview: string }[]>([])
const [isDragOver, setIsDragOver] = useState(false)
const [copiedIndex, setCopiedIndex] = useState<number | null>(null)
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)

const handleScreenshotFiles = (files: FileList | null) => {
Expand Down Expand Up @@ -183,10 +185,8 @@ export function FeedbackModal({ isOpen, onClose, initialType = 'feature' }: Feed
}
}

const handleClose = () => {
if (!success && (title.trim() !== '' || description.trim() !== '') && !window.confirm(t('common:common.discardUnsavedChanges', 'Discard unsaved changes?'))) {
return
}
const forceClose = () => {
setShowDiscardConfirm(false)
localStorage.removeItem(DRAFT_KEY)
setSuccess(null)
setSubmitError(null)
Expand All @@ -196,6 +196,14 @@ export function FeedbackModal({ isOpen, onClose, initialType = 'feature' }: Feed
onClose()
}

const handleClose = () => {
if (!success && (title.trim() !== '' || description.trim() !== '')) {
setShowDiscardConfirm(true)
return
}
forceClose()
}
Comment on lines +199 to +205
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleClose is used from the Escape/Space keydown listener, but that useEffect only depends on isOpen. This can leave the key handler with stale success/title/description values, so Escape may discard edits without showing the confirm dialog. Fix by making the key handler depend on handleClose (and making handleClose stable via useCallback), or by using a ref to always read the latest state in the listener.

Copilot uses AI. Check for mistakes.

// Keyboard navigation - ESC to close, Space to close when not typing
useEffect(() => {
if (!isOpen) return
Expand Down Expand Up @@ -232,6 +240,16 @@ export function FeedbackModal({ isOpen, onClose, initialType = 'feature' }: Feed

return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-2xl">
<ConfirmDialog
isOpen={showDiscardConfirm}
onClose={() => setShowDiscardConfirm(false)}
onConfirm={forceClose}
title={t('common:common.discardUnsavedChanges', 'Discard unsaved changes?')}
message={t('common:common.discardUnsavedChangesMessage', 'You have unsaved changes that will be lost.')}
confirmLabel={t('common:common.discard', 'Discard')}
cancelLabel={t('common:common.keepEditing', 'Keep editing')}
Comment on lines +247 to +250
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The i18n keys used here (common.discardUnsavedChanges, common.discardUnsavedChangesMessage, common.discard, common.keepEditing) don't exist in web/src/locales/en/common.json, so the UI will always fall back to the default strings. Consider adding these keys to common.json (preferred) or switching to existing common action keys to keep translations consistent.

Suggested change
title={t('common:common.discardUnsavedChanges', 'Discard unsaved changes?')}
message={t('common:common.discardUnsavedChangesMessage', 'You have unsaved changes that will be lost.')}
confirmLabel={t('common:common.discard', 'Discard')}
cancelLabel={t('common:common.keepEditing', 'Keep editing')}
title="Discard unsaved changes?"
message="You have unsaved changes that will be lost."
confirmLabel="Discard"
cancelLabel="Keep editing"

Copilot uses AI. Check for mistakes.
variant="warning"
/>
<div className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-lg mx-4 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-secondary/30">
Expand Down
21 changes: 19 additions & 2 deletions web/src/components/gpu/ReservationFormModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
Trash2,
Loader2,
} from 'lucide-react'
import { BaseModal } from '../../lib/modals'
import { BaseModal, ConfirmDialog } from '../../lib/modals'
import {
useNamespaces,
createOrUpdateResourceQuota,
Expand Down Expand Up @@ -72,10 +72,17 @@ export function ReservationFormModal({
const [extraResources, setExtraResources] = useState<Array<{ key: string; value: string }>>([])
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)

const forceClose = () => {
setShowDiscardConfirm(false)
onClose()
}

const handleClose = () => {
const hasChanges = title.trim() !== '' || description.trim() !== ''
if (hasChanges && !window.confirm(t('common:common.discardUnsavedChanges', 'Discard unsaved changes?'))) {
if (hasChanges) {
setShowDiscardConfirm(true)
return
}
onClose()
Expand Down Expand Up @@ -218,6 +225,16 @@ export function ReservationFormModal({

return (
<BaseModal isOpen={isOpen} onClose={handleClose} size="lg" closeOnBackdrop={false} closeOnEscape={true}>
<ConfirmDialog
isOpen={showDiscardConfirm}
onClose={() => setShowDiscardConfirm(false)}
onConfirm={forceClose}
title={t('common:common.discardUnsavedChanges', 'Discard unsaved changes?')}
message={t('common:common.discardUnsavedChangesMessage', 'You have unsaved changes that will be lost.')}
confirmLabel={t('common:common.discard', 'Discard')}
cancelLabel={t('common:common.keepEditing', 'Keep editing')}
Comment on lines +232 to +235
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The i18n keys used here (common.discardUnsavedChanges, common.discardUnsavedChangesMessage, common.discard, common.keepEditing) don't exist in web/src/locales/en/common.json, so the UI will always fall back to the default strings. Consider adding these keys to common.json (preferred) or switching to existing common action keys to keep translations consistent.

Suggested change
title={t('common:common.discardUnsavedChanges', 'Discard unsaved changes?')}
message={t('common:common.discardUnsavedChangesMessage', 'You have unsaved changes that will be lost.')}
confirmLabel={t('common:common.discard', 'Discard')}
cancelLabel={t('common:common.keepEditing', 'Keep editing')}
title={t('common:discardUnsavedChanges', 'Discard unsaved changes?')}
message={t('common:discardUnsavedChangesMessage', 'You have unsaved changes that will be lost.')}
confirmLabel={t('common:discard', 'Discard')}
cancelLabel={t('common:keepEditing', 'Keep editing')}

Copilot uses AI. Check for mistakes.
variant="warning"
/>
<BaseModal.Header
title={editingReservation ? t('gpuReservations.form.editTitle') : t('gpuReservations.form.createTitle')}
icon={Calendar}
Expand Down
22 changes: 20 additions & 2 deletions web/src/components/namespaces/CreateNamespaceModal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { Folder, UserPlus, Shield, X } from 'lucide-react'
import { Button } from '../ui/Button'
import { BaseModal } from '../../lib/modals'
import { BaseModal, ConfirmDialog } from '../../lib/modals'
import { api } from '../../lib/api'
import { useTranslation } from 'react-i18next'

Expand Down Expand Up @@ -101,15 +101,33 @@ export function CreateNamespaceModal({ clusters, onClose, onCreated }: CreateNam
g => !initialAccess.some(a => a.type === 'Group' && a.name === g)
)

const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)

const forceClose = () => {
setShowDiscardConfirm(false)
onClose()
}

const handleClose = () => {
if ((name.trim() !== '' || teamLabel.trim() !== '') && !window.confirm(t('common:common.discardUnsavedChanges', 'Discard unsaved changes?'))) {
if (name.trim() !== '' || teamLabel.trim() !== '') {
setShowDiscardConfirm(true)
return
}
onClose()
}

return (
<BaseModal isOpen={true} onClose={handleClose} size="lg" closeOnBackdrop={false} closeOnEscape={true}>
<ConfirmDialog
isOpen={showDiscardConfirm}
onClose={() => setShowDiscardConfirm(false)}
onConfirm={forceClose}
title={t('common:common.discardUnsavedChanges', 'Discard unsaved changes?')}
message={t('common:common.discardUnsavedChangesMessage', 'You have unsaved changes that will be lost.')}
confirmLabel={t('common:common.discard', 'Discard')}
cancelLabel={t('common:common.keepEditing', 'Keep editing')}
Comment on lines +125 to +128
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The i18n keys used here (common.discardUnsavedChanges, common.discardUnsavedChangesMessage, common.discard, common.keepEditing) don't exist in web/src/locales/en/common.json, so the UI will always fall back to the default strings. Consider adding these keys to common.json (preferred) or switching to existing common action keys to keep translations consistent.

Suggested change
title={t('common:common.discardUnsavedChanges', 'Discard unsaved changes?')}
message={t('common:common.discardUnsavedChangesMessage', 'You have unsaved changes that will be lost.')}
confirmLabel={t('common:common.discard', 'Discard')}
cancelLabel={t('common:common.keepEditing', 'Keep editing')}
title={t('common:discardUnsavedChanges', 'Discard unsaved changes?')}
message={t('common:discardUnsavedChangesMessage', 'You have unsaved changes that will be lost.')}
confirmLabel={t('common:discard', 'Discard')}
cancelLabel={t('common:keepEditing', 'Keep editing')}

Copilot uses AI. Check for mistakes.
variant="warning"
/>
<BaseModal.Header
title="Create Namespace"
icon={Folder}
Expand Down
22 changes: 20 additions & 2 deletions web/src/components/namespaces/GrantAccessModal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { Shield } from 'lucide-react'
import { Button } from '../ui/Button'
import { BaseModal } from '../../lib/modals'
import { BaseModal, ConfirmDialog } from '../../lib/modals'
import { api } from '../../lib/api'
import { useTranslation } from 'react-i18next'
import type { NamespaceDetails, NamespaceAccessEntry } from './types'
Expand Down Expand Up @@ -88,15 +88,33 @@ export function GrantAccessModal({ namespace, existingAccess, onClose, onGranted
setShowDropdown(false)
}

const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)

const forceClose = () => {
setShowDiscardConfirm(false)
onClose()
}

const handleClose = () => {
if (subjectName.trim() !== '' && !window.confirm(t('common:common.discardUnsavedChanges', 'Discard unsaved changes?'))) {
if (subjectName.trim() !== '') {
setShowDiscardConfirm(true)
return
}
onClose()
}

return (
<BaseModal isOpen={true} onClose={handleClose} size="md" closeOnBackdrop={false} closeOnEscape={true}>
<ConfirmDialog
isOpen={showDiscardConfirm}
onClose={() => setShowDiscardConfirm(false)}
onConfirm={forceClose}
title={t('common:common.discardUnsavedChanges', 'Discard unsaved changes?')}
message={t('common:common.discardUnsavedChangesMessage', 'You have unsaved changes that will be lost.')}
confirmLabel={t('common:common.discard', 'Discard')}
cancelLabel={t('common:common.keepEditing', 'Keep editing')}
Comment on lines +112 to +115
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The i18n keys used here (common.discardUnsavedChanges, common.discardUnsavedChangesMessage, common.discard, common.keepEditing) don't exist in web/src/locales/en/common.json, so the UI will always fall back to the default strings. Consider adding these keys to common.json (preferred) or switching to existing common action keys to keep translations consistent.

Suggested change
title={t('common:common.discardUnsavedChanges', 'Discard unsaved changes?')}
message={t('common:common.discardUnsavedChangesMessage', 'You have unsaved changes that will be lost.')}
confirmLabel={t('common:common.discard', 'Discard')}
cancelLabel={t('common:common.keepEditing', 'Keep editing')}
title={t('common:discardUnsavedChanges', 'Discard unsaved changes?')}
message={t('common:discardUnsavedChangesMessage', 'You have unsaved changes that will be lost.')}
confirmLabel={t('common:discard', 'Discard')}
cancelLabel={t('common:keepEditing', 'Keep editing')}

Copilot uses AI. Check for mistakes.
variant="warning"
/>
<BaseModal.Header
title="Grant Access"
description={`Namespace: ${namespace.name}`}
Expand Down
Loading