Compare commits

...

5 Commits

12 changed files with 816 additions and 516 deletions
@@ -72,6 +72,61 @@
}
}
.expandableInputWrapper {
position: relative;
display: flex;
align-items: flex-start;
min-width: 0;
flex: 1;
}
.expandableInputWrapper > .expandableTextarea,
.expandableInputWrapper > :global(.input) {
flex: 1;
min-width: 0;
padding-right: 28px;
}
.expandableTextarea {
resize: none;
min-height: 60px;
overflow: hidden;
line-height: 1.5;
padding-right: 32px;
}
.expandableToggle {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 10px;
line-height: 1;
padding: 2px;
color: var(--text-secondary, #999);
opacity: 0.5;
transition: opacity 0.15s;
z-index: 1;
&:hover {
opacity: 1;
}
&:disabled {
cursor: default;
opacity: 0.35;
}
}
.expandableInputExpanded .expandableToggle {
top: 8px;
transform: none;
right: 14px;
}
.overview {
position: relative;
overflow: hidden;
@@ -1,4 +1,4 @@
import { memo, useId, useMemo, useState } from 'react';
import { memo, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
@@ -23,6 +23,109 @@ import {
import { maskApiKey } from '@/utils/format';
import { isValidApiKeyCharset } from '@/utils/validation';
/** Minimum character count before the expand/collapse toggle appears. */
const EXPAND_THRESHOLD = 30;
/** Auto-expanding textarea that collapses back to a single-line input on demand. */
function ExpandableInput({
value,
placeholder,
ariaLabel,
disabled,
className,
onChange,
}: {
value: string;
placeholder?: string;
ariaLabel?: string;
disabled?: boolean;
className?: string;
onChange: (nextValue: string) => void;
}) {
const { t } = useTranslation();
const [collapsed, setCollapsed] = useState(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const autoResize = useCallback((el: HTMLTextAreaElement) => {
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}, []);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
// Strip newlines — these fields are single-line identifiers/paths that
// would break YAML serialization if they contained line breaks.
const sanitized = e.target.value.replace(/[\r\n]/g, '');
onChange(sanitized);
// autoResize is handled by useLayoutEffect after React syncs the
// sanitized value back to the DOM — calling it here would measure
// stale content.
};
// Resize synchronously before paint to avoid visual flicker.
useLayoutEffect(() => {
if (!collapsed && textareaRef.current) {
autoResize(textareaRef.current);
}
}, [collapsed, value, autoResize]);
if (collapsed) {
return (
<div className={styles.expandableInputWrapper}>
<input
className={`input ${className ?? ''}`}
placeholder={placeholder}
aria-label={ariaLabel}
value={value}
onChange={(e) => onChange(e.target.value.replace(/[\r\n]/g, ''))}
disabled={disabled}
/>
{value.length > EXPAND_THRESHOLD && (
<button
type="button"
className={styles.expandableToggle}
disabled={disabled}
onClick={() => {
setCollapsed(false);
requestAnimationFrame(() => {
textareaRef.current?.focus();
});
}}
title={t('common.expand')}
aria-label={t('common.expand')}
>
</button>
)}
</div>
);
}
return (
<div className={`${styles.expandableInputWrapper} ${styles.expandableInputExpanded}`}>
<textarea
ref={textareaRef}
className={`input ${styles.expandableTextarea} ${className ?? ''}`}
placeholder={placeholder}
aria-label={ariaLabel}
value={value}
onChange={handleChange}
disabled={disabled}
rows={2}
/>
<button
type="button"
className={styles.expandableToggle}
disabled={disabled}
onClick={() => setCollapsed(true)}
title={t('common.collapse')}
aria-label={t('common.collapse')}
>
</button>
</div>
);
}
function getValidationMessage(
t: ReturnType<typeof useTranslation>['t'],
errorCode?: PayloadParamValidationErrorCode
@@ -325,14 +428,12 @@ const StringListEditor = memo(function StringListEditor({
<div className={styles.stringList}>
{items.map((item, index) => (
<div key={renderItemIds[index] ?? `item-${index}`} className={styles.stringListRow}>
<input
className="input"
<ExpandableInput
placeholder={placeholder}
aria-label={inputAriaLabel ?? placeholder}
ariaLabel={inputAriaLabel ?? placeholder}
value={item}
onChange={(e) => updateItem(index, e.target.value)}
onChange={(nextValue) => updateItem(index, nextValue)}
disabled={disabled}
style={{ flex: 1 }}
/>
<Button variant="ghost" size="sm" onClick={() => removeItem(index)} disabled={disabled}>
{t('config_management.visual.common.delete')}
@@ -508,12 +609,11 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
}
return (
<input
className="input"
<ExpandableInput
placeholder={getValuePlaceholder(param.valueType)}
aria-label={t('config_management.visual.payload_rules.param_value')}
ariaLabel={t('config_management.visual.payload_rules.param_value')}
value={param.value}
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { value: nextValue })}
disabled={disabled}
/>
);
@@ -564,23 +664,21 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
})
}
/>
<input
className="input"
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.model_name')}
aria-label={t('config_management.visual.payload_rules.model_name')}
ariaLabel={t('config_management.visual.payload_rules.model_name')}
value={model.name}
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
disabled={disabled}
/>
</>
) : (
<>
<input
className="input"
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.model_name')}
aria-label={t('config_management.visual.payload_rules.model_name')}
ariaLabel={t('config_management.visual.payload_rules.model_name')}
value={model.name}
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
disabled={disabled}
/>
<Select
@@ -629,12 +727,11 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
return (
<div key={param.id} className={styles.payloadRuleParamGroup}>
<div className={styles.payloadRuleParamRow}>
<input
className="input"
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.json_path')}
aria-label={t('config_management.visual.payload_rules.json_path')}
ariaLabel={t('config_management.visual.payload_rules.json_path')}
value={param.path}
onChange={(e) => updateParam(ruleIndex, paramIndex, { path: e.target.value })}
onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { path: nextValue })}
disabled={disabled}
/>
{rawJsonValues ? null : (
@@ -767,12 +864,11 @@ export const PayloadFilterRulesEditor = memo(function PayloadFilterRulesEditor({
</div>
{rule.models.map((model, modelIndex) => (
<div key={model.id} className={styles.payloadFilterModelRow}>
<input
className="input"
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.model_name')}
aria-label={t('config_management.visual.payload_rules.model_name')}
ariaLabel={t('config_management.visual.payload_rules.model_name')}
value={model.name}
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
disabled={disabled}
/>
<Select
+150 -60
View File
@@ -1,4 +1,5 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import {
collectUsageDetails,
@@ -10,11 +11,28 @@ import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
const COLOR_STOPS = [
{ r: 239, g: 68, b: 68 }, // #ef4444
{ r: 250, g: 204, b: 21 }, // #facc15
{ r: 34, g: 197, b: 94 }, // #22c55e
{ r: 239, g: 68, b: 68 }, // #ef4444
{ r: 250, g: 204, b: 21 }, // #facc15
{ r: 34, g: 197, b: 94 }, // #22c55e
] as const;
const TOOLTIP_OFFSET = 8;
const TOOLTIP_SAFE_WIDTH = 180;
const TOOLTIP_SAFE_HEIGHT = 72;
type TooltipHorizontalPosition = 'center' | 'left' | 'right';
type TooltipVerticalPosition = 'above' | 'below';
interface ActiveTooltipState {
idx: number;
anchorEl: HTMLDivElement;
horizontal: TooltipHorizontalPosition;
vertical: TooltipVerticalPosition;
left: number;
top: number;
transform: string;
}
function rateToColor(rate: number): string {
const t = Math.max(0, Math.min(1, rate));
const segment = t < 0.5 ? 0 : 1;
@@ -43,7 +61,7 @@ export interface ServiceHealthCardProps {
export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
const { t } = useTranslation();
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
const [activeTooltip, setActiveTooltip] = useState<ActiveTooltipState | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
const healthData: ServiceHealthData = useMemo(() => {
@@ -64,11 +82,74 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
return () => document.removeEventListener('pointerdown', handler);
}, [activeTooltip]);
const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => {
if (e.pointerType === 'mouse') {
setActiveTooltip(idx);
}
}, []);
const buildTooltipState = useCallback(
(idx: number, anchorEl: HTMLDivElement): ActiveTooltipState => {
const rect = anchorEl.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
let horizontal: TooltipHorizontalPosition = 'center';
let left = centerX;
if (centerX <= TOOLTIP_SAFE_WIDTH / 2) {
horizontal = 'left';
left = rect.left;
} else if (centerX >= window.innerWidth - TOOLTIP_SAFE_WIDTH / 2) {
horizontal = 'right';
left = rect.right;
}
const vertical: TooltipVerticalPosition = rect.top <= TOOLTIP_SAFE_HEIGHT ? 'below' : 'above';
const top = vertical === 'below' ? rect.bottom + TOOLTIP_OFFSET : rect.top - TOOLTIP_OFFSET;
const translateX = horizontal === 'center' ? '-50%' : horizontal === 'right' ? '-100%' : '0';
const translateY = vertical === 'below' ? '0' : '-100%';
return {
idx,
anchorEl,
horizontal,
vertical,
left: Math.round(left),
top: Math.round(top),
transform: `translate(${translateX}, ${translateY})`,
};
},
[]
);
useEffect(() => {
if (!activeTooltip) return;
const updateTooltipPosition = () => {
if (!document.body.contains(activeTooltip.anchorEl)) {
setActiveTooltip(null);
return;
}
setActiveTooltip(buildTooltipState(activeTooltip.idx, activeTooltip.anchorEl));
};
window.addEventListener('resize', updateTooltipPosition);
window.addEventListener('scroll', updateTooltipPosition, true);
return () => {
window.removeEventListener('resize', updateTooltipPosition);
window.removeEventListener('scroll', updateTooltipPosition, true);
};
}, [activeTooltip, buildTooltipState]);
const openTooltip = useCallback(
(idx: number, anchorEl: HTMLDivElement) => {
setActiveTooltip(buildTooltipState(idx, anchorEl));
},
[buildTooltipState]
);
const handlePointerEnter = useCallback(
(e: React.PointerEvent<HTMLDivElement>, idx: number) => {
if (e.pointerType === 'mouse') {
openTooltip(idx, e.currentTarget);
}
},
[openTooltip]
);
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
if (e.pointerType === 'mouse') {
@@ -76,39 +157,49 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
}
}, []);
const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => {
if (e.pointerType === 'touch') {
e.preventDefault();
setActiveTooltip((prev) => (prev === idx ? null : idx));
}
}, []);
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>, idx: number) => {
if (e.pointerType === 'touch') {
e.preventDefault();
setActiveTooltip((prev) =>
prev?.idx === idx ? null : buildTooltipState(idx, e.currentTarget)
);
}
},
[buildTooltipState]
);
const getTooltipPositionClass = (idx: number): string => {
const col = Math.floor(idx / healthData.rows);
if (col <= 2) return styles.healthTooltipLeft;
if (col >= healthData.cols - 3) return styles.healthTooltipRight;
return '';
};
const getTooltipVerticalClass = (idx: number): string => {
const row = idx % healthData.rows;
if (row <= 1) return styles.healthTooltipBelow;
return '';
};
const renderTooltip = (detail: StatusBlockDetail, idx: number) => {
const renderTooltip = (detail: StatusBlockDetail, tooltipState: ActiveTooltipState) => {
const total = detail.success + detail.failure;
const posClass = getTooltipPositionClass(idx);
const vertClass = getTooltipVerticalClass(idx);
const posClass =
tooltipState.horizontal === 'left'
? styles.healthTooltipLeft
: tooltipState.horizontal === 'right'
? styles.healthTooltipRight
: '';
const vertClass = tooltipState.vertical === 'below' ? styles.healthTooltipBelow : '';
const timeRange = `${formatDateTime(detail.startTime)} ${formatDateTime(detail.endTime)}`;
return (
<div className={`${styles.healthTooltip} ${posClass} ${vertClass}`}>
const tooltip = (
<div
className={`${styles.healthTooltip} ${posClass} ${vertClass}`}
style={{
position: 'fixed',
left: `${tooltipState.left}px`,
top: `${tooltipState.top}px`,
bottom: 'auto',
right: 'auto',
transform: tooltipState.transform,
}}
>
<span className={styles.healthTooltipTime}>{timeRange}</span>
{total > 0 ? (
<span className={styles.healthTooltipStats}>
<span className={styles.healthTooltipSuccess}>{t('status_bar.success_short')} {detail.success}</span>
<span className={styles.healthTooltipFailure}>{t('status_bar.failure_short')} {detail.failure}</span>
<span className={styles.healthTooltipSuccess}>
{t('status_bar.success_short')} {detail.success}
</span>
<span className={styles.healthTooltipFailure}>
{t('status_bar.failure_short')} {detail.failure}
</span>
<span className={styles.healthTooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
</span>
) : (
@@ -116,6 +207,8 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
)}
</div>
);
return typeof document === 'undefined' ? tooltip : createPortal(tooltip, document.body);
};
const rateClass = !hasData
@@ -138,32 +231,29 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
</div>
</div>
<div className={styles.healthGridScroller}>
<div
className={styles.healthGrid}
ref={gridRef}
>
{healthData.blockDetails.map((detail, idx) => {
const isIdle = detail.rate === -1;
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
const isActive = activeTooltip === idx;
<div className={styles.healthGrid} ref={gridRef}>
{healthData.blockDetails.map((detail, idx) => {
const isIdle = detail.rate === -1;
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
const isActive = activeTooltip?.idx === idx;
return (
<div
key={idx}
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
onPointerEnter={(e) => handlePointerEnter(e, idx)}
onPointerLeave={handlePointerLeave}
onPointerDown={(e) => handlePointerDown(e, idx)}
>
return (
<div
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
style={blockStyle}
/>
{isActive && renderTooltip(detail, idx)}
</div>
);
})}
</div>
key={idx}
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
onPointerEnter={(e) => handlePointerEnter(e, idx)}
onPointerLeave={handlePointerLeave}
onPointerDown={(e) => handlePointerDown(e, idx)}
>
<div
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
style={blockStyle}
/>
{isActive && activeTooltip && renderTooltip(detail, activeTooltip)}
</div>
);
})}
</div>
</div>
<div className={styles.healthLegend}>
<span className={styles.healthLegendLabel}>{t('service_health.oldest')}</span>
+1
View File
@@ -7,6 +7,7 @@ export type AuthFilesUiState = {
problemOnly?: boolean;
compactMode?: boolean;
search?: string;
regexSearchMode?: boolean;
page?: number;
pageSize?: number;
regularPageSize?: number;
+6
View File
@@ -41,6 +41,8 @@
"quota_update_required": "Please update the CPA version or check for updates",
"quota_check_credential": "Please check the credential status",
"copy": "Copy",
"expand": "Expand",
"collapse": "Collapse",
"status": "Status",
"action": "Action",
"custom_headers_label": "Custom Headers",
@@ -496,10 +498,14 @@
"pagination_info": "Page {{current}} / {{total}} · {{count}} files",
"search_label": "Search configs",
"search_placeholder": "Filter by name, type, or provider",
"search_regex_placeholder": "Match name, type, or provider with a regex",
"search_regex_invalid": "Enter a valid regex pattern (max {{max}} characters)",
"search_regex_unsafe": "This regex may freeze the page and has been blocked (avoid nested quantifiers, alternation in repeated groups, or backreferences)",
"problem_filter_label": "Problem Filter",
"problem_filter_only": "Only show problematic credentials",
"display_options_label": "Display options",
"compact_mode_label": "Compact mode",
"regex_search_mode_label": "Regex mode",
"sort_label": "Sort",
"sort_default": "Default",
"sort_az": "A-Z Name",
+6
View File
@@ -41,6 +41,8 @@
"quota_update_required": "Пожалуйста, обновите CPA или проверьте наличие обновлений",
"quota_check_credential": "Пожалуйста, проверьте статус учётных данных",
"copy": "Копировать",
"expand": "Развернуть",
"collapse": "Свернуть",
"status": "Статус",
"action": "Действие",
"custom_headers_label": "Пользовательские заголовки",
@@ -496,10 +498,14 @@
"pagination_info": "Страница {{current}} / {{total}} · {{count}} файлов",
"search_label": "Поиск конфигов",
"search_placeholder": "Фильтр по имени, типу или провайдеру",
"search_regex_placeholder": "Сопоставление имени, типа или провайдера по regex",
"search_regex_invalid": "Введите корректный regex-шаблон (не более {{max}} символов)",
"search_regex_unsafe": "Этот regex может вызвать зависание страницы и был заблокирован (избегайте вложенных квантификаторов, альтернативы в повторяющихся группах или обратных ссылок)",
"problem_filter_label": "Фильтр проблем",
"problem_filter_only": "Показывать только проблемные учётные данные",
"display_options_label": "Параметры отображения",
"compact_mode_label": "Компактный режим",
"regex_search_mode_label": "Режим regex",
"sort_label": "Сортировка",
"sort_default": "По умолчанию",
"sort_az": "A-Z Имя",
+6
View File
@@ -41,6 +41,8 @@
"quota_update_required": "请更新 CPA 版本或检查更新",
"quota_check_credential": "请检查凭证状态",
"copy": "复制",
"expand": "展开",
"collapse": "收起",
"status": "状态",
"action": "操作",
"custom_headers_label": "自定义请求头",
@@ -496,10 +498,14 @@
"pagination_info": "第 {{current}} / {{total}} 页 · 共 {{count}} 个文件",
"search_label": "搜索配置文件",
"search_placeholder": "输入名称、类型或提供方关键字",
"search_regex_placeholder": "输入正则表达式匹配名称、类型或提供方",
"search_regex_invalid": "请输入有效的正则表达式(最多 {{max}} 个字符)",
"search_regex_unsafe": "该正则表达式可能导致页面卡顿,已阻止执行(避免嵌套量词、重复分组中的 | 或反向引用)",
"problem_filter_label": "问题筛选",
"problem_filter_only": "仅显示有问题凭证",
"display_options_label": "显示选项",
"compact_mode_label": "简略模式",
"regex_search_mode_label": "正则模式",
"sort_label": "排序",
"sort_default": "默认",
"sort_az": "A-Z 名称",
File diff suppressed because it is too large Load Diff
+81 -8
View File
@@ -24,6 +24,7 @@ import { IconFilterAll } from '@/components/ui/icons';
import { EmptyState } from '@/components/ui/EmptyState';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { copyToClipboard } from '@/utils/clipboard';
import { isLikelyUnsafeJsRegex } from '@/utils/regexSafety';
import {
MAX_CARD_PAGE_SIZE,
MIN_CARD_PAGE_SIZE,
@@ -67,6 +68,7 @@ const BATCH_BAR_BASE_TRANSFORM = 'translateX(-50%)';
const BATCH_BAR_HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
const DEFAULT_REGULAR_PAGE_SIZE = 9;
const DEFAULT_COMPACT_PAGE_SIZE = 12;
const MAX_REGEX_SEARCH_PATTERN_LENGTH = 120;
export function AuthFilesPage() {
const { t } = useTranslation();
@@ -81,6 +83,7 @@ export function AuthFilesPage() {
const [problemOnly, setProblemOnly] = useState(false);
const [compactMode, setCompactMode] = useState(false);
const [search, setSearch] = useState('');
const [regexSearchMode, setRegexSearchMode] = useState(false);
const [page, setPage] = useState(1);
const [pageSizeByMode, setPageSizeByMode] = useState({
regular: DEFAULT_REGULAR_PAGE_SIZE,
@@ -201,6 +204,9 @@ export function AuthFilesPage() {
if (typeof persisted.search === 'string') {
setSearch(persisted.search);
}
if (typeof persisted.regexSearchMode === 'boolean') {
setRegexSearchMode(persisted.regexSearchMode);
}
if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) {
setPage(Math.max(1, Math.round(persisted.page)));
}
@@ -236,6 +242,7 @@ export function AuthFilesPage() {
problemOnly,
compactMode,
search,
regexSearchMode,
page,
pageSize,
regularPageSize: pageSizeByMode.regular,
@@ -250,6 +257,7 @@ export function AuthFilesPage() {
pageSize,
pageSizeByMode,
problemOnly,
regexSearchMode,
search,
sortMode,
uiStateHydrated,
@@ -368,18 +376,63 @@ export function AuthFilesPage() {
return counts;
}, [filesMatchingProblemFilter]);
const normalizedSearch = search.trim();
const { regexSearch, regexSearchErrorKey } = useMemo(() => {
if (!regexSearchMode || !normalizedSearch) {
return { regexSearch: null as RegExp | null, regexSearchErrorKey: undefined as string | undefined };
}
if (normalizedSearch.length > MAX_REGEX_SEARCH_PATTERN_LENGTH) {
return {
regexSearch: null,
regexSearchErrorKey: 'auth_files.search_regex_invalid',
};
}
if (isLikelyUnsafeJsRegex(normalizedSearch)) {
return {
regexSearch: null,
regexSearchErrorKey: 'auth_files.search_regex_unsafe',
};
}
try {
return { regexSearch: new RegExp(normalizedSearch, 'i'), regexSearchErrorKey: undefined };
} catch {
return {
regexSearch: null,
regexSearchErrorKey: 'auth_files.search_regex_invalid',
};
}
}, [normalizedSearch, regexSearchMode]);
const searchError = regexSearchErrorKey
? t(regexSearchErrorKey, { max: MAX_REGEX_SEARCH_PATTERN_LENGTH })
: undefined;
const filtered = useMemo(() => {
return filesMatchingProblemFilter.filter((item) => {
const matchType = filter === 'all' || item.type === filter;
const term = search.trim().toLowerCase();
const matchSearch =
!term ||
item.name.toLowerCase().includes(term) ||
(item.type || '').toString().toLowerCase().includes(term) ||
(item.provider || '').toString().toLowerCase().includes(term);
const matchSearch = (() => {
if (!normalizedSearch) return true;
if (!regexSearchMode) {
const term = normalizedSearch.toLowerCase();
return (
item.name.toLowerCase().includes(term) ||
(item.type || '').toString().toLowerCase().includes(term) ||
(item.provider || '').toString().toLowerCase().includes(term)
);
}
if (!regexSearch) return false;
return [item.name, item.type, item.provider].some((value) =>
regexSearch.test((value || '').toString())
);
})();
return matchType && matchSearch;
});
}, [filesMatchingProblemFilter, filter, search]);
}, [filesMatchingProblemFilter, filter, normalizedSearch, regexSearch, regexSearchMode]);
const sorted = useMemo(() => {
const copy = [...filtered];
@@ -691,7 +744,12 @@ export function AuthFilesPage() {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t('auth_files.search_placeholder')}
placeholder={
regexSearchMode
? t('auth_files.search_regex_placeholder')
: t('auth_files.search_placeholder')
}
error={searchError}
/>
</div>
<div className={styles.filterItem}>
@@ -753,6 +811,21 @@ export function AuthFilesPage() {
}
/>
</div>
<div className={styles.filterToggleCard}>
<ToggleSwitch
checked={regexSearchMode}
onChange={(value) => {
setRegexSearchMode(value);
setPage(1);
}}
ariaLabel={t('auth_files.regex_search_mode_label')}
label={
<span className={styles.filterToggleLabel}>
{t('auth_files.regex_search_mode_label')}
</span>
}
/>
</div>
</div>
</div>
</div>
+10 -7
View File
@@ -1112,17 +1112,20 @@
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--bg-primary, #fff);
border: 1px solid var(--border-secondary, #e5e7eb);
background-color: var(--floating-surface, #ffffff);
background-image: none;
border: 1px solid var(--floating-border, #e5e7eb);
border-radius: 6px;
padding: 6px 10px;
font-size: 11px;
line-height: 1.5;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
box-shadow: var(--floating-shadow, 0 10px 24px rgba(0, 0, 0, 0.16));
z-index: $z-dropdown;
pointer-events: none;
color: var(--text-primary);
opacity: 1;
background-clip: padding-box;
&::after {
content: '';
@@ -1131,7 +1134,7 @@
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--bg-primary, #fff);
border-top-color: var(--floating-surface, #ffffff);
}
&::before {
@@ -1141,7 +1144,7 @@
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--border-secondary, #e5e7eb);
border-top-color: var(--floating-border, #e5e7eb);
}
}
@@ -1154,14 +1157,14 @@
top: auto;
bottom: 100%;
border-top-color: transparent;
border-bottom-color: var(--bg-primary, #fff);
border-bottom-color: var(--floating-surface, #ffffff);
}
&::before {
top: auto;
bottom: 100%;
border-top-color: transparent;
border-bottom-color: var(--border-secondary, #e5e7eb);
border-bottom-color: var(--floating-border, #e5e7eb);
}
}
+9
View File
@@ -11,6 +11,9 @@
--bg-hover: var(--bg-tertiary);
--bg-quinary: #f6f4ee;
--bg-error-light: rgba(198, 87, 70, 0.1);
--floating-surface: #fffdf9;
--floating-border: #d8d3ca;
--floating-shadow: 0 12px 26px rgba(0, 0, 0, 0.14);
--text-primary: #2d2a26;
--text-secondary: #6d6760;
@@ -64,6 +67,9 @@
--bg-hover: var(--bg-tertiary);
--bg-quinary: #ffffff;
--bg-error-light: rgba(198, 87, 70, 0.08);
--floating-surface: #ffffff;
--floating-border: #d9d9d9;
--floating-shadow: 0 12px 26px rgba(0, 0, 0, 0.12);
--text-primary: #2d2a26;
--text-secondary: #6d6760;
@@ -118,6 +124,9 @@
--bg-hover: #2e2a26;
--bg-quinary: #191714;
--bg-error-light: rgba(198, 87, 70, 0.18);
--floating-surface: #2a2723;
--floating-border: #4a443d;
--floating-shadow: 0 14px 30px rgba(0, 0, 0, 0.4);
--text-primary: #f6f4f1;
--text-secondary: #c9c3bb;
+166
View File
@@ -0,0 +1,166 @@
type GroupState = {
hasInnerVariableQuantifier: boolean;
hasAlternation: boolean;
justOpened: boolean;
};
type Quantifier = {
length: number;
min: number;
max: number | null; // null means unbounded
variable: boolean; // can match multiple lengths for the repeated token
};
const OUTER_REPEAT_MAX_SAFE_UPPER_BOUND = 9;
const isDigit = (ch: string | undefined): ch is string => ch !== undefined && ch >= '0' && ch <= '9';
const readBraceQuantifier = (pattern: string, index: number): Quantifier | null => {
if (pattern[index] !== '{') return null;
let i = index + 1;
let minStr = '';
while (isDigit(pattern[i])) {
minStr += pattern[i];
i += 1;
}
if (minStr.length === 0) return null;
const min = Number(minStr);
let max: number | null = min;
if (pattern[i] === ',') {
i += 1;
let maxStr = '';
while (isDigit(pattern[i])) {
maxStr += pattern[i];
i += 1;
}
max = maxStr.length === 0 ? null : Number(maxStr);
}
if (pattern[i] !== '}') return null;
const variable = max === null || max !== min;
return { length: i - index + 1, min, max, variable };
};
const readQuantifier = (pattern: string, index: number): Quantifier | null => {
const ch = pattern[index];
if (ch === '*') return { length: 1, min: 0, max: null, variable: true };
if (ch === '+') return { length: 1, min: 1, max: null, variable: true };
if (ch === '?') return { length: 1, min: 0, max: 1, variable: true };
if (ch !== '{') return null;
return readBraceQuantifier(pattern, index);
};
/**
* Heuristic safety check for user-supplied JS regex patterns.
*
* Goal: prevent patterns that are very likely to cause catastrophic backtracking
* (e.g. `^(a+)+$`) from running on the main thread.
*
* Notes:
* - This is intentionally conservative but tries to avoid blocking common safe patterns.
* - We do not execute the regex here; only scan the pattern string.
*/
export function isLikelyUnsafeJsRegex(pattern: string): boolean {
let inCharClass = false;
const groupStack: GroupState[] = [
{ hasInnerVariableQuantifier: false, hasAlternation: false, justOpened: false },
];
const markInnerVariableQuantifier = () => {
for (let i = 0; i < groupStack.length; i += 1) {
groupStack[i].hasInnerVariableQuantifier = true;
}
};
const markAlternation = () => {
for (let i = 0; i < groupStack.length; i += 1) {
groupStack[i].hasAlternation = true;
}
};
const isOuterRepeatRisky = (q: Quantifier): boolean => {
// If it cannot repeat more than once, it's not a "repeat group" in the sense that
// triggers catastrophic backtracking (e.g. `(a+)?`).
const max = q.max ?? Number.POSITIVE_INFINITY;
if (max <= 1) return false;
// Unbounded repetition is the main hazard: `*`, `+`, `{m,}`.
if (q.max === null) return true;
// Large fixed/variable upper bounds also explode combinatorially with an inner variable quantifier.
return q.max > OUTER_REPEAT_MAX_SAFE_UPPER_BOUND;
};
for (let i = 0; i < pattern.length; i += 1) {
const ch = pattern[i];
// Reset "justOpened" once we move past the first token inside the group.
const top = groupStack[groupStack.length - 1];
if (top.justOpened) {
top.justOpened = false;
// `(?...)` group prefixes use `?` immediately after `(` and are not quantifiers.
if (ch === '?') continue;
}
if (ch === '\\') {
const next = pattern[i + 1];
// Backreferences often make backtracking far worse.
if (next && next >= '1' && next <= '9') return true;
// Named backreference: \k<name>
if (next === 'k' && pattern[i + 2] === '<') return true;
// Skip escaped character.
i += 1;
continue;
}
if (inCharClass) {
if (ch === ']') inCharClass = false;
continue;
}
if (ch === '[') {
inCharClass = true;
continue;
}
if (ch === '(') {
groupStack.push({ hasInnerVariableQuantifier: false, hasAlternation: false, justOpened: true });
continue;
}
if (ch === ')') {
const group = groupStack.pop();
if (!group) return true; // unbalanced, treat as unsafe
const q = readQuantifier(pattern, i + 1);
if (
q &&
isOuterRepeatRisky(q) &&
(group.hasInnerVariableQuantifier || group.hasAlternation)
) {
return true;
}
continue;
}
if (ch === '|') {
// Alternation inside a repeated group is frequently a backtracking hotspot.
markAlternation();
continue;
}
const q = readQuantifier(pattern, i);
if (q) {
if (q.variable) markInnerVariableQuantifier();
i += q.length - 1;
continue;
}
}
return false;
}