From c54e3f0d6347d3436fe270c11e49e363eae73d19 Mon Sep 17 00:00:00 2001 From: hoangduy0308 Date: Wed, 13 May 2026 02:42:26 +0700 Subject: [PATCH 1/2] fix(auth-files): harden auth JSON import Add the auth JSON paste/import flow with converter hardening, provider-aware CPA validation, upload failure handling, duplicate-save guards, hidden Unicode checks, and focused regression coverage. --- package-lock.json | 89 +- package.json | 3 + .../components/AuthJsonPasteModal.module.scss | 60 + .../components/AuthJsonPasteModal.test.tsx | 232 ++++ .../components/AuthJsonPasteModal.tsx | 205 ++++ .../authFiles/hooks/useAuthFilesData.test.ts | 411 +++++++ .../authFiles/hooks/useAuthFilesData.ts | 78 +- .../authFiles/sessionAuthConverter.test.ts | 1079 +++++++++++++++++ .../authFiles/sessionAuthConverter.ts | 787 ++++++++++++ src/i18n/locales/en.json | 18 + src/i18n/locales/ru.json | 18 + src/i18n/locales/zh-CN.json | 18 + src/i18n/locales/zh-TW.json | 18 + .../AuthFilesPage.authJsonPaste.test.tsx | 287 +++++ src/pages/AuthFilesPage.module.scss | 2 - .../AuthFilesPage.pasteIntegration.test.tsx | 339 ++++++ src/pages/AuthFilesPage.tsx | 64 +- src/services/api/authFiles.test.ts | 156 +++ src/services/api/authFiles.ts | 93 +- tests/repoSourceIntegrity.test.mjs | 165 +++ tsconfig.json | 1 + tsconfig.node.json | 3 + 22 files changed, 4041 insertions(+), 85 deletions(-) create mode 100644 src/features/authFiles/components/AuthJsonPasteModal.module.scss create mode 100644 src/features/authFiles/components/AuthJsonPasteModal.test.tsx create mode 100644 src/features/authFiles/components/AuthJsonPasteModal.tsx create mode 100644 src/features/authFiles/hooks/useAuthFilesData.test.ts create mode 100644 src/features/authFiles/sessionAuthConverter.test.ts create mode 100644 src/features/authFiles/sessionAuthConverter.ts create mode 100644 src/pages/AuthFilesPage.authJsonPaste.test.tsx create mode 100644 src/pages/AuthFilesPage.pasteIntegration.test.tsx create mode 100644 src/services/api/authFiles.test.ts create mode 100644 tests/repoSourceIntegrity.test.mjs diff --git a/package-lock.json b/package-lock.json index e9cea2898..b4ce76eb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,8 +23,10 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@types/node": "^25.7.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/react-test-renderer": "^19.1.0", "@typescript-eslint/eslint-plugin": "^8.48.1", "@typescript-eslint/parser": "^8.48.1", "@vitejs/plugin-react": "^6.0.1", @@ -33,6 +35,7 @@ "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "prettier": "^3.7.4", + "react-test-renderer": "^19.2.1", "sass": "^1.94.2", "typescript": "^5.9.3", "typescript-eslint": "^8.48.1", @@ -72,6 +75,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -437,6 +441,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz", "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -444,29 +449,6 @@ "w3c-keyname": "^2.2.4" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1451,12 +1433,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", + "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.21.0" + } + }, "node_modules/@types/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1471,6 +1465,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-test-renderer": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz", + "integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react/node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1544,6 +1548,7 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -1979,6 +1984,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2429,6 +2435,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2950,6 +2957,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -3621,6 +3629,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3732,6 +3741,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3741,6 +3751,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3775,6 +3786,13 @@ } } }, + "node_modules/react-is": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "dev": true, + "license": "MIT" + }, "node_modules/react-router": { "version": "7.12.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", @@ -3813,6 +3831,20 @@ "react-dom": ">=18" } }, + "node_modules/react-test-renderer": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.2.1.tgz", + "integrity": "sha512-xsyf515ij+d8Rs/tsDZSJYXn+GdYO/IOw9BVFtjJbrrFR+dL1yZQQN1ChxY6otYAsCeAHhU0XsKyJUzP6omyvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-is": "^19.2.1", + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.1" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -4078,6 +4110,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4110,6 +4143,13 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici-types": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", + "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.2", "dev": true, @@ -4154,6 +4194,7 @@ "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -4398,6 +4439,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -4425,6 +4467,7 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 80c7ce20e..4ba870dd7 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,10 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@types/node": "^25.7.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/react-test-renderer": "^19.1.0", "@typescript-eslint/eslint-plugin": "^8.48.1", "@typescript-eslint/parser": "^8.48.1", "@vitejs/plugin-react": "^6.0.1", @@ -38,6 +40,7 @@ "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "prettier": "^3.7.4", + "react-test-renderer": "^19.2.1", "sass": "^1.94.2", "typescript": "^5.9.3", "typescript-eslint": "^8.48.1", diff --git a/src/features/authFiles/components/AuthJsonPasteModal.module.scss b/src/features/authFiles/components/AuthJsonPasteModal.module.scss new file mode 100644 index 000000000..173443950 --- /dev/null +++ b/src/features/authFiles/components/AuthJsonPasteModal.module.scss @@ -0,0 +1,60 @@ +@use '../../../styles/variables' as *; + +.authJsonPasteModal { + display: flex; + flex-direction: column; + gap: $spacing-md; +} + +.prefixProxyError { + padding: $spacing-sm $spacing-md; + border-radius: $radius-md; + border: 1px solid var(--danger-color); + background-color: rgba($error-color, 0.1); + color: var(--danger-color); + font-size: 12px; +} + +.formGroup { + display: flex; + flex-direction: column; + gap: $spacing-xs; + margin-top: $spacing-md; + + label { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + } +} + +.authJsonPasteTextarea { + width: 100%; + min-height: 300px; + box-sizing: border-box; + padding: $spacing-sm $spacing-md; + border: 1px solid var(--border-color); + border-radius: $radius-md; + background-color: var(--bg-secondary); + color: var(--text-primary); + font-family: monospace; + font-size: 12px; + line-height: 1.6; + resize: vertical; + + &:focus { + outline: none; + border-color: var(--primary-color); + } + + &::placeholder { + color: var(--text-tertiary); + } +} + +.authJsonPasteHint { + margin: 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; +} diff --git a/src/features/authFiles/components/AuthJsonPasteModal.test.tsx b/src/features/authFiles/components/AuthJsonPasteModal.test.tsx new file mode 100644 index 000000000..b8b1e5569 --- /dev/null +++ b/src/features/authFiles/components/AuthJsonPasteModal.test.tsx @@ -0,0 +1,232 @@ +import { act, type ReactNode } from 'react'; +import { create, type ReactTestRenderer } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Select } from '@/components/ui/Select'; +import { AuthJsonPasteModal } from './AuthJsonPasteModal'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@/components/ui/Modal', () => ({ + Modal: (props: { children: ReactNode; footer?: ReactNode }) => ( +
+
{props.children}
+
{props.footer}
+
+ ), +})); + +type ModalHarness = { + renderer: ReactTestRenderer; + clickSave: () => Promise; + setFileName: (value: string) => void; + setJsonText: (value: string) => void; + setType: (value: 'session' | 'cpa') => void; + getText: () => string; +}; + + const mountModal = ( + onSave: (type: 'session' | 'cpa', fileName: string, jsonText: string) => Promise, + saving = false, + disabled = false + ): ModalHarness => { + let renderer: ReactTestRenderer; + act(() => { + renderer = create( + {}} + onSave={onSave} + /> + ); + }); + + const setFileName = (value: string) => { + const input = renderer!.root.findByType(Input); + act(() => { + input.props.onChange({ target: { value } }); + }); + }; + + const setJsonText = (value: string) => { + const textarea = renderer!.root.findByProps({ id: 'auth-json-paste-content' }); + act(() => { + textarea.props.onChange({ target: { value } }); + }); + }; + + const setType = (value: 'session' | 'cpa') => { + const select = renderer!.root.findByType(Select); + act(() => { + select.props.onChange(value); + }); + }; + + const clickSave = async () => { + const saveButton = renderer!.root + .findAllByType(Button) + .find((node) => node.props.children === 'auth_files.paste_save_button'); + if (!saveButton) throw new Error('Save button not found'); + await act(async () => { + await saveButton.props.onClick(); + }); + }; + + const getText = () => JSON.stringify(renderer!.toJSON()); + + return { + renderer: renderer!, + clickSave, + setFileName, + setJsonText, + setType, + getText, + }; +}; + +describe('AuthJsonPasteModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('rejects invalid file names without calling save', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const modal = mountModal(onSave); + + modal.setFileName('invalid/name.json'); + modal.setJsonText('{"type":"codex"}'); + + await modal.clickSave(); + + expect(onSave).not.toHaveBeenCalled(); + expect(modal.getText()).toContain('auth_files.paste_error_file_name_invalid'); + modal.renderer.unmount(); + }); + + it.each([ + 'CON.json', + 'CON.codex.json', + 'AUX.json', + 'NUL.json', + '.json', + '.codex.json', + '.hidden.json', + 'LPT1.json', + 'LPT1.backup.json', + 'name .json', + 'name..json', + ])( + 'rejects Windows-unsafe file name %s without calling save', + async (fileName) => { + const onSave = vi.fn().mockResolvedValue(undefined); + const modal = mountModal(onSave); + + modal.setFileName(fileName); + modal.setJsonText('{"type":"codex"}'); + + await modal.clickSave(); + + expect(onSave).not.toHaveBeenCalled(); + expect(modal.getText()).toContain('auth_files.paste_error_file_name_invalid'); + modal.renderer.unmount(); + } + ); + + it.each([ + 'codex-\u202Egpj.json', + 'codex-\u2066account.json', + 'codex-\u200Baccount.json', + ])('rejects visually misleading file name %s without calling save', async (fileName) => { + const onSave = vi.fn().mockResolvedValue(undefined); + const modal = mountModal(onSave); + + modal.setFileName(fileName); + modal.setJsonText('{"type":"codex"}'); + + await modal.clickSave(); + + expect(onSave).not.toHaveBeenCalled(); + expect(modal.getText()).toContain('auth_files.paste_error_file_name_invalid'); + modal.renderer.unmount(); + }); + + it('rejects empty json text without calling save', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const modal = mountModal(onSave); + + modal.setFileName('valid.json'); + modal.setJsonText(' '); + + await modal.clickSave(); + + expect(onSave).not.toHaveBeenCalled(); + expect(modal.getText()).toContain('auth_files.paste_error_json_required'); + modal.renderer.unmount(); + }); + + it('passes selected type, file name, and json text to save', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const modal = mountModal(onSave); + + modal.setType('cpa'); + modal.setFileName('custom-auth.json'); + modal.setJsonText('{"type":"codex","email":"user@example.com"}'); + + await modal.clickSave(); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave).toHaveBeenCalledWith( + 'cpa', + 'custom-auth.json', + '{"type":"codex","email":"user@example.com"}' + ); + modal.renderer.unmount(); + }); + + it('does not save again while a save is already in progress', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const modal = mountModal(onSave, true); + + modal.setFileName('custom-auth.json'); + modal.setJsonText('{"type":"codex","email":"user@example.com"}'); + + await modal.clickSave(); + + expect(onSave).not.toHaveBeenCalled(); + modal.renderer.unmount(); + }); + + it('does not save while disabled', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const modal = mountModal(onSave, false, true); + + modal.setFileName('custom-auth.json'); + modal.setJsonText('{"type":"codex","email":"user@example.com"}'); + + await modal.clickSave(); + + expect(onSave).not.toHaveBeenCalled(); + modal.renderer.unmount(); + }); + + it('renders save error returned by onSave', async () => { + const onSave = vi.fn().mockRejectedValue(new Error('upload failed')); + const modal = mountModal(onSave); + + modal.setFileName('custom-auth.json'); + modal.setJsonText('{"type":"codex"}'); + + await modal.clickSave(); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(modal.getText()).toContain('upload failed'); + modal.renderer.unmount(); + }); +}); diff --git a/src/features/authFiles/components/AuthJsonPasteModal.tsx b/src/features/authFiles/components/AuthJsonPasteModal.tsx new file mode 100644 index 000000000..a151d01ed --- /dev/null +++ b/src/features/authFiles/components/AuthJsonPasteModal.tsx @@ -0,0 +1,205 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal } from '@/components/ui/Modal'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Select } from '@/components/ui/Select'; +import type { AuthJsonInputType } from '@/features/authFiles/sessionAuthConverter'; +import styles from './AuthJsonPasteModal.module.scss'; + +type AuthJsonPasteModalProps = { + open: boolean; + saving: boolean; + disabled?: boolean; + onClose: () => void; + onSave: (type: AuthJsonInputType, fileName: string, jsonText: string) => Promise; +}; + +const DEFAULT_FILE_NAME = 'codex-account.json'; +const INVALID_BASE_FILE_NAME_PATTERN = /[\\/:*?"<>|]/; +const FORBIDDEN_INVISIBLE_CODE_POINTS = new Set([ + 0x200b, + 0x200c, + 0x200d, + 0x200e, + 0x200f, + 0x202a, + 0x202b, + 0x202c, + 0x202d, + 0x202e, + 0x2060, + 0x2066, + 0x2067, + 0x2068, + 0x2069, + 0xfeff, +]); +const WINDOWS_RESERVED_BASE_NAMES = new Set([ + 'con', + 'prn', + 'aux', + 'nul', + 'com1', + 'com2', + 'com3', + 'com4', + 'com5', + 'com6', + 'com7', + 'com8', + 'com9', + 'lpt1', + 'lpt2', + 'lpt3', + 'lpt4', + 'lpt5', + 'lpt6', + 'lpt7', + 'lpt8', + 'lpt9', +]); + +const isValidBaseJsonFileName = (value: string) => { + const lowerValue = value.toLowerCase(); + const baseName = value.slice(0, -'.json'.length); + const windowsDeviceName = baseName.split('.')[0]?.toLowerCase() ?? ''; + + return ( + lowerValue.endsWith('.json') && + baseName !== '' && + baseName.trim() === baseName && + !baseName.startsWith('.') && + !baseName.endsWith('.') && + !WINDOWS_RESERVED_BASE_NAMES.has(windowsDeviceName) && + !INVALID_BASE_FILE_NAME_PATTERN.test(value) && + !Array.from(value).some((char) => { + const codePoint = char.codePointAt(0); + return ( + codePoint === undefined || + codePoint < 32 || + FORBIDDEN_INVISIBLE_CODE_POINTS.has(codePoint) + ); + }) + ); +}; + +export function AuthJsonPasteModal({ + open, + saving, + disabled = false, + onClose, + onSave, +}: AuthJsonPasteModalProps) { + const { t } = useTranslation(); + const [type, setType] = useState('session'); + const [fileName, setFileName] = useState(DEFAULT_FILE_NAME); + const [jsonText, setJsonText] = useState(''); + const [error, setError] = useState(''); + + const resetForm = () => { + setType('session'); + setFileName(DEFAULT_FILE_NAME); + setJsonText(''); + setError(''); + }; + + const handleClose = () => { + resetForm(); + onClose(); + }; + + const options = useMemo( + () => [ + { value: 'cpa', label: t('auth_files.paste_type_cpa') }, + { value: 'session', label: t('auth_files.paste_type_session') }, + ], + [t] + ); + + const handleSave = async () => { + if (saving || disabled) return; + + const trimmedName = fileName.trim(); + if (!trimmedName) { + setError(t('auth_files.paste_error_file_name')); + return; + } + if (!isValidBaseJsonFileName(trimmedName)) { + setError(t('auth_files.paste_error_file_name_invalid')); + return; + } + if (!jsonText.trim()) { + setError(t('auth_files.paste_error_json_required')); + return; + } + + setError(''); + try { + await onSave(type, trimmedName, jsonText); + resetForm(); + } catch (err) { + setError(err instanceof Error ? err.message : t('notification.save_failed')); + } + }; + + return ( + + + + + } + > +
+ {error &&
{error}
} +
+ + setFileName(event.target.value)} + disabled={saving || disabled} + placeholder={DEFAULT_FILE_NAME} + /> +
+ +