Skip to content
Open
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
14 changes: 14 additions & 0 deletions src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,20 @@ export const jaOverrides = {
'authFiles.runtimeDeleteError': '実行時認証ファイルはディスクから削除できません',
'authFiles.deleteConfirm': '「{name}」を削除しますか?',
'authFiles.deleted': '認証ファイルを削除しました',
'authFiles.batch.selected': '{count} 件選択中',
'authFiles.batch.selectVisible': 'すべて選択',
'authFiles.batch.deselectVisible': '表示中の選択を解除',
'authFiles.batch.clear': '選択を解除',
'authFiles.batch.selectCredential': '{name} を選択',
'authFiles.batch.enable': '有効化({count})',
'authFiles.batch.disable': '無効化({count})',
'authFiles.batch.delete': '削除({count})',
'authFiles.batch.enabled': '認証ファイルを {count} 件有効化しました',
'authFiles.batch.disabled': '認証ファイルを {count} 件無効化しました',
'authFiles.batch.deleted': '認証ファイルを {count} 件削除しました',
'authFiles.batch.failed': '{count} 件の一括操作に失敗しました:{errors}',
'authFiles.batch.deleteConfirm': '選択した認証ファイル {count} 件を削除しますか?',
'authFiles.batch.runtimeSkipped': '実行時認証情報 {count} 件はスキップされます。',
'common.copyFailed': 'コピーに失敗しました',
'authFiles.title': '認証ファイル',
'authFiles.summary': 'ファイル {files} 件 · 無効 {disabled} 件',
Expand Down
14 changes: 14 additions & 0 deletions src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,20 @@ export const en: Record<MessageKey, string> = {
'authFiles.runtimeDeleteError': 'Runtime authentication files cannot be deleted from disk',
'authFiles.deleteConfirm': 'Delete “{name}”?',
'authFiles.deleted': 'Authentication file deleted',
'authFiles.batch.selected': '{count} selected',
'authFiles.batch.selectVisible': 'Select all',
'authFiles.batch.deselectVisible': 'Deselect Visible',
'authFiles.batch.clear': 'Clear selection',
'authFiles.batch.selectCredential': 'Select {name}',
'authFiles.batch.enable': 'Enable ({count})',
'authFiles.batch.disable': 'Disable ({count})',
'authFiles.batch.delete': 'Delete ({count})',
'authFiles.batch.enabled': 'Enabled {count} authentication files',
'authFiles.batch.disabled': 'Disabled {count} authentication files',
'authFiles.batch.deleted': 'Deleted {count} authentication files',
'authFiles.batch.failed': '{count} batch operations failed: {errors}',
'authFiles.batch.deleteConfirm': 'Delete the {count} selected authentication files?',
'authFiles.batch.runtimeSkipped': '{count} runtime credentials will be skipped.',
'common.copyFailed': 'Copy failed',
'authFiles.title': 'Authentication Files',
'authFiles.summary': '{files} files · {disabled} disabled',
Expand Down
14 changes: 14 additions & 0 deletions src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,20 @@ export const zhCN = {
'authFiles.runtimeDeleteError': '运行时认证文件不能从磁盘删除',
'authFiles.deleteConfirm': '确定删除“{name}”吗?',
'authFiles.deleted': '认证文件已删除',
'authFiles.batch.selected': '已选择 {count} 个',
'authFiles.batch.selectVisible': '全选',
'authFiles.batch.deselectVisible': '取消当前结果',
'authFiles.batch.clear': '清除选择',
'authFiles.batch.selectCredential': '选择 {name}',
'authFiles.batch.enable': '启用({count})',
'authFiles.batch.disable': '停用({count})',
'authFiles.batch.delete': '删除({count})',
'authFiles.batch.enabled': '已启用 {count} 个认证文件',
'authFiles.batch.disabled': '已停用 {count} 个认证文件',
'authFiles.batch.deleted': '已删除 {count} 个认证文件',
'authFiles.batch.failed': '{count} 个批次操作失败:{errors}',
'authFiles.batch.deleteConfirm': '确定删除选择的 {count} 个认证文件吗?',
'authFiles.batch.runtimeSkipped': '将跳过 {count} 个运行时凭据。',
'common.copyFailed': '复制失败',
'authFiles.title': '认证文件',
'authFiles.summary': '{files} 个文件 · {disabled} 个停用',
Expand Down
138 changes: 137 additions & 1 deletion src/pages/AuthFileManagementPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
useQuotaCache,
} from '../services/quotaCache';
import {
authFilesForBatchAction,
authFileName,
dedupeAuthFiles,
isRuntimeOnlyAuthFile,
Expand Down Expand Up @@ -154,6 +155,7 @@ export function AuthFileManagementPage() {
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [copied, setCopied] = useState('');
const [selectedNames, setSelectedNames] = useState<Set<string>>(new Set());
const [priorityEditor, setPriorityEditor] = useState<PriorityEditor | null>(null);
const [oauthModelProvider, setOauthModelProvider] = useState('');
const [oauthModelProviderLabel, setOauthModelProviderLabel] = useState('');
Expand All @@ -166,6 +168,7 @@ export function AuthFileManagementPage() {
const [oauthModelError, setOauthModelError] = useState('');
const quotas = useQuotaCache();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const selectVisibleRef = useRef<HTMLInputElement | null>(null);
const oauthModelRequestRef = useRef(0);

const loadFiles = useCallback(async () => {
Expand All @@ -175,6 +178,10 @@ export function AuthFileManagementPage() {
const payload = await managementApi.get('/auth-files');
const nextFiles = dedupeAuthFiles(responseList(payload, 'files'));
setFiles(nextFiles);
const validNames = new Set(nextFiles.map(fileName));
setSelectedNames((current) => new Set(
Array.from(current).filter((name) => validNames.has(name)),
));
const validQuotaKeys = new Set(nextFiles.map(quotaKey));
pruneQuotaCache(validQuotaKeys);
updateQuotaCache((current) => {
Expand Down Expand Up @@ -327,6 +334,53 @@ export function AuthFileManagementPage() {
});
}, [files, filter, providerFilter, statusFilter]);

const selectedFiles = useMemo(
() => files.filter((file) => selectedNames.has(fileName(file))),
[files, selectedNames],
);
const enableTargets = useMemo(
() => authFilesForBatchAction(files, selectedNames, 'enable'),
[files, selectedNames],
);
const disableTargets = useMemo(
() => authFilesForBatchAction(files, selectedNames, 'disable'),
[files, selectedNames],
);
const deleteTargets = useMemo(
() => authFilesForBatchAction(files, selectedNames, 'delete'),
[files, selectedNames],
);
const visibleNames = visibleFiles.map(fileName);
const selectedVisibleCount = visibleNames.filter((name) => selectedNames.has(name)).length;
const allVisibleSelected = visibleNames.length > 0 && selectedVisibleCount === visibleNames.length;
const someVisibleSelected = selectedVisibleCount > 0 && !allVisibleSelected;

useEffect(() => {
if (selectVisibleRef.current) {
selectVisibleRef.current.indeterminate = someVisibleSelected;
}
}, [someVisibleSelected]);

const toggleFileSelection = (name: string) => {
setSelectedNames((current) => {
const next = new Set(current);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
};

const toggleVisibleSelection = () => {
setSelectedNames((current) => {
const next = new Set(current);
visibleNames.forEach((name) => {
if (allVisibleSelected) next.delete(name);
else next.add(name);
});
return next;
});
};

const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = '';
Expand Down Expand Up @@ -459,6 +513,60 @@ export function AuthFileManagementPage() {
}
};

const runBatchAction = async (
action: 'enable' | 'disable' | 'delete',
targets: AuthFile[],
) => {
if (busy || targets.length === 0) return;
if (action === 'delete') {
const skipped = selectedFiles.length - targets.length;
const detail = skipped > 0 ? `\n\n${t('authFiles.batch.runtimeSkipped', { count: skipped })}` : '';
if (!window.confirm(`${t('authFiles.batch.deleteConfirm', { count: targets.length })}${detail}`)) return;
}

setBusy(true);
setError('');
setNotice('');
let completed = 0;
const failures: string[] = [];
for (const file of targets) {
const name = fileName(file);
try {
if (action === 'delete') {
await managementApi.delete('/auth-files', { query: { name } });
} else {
await managementApi.patch('/auth-files/status', {
name,
disabled: action === 'disable',
});
}
completed += 1;
} catch (requestError) {
failures.push(`${name}: ${String(requestError)}`);
}
}

try {
await loadFiles();
if (completed > 0) {
const messageKey = action === 'enable'
? 'authFiles.batch.enabled'
: action === 'disable'
? 'authFiles.batch.disabled'
: 'authFiles.batch.deleted';
setNotice(t(messageKey, { count: completed }));
}
if (failures.length > 0) {
setError(t('authFiles.batch.failed', {
count: failures.length,
errors: failures.join('; '),
}));
}
} finally {
setBusy(false);
}
};

const disabledCount = files.filter((file) => readBoolean(file, 'disabled')).length;
const runtimeCount = files.filter(isRuntimeOnly).length;

Expand Down Expand Up @@ -503,6 +611,31 @@ export function AuthFileManagementPage() {
</select>
</div>

{files.length > 0 ? (
<div className={`auth-files-batch-toolbar ${selectedNames.size > 0 ? 'active' : ''}`}>
<label className={`auth-files-select-visible ${busy || visibleNames.length === 0 ? 'disabled' : ''}`}>
<input
ref={selectVisibleRef}
type="checkbox"
checked={allVisibleSelected}
disabled={busy || visibleNames.length === 0}
aria-label={allVisibleSelected ? t('authFiles.batch.deselectVisible') : t('authFiles.batch.selectVisible')}
onChange={toggleVisibleSelection}
/>
<span>{t('authFiles.batch.selectVisible')}</span>
</label>
<strong>{t('authFiles.batch.selected', { count: selectedNames.size })}</strong>
{selectedNames.size > 0 ? (
<div className="auth-files-batch-actions">
<button type="button" className="secondary-button compact-button" onClick={() => void runBatchAction('enable', enableTargets)} disabled={busy || enableTargets.length === 0}>{t('authFiles.batch.enable', { count: enableTargets.length })}</button>
<button type="button" className="secondary-button compact-button" onClick={() => void runBatchAction('disable', disableTargets)} disabled={busy || disableTargets.length === 0}>{t('authFiles.batch.disable', { count: disableTargets.length })}</button>
<button type="button" className="danger-button compact-button" onClick={() => void runBatchAction('delete', deleteTargets)} disabled={busy || deleteTargets.length === 0}><Trash2 size={15} />{t('authFiles.batch.delete', { count: deleteTargets.length })}</button>
<button type="button" className="icon-button quiet" onClick={() => setSelectedNames(new Set())} disabled={busy} title={t('authFiles.batch.clear')}><X size={16} /></button>
</div>
) : null}
</div>
) : null}

{loading ? (
<div className="management-loading"><LoaderCircle size={20} className="spin" />{t('authFiles.loading')}</div>
) : visibleFiles.length === 0 ? (
Expand All @@ -515,7 +648,10 @@ export function AuthFileManagementPage() {
const disabled = readBoolean(file, 'disabled');
const priority = parseAuthFilePriority(file.priority) ?? 0;
return (
<article className={`real-auth-file-row ${disabled ? 'disabled' : ''}`} key={`${name}-${readString(file, 'auth_index', 'authIndex')}`}>
<article className={`real-auth-file-row ${disabled ? 'disabled' : ''} ${selectedNames.has(name) ? 'selected' : ''}`} key={`${name}-${readString(file, 'auth_index', 'authIndex')}`}>
<label className="auth-file-select" title={t('authFiles.batch.selectCredential', { name })}>
<input type="checkbox" checked={selectedNames.has(name)} onChange={() => toggleFileSelection(name)} aria-label={t('authFiles.batch.selectCredential', { name })} />
</label>
<img src={icon} alt="" className="provider-logo" />
<div className="auth-file-main">
<div className="auth-file-title"><strong title={name}>{name}</strong><span className={`state-pill ${disabled ? 'error' : readBoolean(file, 'unavailable') ? 'error' : 'success'}`} title={readString(file, 'status_message', 'statusMessage') || undefined}>{statusText(file)}</span></div>
Expand Down
12 changes: 12 additions & 0 deletions src/services/authFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,25 @@ import { getCurrentLocale, translate } from '../i18n';

export type AuthFileRecord = Record<string, unknown>;
export type AuthFileSnapshot = Map<string, string>;
export type AuthFileBatchAction = 'enable' | 'disable' | 'delete';

export const authFileName = (file: AuthFileRecord) =>
readString(file, 'name') || translate(getCurrentLocale(), 'authFiles.unnamed');

export const isRuntimeOnlyAuthFile = (file: AuthFileRecord) =>
readBoolean(file, 'runtime_only', 'runtimeOnly');

export const authFilesForBatchAction = (
files: AuthFileRecord[],
selectedNames: ReadonlySet<string>,
action: AuthFileBatchAction,
) => files.filter((file) => {
if (!selectedNames.has(authFileName(file))) return false;
if (action === 'delete') return !isRuntimeOnlyAuthFile(file);
const disabled = readBoolean(file, 'disabled');
return action === 'enable' ? disabled : !disabled;
});

export const parseAuthFilePriority = (value: unknown): number | undefined => {
if (typeof value === 'number') {
return Number.isSafeInteger(value) ? value : undefined;
Expand Down
Loading