From f5ae311a82705fb28c2b954f5d590dda3807e745 Mon Sep 17 00:00:00 2001 From: rednakse Date: Thu, 2 Apr 2026 01:25:38 +0300 Subject: [PATCH] Restore settings preferences and simplify services editor --- docs/PLAN.md | 2 + docs/PROJECT_INDEX.md | 18 +- server/app.test.ts | 26 ++- server/app.ts | 19 +- server/lib/config.test.ts | 2 +- server/lib/config.ts | 4 - server/lib/store.ts | 34 ++- src/App.test.tsx | 76 +++++- src/App.tsx | 249 +++++++++++++------- src/SystemTab.tsx | 450 ++++++++++++++++++++---------------- src/app.css | 50 +++- src/data/mockDashboard.ts | 10 - src/lib/panelPreferences.ts | 74 ++++++ src/lib/panelText.ts | 243 +++++++++++++++++++ src/shared/contracts.ts | 2 +- src/shared/validation.ts | 8 +- 16 files changed, 934 insertions(+), 333 deletions(-) create mode 100644 src/lib/panelPreferences.ts create mode 100644 src/lib/panelText.ts diff --git a/docs/PLAN.md b/docs/PLAN.md index 49c39da..a7de2cc 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -31,3 +31,5 @@ Updated: 2026-04-02 15. Added editable System settings with shared validation, a system update API, service/port conflict protection, and UI coverage for local save flows. 16. Simplified the System tab layout by removing the redundant Runtime card and collapsing those fields into a compact settings section. 17. Moved panel auth to server-issued expiring tokens with `sessionStorage` persistence and Compose-configurable credentials/TTL. +18. Restored panel language/theme preferences in Settings with `localStorage`, merged service `Command/Protocol` into a single `Type`, and removed the legacy `admin` service path from managed panel state. +19. Added service-removal confirmation with linked-user warnings, backend cascade deletion for removed services, and migration that strips persisted legacy `admin` services from stored state. diff --git a/docs/PROJECT_INDEX.md b/docs/PROJECT_INDEX.md index 35180df..d8a624f 100644 --- a/docs/PROJECT_INDEX.md +++ b/docs/PROJECT_INDEX.md @@ -23,27 +23,29 @@ Updated: 2026-04-02 ## Frontend - `src/main.tsx`: application bootstrap -- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, and protected panel mutations -- `src/SystemTab.tsx`: editable system settings and managed services form with compact panel-level controls -- `src/App.test.tsx`: login-gate, modal interaction, pause/resume, delete-confirm, and system-save UI tests +- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, localized labels, theme application, and protected panel mutations +- `src/SystemTab.tsx`: Settings tab with panel language/style preferences, unified service type editing, remove confirmation, and generated config preview +- `src/App.test.tsx`: login-gate, preferences persistence, modal interaction, pause/resume, delete-confirm, and settings-save UI tests - `src/app.css`: full panel styling - `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot - `src/lib/3proxy.ts`: formatting and status helpers - `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules +- `src/lib/panelPreferences.ts`: `localStorage`-backed panel language/theme preferences plus theme application helpers +- `src/lib/panelText.ts`: English/Russian UI text catalog for the panel shell and settings flows - `src/shared/contracts.ts`: shared panel, service, user, and API data contracts -- `src/shared/validation.ts`: shared validation for user creation, system edits, protocol mapping, and quota conversion +- `src/shared/validation.ts`: shared validation for user creation, system edits, service type mapping, and quota conversion - `src/test/setup.ts`: Testing Library matchers ## Server - `server/index.ts`: backend entrypoint and runtime bootstrap -- `server/app.ts`: Express app with login, protected panel state/runtime routes, and writable system configuration API -- `server/app.test.ts`: API tests for user management plus system-update safety edge cases +- `server/app.ts`: Express app with login, protected panel state/runtime routes, and writable system configuration API with linked-user cleanup on removed services +- `server/app.test.ts`: API tests for user management plus system-update safety, cascade delete, and config edge cases - `server/lib/auth.ts`: expiring token issuance and bearer-token verification for the panel -- `server/lib/config.ts`: 3proxy config renderer, validation, and dashboard derivation +- `server/lib/config.ts`: 3proxy config renderer, validation, and dashboard derivation for SOCKS/HTTP managed services - `server/lib/config.test.ts`: config-generation regression tests - `server/lib/runtime.ts`: managed 3proxy process controller -- `server/lib/store.ts`: JSON-backed persistent state store +- `server/lib/store.ts`: JSON-backed persistent state store with legacy admin-service migration ## Static diff --git a/server/app.test.ts b/server/app.test.ts index 5c4005a..7d790de 100644 --- a/server/app.test.ts +++ b/server/app.test.ts @@ -69,13 +69,13 @@ describe('panel api', () => { expect(typeof response.body.expiresAt).toBe('string'); }); - it('rejects user creation against a non-assignable service', async () => { + it('rejects user creation against a disabled service', async () => { const app = await createTestApp(); const token = await authorize(app); const response = await request(app).post('/api/users').set('Authorization', `Bearer ${token}`).send({ - username: 'bad-admin-user', + username: 'bad-proxy-user', password: 'secret123', - serviceId: 'admin', + serviceId: 'proxy', quotaMb: 100, }); @@ -132,6 +132,26 @@ describe('panel api', () => { expect(response.body.error).toMatch(/night-shift/i); }); + it('removes linked users when a service is deleted from settings', async () => { + const app = await createTestApp(); + const token = await authorize(app); + const initial = await request(app).get('/api/state').set('Authorization', `Bearer ${token}`); + const system = createSystemPayload(initial.body); + + system.services = system.services.filter((service) => service.id !== 'socks-main'); + + const response = await request(app).put('/api/system').set('Authorization', `Bearer ${token}`).send(system); + + expect(response.status).toBe(200); + expect(response.body.userRecords.some((entry: { username: string }) => entry.username === 'night-shift')).toBe( + false, + ); + expect(response.body.userRecords.some((entry: { username: string }) => entry.username === 'ops-east')).toBe( + false, + ); + expect(response.body.service.lastEvent).toMatch(/removed 2 linked users/i); + }); + it('updates system settings and regenerates the rendered config', async () => { const app = await createTestApp(); const token = await authorize(app); diff --git a/server/app.ts b/server/app.ts index 58c125b..9b89ae7 100644 --- a/server/app.ts +++ b/server/app.ts @@ -129,8 +129,23 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices) app.put('/api/system', async (request, response, next) => { try { const state = await store.read(); - state.system = validateSystemInput(request.body as Partial, state.userRecords); - state.service.lastEvent = 'System configuration updated from panel'; + const requestedSystem = request.body as Partial; + const nextServiceIds = new Set( + (Array.isArray(requestedSystem.services) ? requestedSystem.services : []).map((service) => service.id), + ); + const removedServiceIds = new Set( + state.system.services + .map((service) => service.id) + .filter((serviceId) => !nextServiceIds.has(serviceId)), + ); + + const removedUsers = state.userRecords.filter((user) => removedServiceIds.has(user.serviceId)); + state.userRecords = state.userRecords.filter((user) => !removedServiceIds.has(user.serviceId)); + state.system = validateSystemInput(requestedSystem, state.userRecords); + state.service.lastEvent = + removedUsers.length > 0 + ? `System configuration updated from panel and removed ${removedUsers.length} linked users` + : 'System configuration updated from panel'; await persistRuntimeMutation(store, runtime, state, runtimePaths); response.json(await getSnapshot(store, runtime, runtimePaths)); } catch (error) { diff --git a/server/lib/config.test.ts b/server/lib/config.test.ts index 05753b5..2a79399 100644 --- a/server/lib/config.test.ts +++ b/server/lib/config.test.ts @@ -15,7 +15,7 @@ describe('render3proxyConfig', () => { expect(config).toContain('socks -p1080 -u2'); expect(config).toContain('socks -p2080 -u2'); - expect(config).toContain('admin -p8081 -s'); + expect(config).not.toContain('admin -p'); expect(config).toContain('allow night-shift,ops-east'); expect(config).toContain('allow lab-unlimited,burst-user'); }); diff --git a/server/lib/config.ts b/server/lib/config.ts index 318b676..4402656 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -198,10 +198,6 @@ function renderUserCredential(user: ProxyUserRecord): string { } function renderServiceCommand(service: ProxyServiceRecord): string { - if (service.command === 'admin') { - return `admin -p${service.port} -s`; - } - if (service.command === 'socks') { return `socks -p${service.port} -u2`; } diff --git a/server/lib/store.ts b/server/lib/store.ts index 4f2451d..e60a32f 100644 --- a/server/lib/store.ts +++ b/server/lib/store.ts @@ -11,7 +11,14 @@ export class StateStore { try { const raw = await fs.readFile(this.statePath, 'utf8'); - return JSON.parse(raw) as ControlPlaneState; + const state = JSON.parse(raw) as ControlPlaneState; + const migrated = migrateLegacyAdminServices(state); + + if (migrated !== state) { + await this.write(migrated); + } + + return migrated; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error; @@ -28,3 +35,28 @@ export class StateStore { await fs.writeFile(this.statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); } } + +function migrateLegacyAdminServices(state: ControlPlaneState): ControlPlaneState { + const legacyServiceIds = new Set( + state.system.services + .filter((service) => (service as { command?: unknown }).command === 'admin') + .map((service) => service.id), + ); + + if (legacyServiceIds.size === 0) { + return state; + } + + return { + ...state, + service: { + ...state.service, + lastEvent: 'Legacy admin service removed from stored panel state', + }, + userRecords: state.userRecords.filter((user) => !legacyServiceIds.has(user.serviceId)), + system: { + ...state.system, + services: state.system.services.filter((service) => !legacyServiceIds.has(service.id)), + }, + }; +} diff --git a/src/App.test.tsx b/src/App.test.tsx index bd396eb..9101bb5 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -10,7 +10,9 @@ async function loginIntoPanel(user: ReturnType) { } beforeEach(() => { + document.documentElement.dataset.theme = ''; window.sessionStorage.clear(); + window.localStorage.clear(); }); describe('App login gate', () => { @@ -50,6 +52,39 @@ describe('App login gate', () => { expect(screen.queryByRole('button', { name: /open panel/i })).not.toBeInTheDocument(); }); + it('stores panel language in localStorage and restores it after a remount', async () => { + const user = userEvent.setup(); + const firstRender = render(); + + await loginIntoPanel(user); + await user.click(screen.getByRole('button', { name: /settings/i })); + await user.selectOptions(screen.getByLabelText(/panel language/i), 'ru'); + + expect(screen.getByRole('button', { name: /панель/i })).toBeInTheDocument(); + + firstRender.unmount(); + render(); + + expect(screen.getByRole('button', { name: /панель/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /настройки/i })).toBeInTheDocument(); + }); + + it('stores panel theme in localStorage and restores it after a remount', async () => { + const user = userEvent.setup(); + const firstRender = render(); + + await loginIntoPanel(user); + await user.click(screen.getByRole('button', { name: /settings/i })); + await user.selectOptions(screen.getByLabelText(/panel style/i), 'dark'); + + expect(document.documentElement.dataset.theme).toBe('dark'); + + firstRender.unmount(); + render(); + + expect(document.documentElement.dataset.theme).toBe('dark'); + }); + it('opens add-user flow in a modal and closes it on escape', async () => { const user = userEvent.setup(); render(); @@ -107,26 +142,49 @@ describe('App login gate', () => { expect(screen.queryByRole('dialog', { name: /delete user/i })).not.toBeInTheDocument(); }); - it('saves system settings from the system tab and applies them to the local fallback state', async () => { + it('uses a combined service type field in settings and applies saved ports to the local fallback state', async () => { const user = userEvent.setup(); render(); await loginIntoPanel(user); - await user.click(screen.getByRole('button', { name: /system/i })); + await user.click(screen.getByRole('button', { name: /settings/i })); - await user.clear(screen.getByLabelText(/public host/i)); - await user.type(screen.getByLabelText(/public host/i), 'ops-gateway.example.net'); + expect(screen.queryByText(/panel settings/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/public host/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/command/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/protocol/i)).not.toBeInTheDocument(); + expect(screen.getAllByLabelText(/type/i)).toHaveLength(3); + expect(screen.queryByRole('option', { name: /admin/i })).not.toBeInTheDocument(); const firstPortInput = screen.getAllByLabelText(/port/i)[0]; await user.clear(firstPortInput); await user.type(firstPortInput, '1180'); - await user.click(screen.getByRole('button', { name: /save system/i })); - - expect(screen.getByText(/ops-gateway\.example\.net/i)).toBeInTheDocument(); - + await user.click(screen.getByRole('button', { name: /save settings/i })); await user.click(screen.getByRole('button', { name: /users/i })); - expect(screen.getAllByText(/ops-gateway\.example\.net:1180/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/edge\.example\.net:1180/i).length).toBeGreaterThan(0); + }); + + it('warns before deleting a service and removes linked users after confirmation', async () => { + const user = userEvent.setup(); + render(); + + await loginIntoPanel(user); + await user.click(screen.getByRole('button', { name: /settings/i })); + + await user.click(screen.getAllByRole('button', { name: /^remove$/i })[0]); + + const dialog = screen.getByRole('dialog', { name: /delete service/i }); + expect(dialog).toBeInTheDocument(); + expect(within(dialog).getByText(/linked users to be removed/i)).toBeInTheDocument(); + expect(within(dialog).getByText(/night-shift, ops-east/i)).toBeInTheDocument(); + + await user.click(within(dialog).getByRole('button', { name: /^remove$/i })); + await user.click(screen.getByRole('button', { name: /save settings/i })); + await user.click(screen.getByRole('button', { name: /users/i })); + + expect(screen.queryByText(/night-shift/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/ops-east/i)).not.toBeInTheDocument(); }); }); diff --git a/src/App.tsx b/src/App.tsx index 64deec2..b1e9200 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,11 +4,18 @@ import { fallbackDashboardSnapshot, panelAuth } from './data/mockDashboard'; import { buildProxyLink, formatBytes, - formatQuotaState, formatTrafficShare, getServiceTone, isQuotaExceeded, } from './lib/3proxy'; +import { + applyPanelTheme, + loadPanelPreferences, + observeSystemTheme, + savePanelPreferences, + type PanelPreferences, +} from './lib/panelPreferences'; +import { getPanelText } from './lib/panelText'; import type { CreateUserInput, DashboardSnapshot, @@ -22,10 +29,10 @@ import { quotaMbToBytes, validateCreateUserInput } from './shared/validation'; type TabId = 'dashboard' | 'users' | 'system'; -const tabs: Array<{ id: TabId; label: string }> = [ - { id: 'dashboard', label: 'Dashboard' }, - { id: 'users', label: 'Users' }, - { id: 'system', label: 'System' }, +const tabs: Array<{ id: TabId; textKey: 'dashboard' | 'users' | 'settings' }> = [ + { id: 'dashboard', textKey: 'dashboard' }, + { id: 'users', textKey: 'users' }, + { id: 'system', textKey: 'settings' }, ]; const SESSION_KEY = '3proxy-ui-panel-session'; @@ -36,7 +43,14 @@ interface StoredSession { expiresAt: string; } -function LoginGate({ onUnlock }: { onUnlock: (login: string, password: string) => Promise }) { +function LoginGate({ + onUnlock, + preferences, +}: { + onUnlock: (login: string, password: string) => Promise; + preferences: PanelPreferences; +}) { + const text = getPanelText(preferences.language); const [login, setLogin] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); @@ -60,12 +74,12 @@ function LoginGate({ onUnlock }: { onUnlock: (login: string, password: string) =
-

3proxy UI

-

Sign in to the control panel.

+

{text.auth.title}

+

{text.auth.subtitle}

{error ?

{error}

: null}
@@ -100,12 +114,15 @@ function AddUserModal({ services, onClose, onCreate, + preferences, }: { host: string; services: ProxyServiceRecord[]; onClose: () => void; onCreate: (input: CreateUserInput) => Promise; + preferences: PanelPreferences; }) { + const text = getPanelText(preferences.language); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [serviceId, setServiceId] = useState(services[0]?.id ?? ''); @@ -156,22 +173,31 @@ function AddUserModal({ onClick={stopPropagation} >
-

Add user

+

{text.users.addUser}

- Endpoint - {selectedService ? `${host}:${selectedService.port}` : 'Unavailable'} + {text.common.endpoint} + {selectedService ? `${host}:${selectedService.port}` : text.common.unavailable}
- Protocol - {selectedService ? selectedService.protocol : 'Unavailable'} + {text.common.protocol} + {selectedService ? selectedService.protocol : text.common.unavailable}
{error ?

{error}

: null}
- +
@@ -209,11 +239,15 @@ function ConfirmDeleteModal({ username, onClose, onConfirm, + preferences, }: { username: string; onClose: () => void; onConfirm: () => Promise; + preferences: PanelPreferences; }) { + const text = getPanelText(preferences.language); + useEffect(() => { const handleKeyDown = (event: globalThis.KeyboardEvent) => { if (event.key === 'Escape') { @@ -240,18 +274,18 @@ function ConfirmDeleteModal({ onClick={stopPropagation} >
-

Delete user

+

{text.users.deleteTitle}

- Remove profile {username}? This action will delete the user entry from the - current panel state. + {text.users.deletePrompt} {username} + {text.users.deletePromptSuffix}

@@ -262,62 +296,65 @@ function ConfirmDeleteModal({ function DashboardTab({ snapshot, onRuntimeAction, + preferences, }: { snapshot: DashboardSnapshot; onRuntimeAction: (action: 'start' | 'restart') => Promise; + preferences: PanelPreferences; }) { + const text = getPanelText(preferences.language); const serviceTone = getServiceTone(snapshot.service.status); return (
-

Service

- {snapshot.service.status} +

{text.dashboard.service}

+ {text.status[snapshot.service.status]}
-
Process
+
{text.common.process}
{snapshot.service.pidLabel}
-
Version
+
{text.common.version}
{snapshot.service.versionLabel}
-
Uptime
+
{text.common.uptime}
{snapshot.service.uptimeLabel}
-
Last event
+
{text.common.lastEvent}
{snapshot.service.lastEvent}
-

Traffic

+

{text.dashboard.traffic}

- Total + {text.common.total} {formatBytes(snapshot.traffic.totalBytes)}
- Connections + {text.common.connections} {snapshot.traffic.liveConnections}
- Active users + {text.common.activeUsers} {snapshot.traffic.activeUsers}
@@ -325,7 +362,7 @@ function DashboardTab({
-

Daily usage

+

{text.dashboard.dailyUsage}

{snapshot.traffic.daily.map((bucket) => ( @@ -342,7 +379,7 @@ function DashboardTab({
-

Attention

+

{text.dashboard.attention}

{snapshot.attention.map((item) => ( @@ -365,12 +402,15 @@ function UsersTab({ onCreateUser, onTogglePause, onDeleteUser, + preferences, }: { snapshot: DashboardSnapshot; onCreateUser: (input: CreateUserInput) => Promise; onTogglePause: (userId: string) => Promise; onDeleteUser: (userId: string) => Promise; + preferences: PanelPreferences; }) { + const text = getPanelText(preferences.language); const [copiedId, setCopiedId] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [deleteTargetId, setDeleteTargetId] = useState(null); @@ -395,35 +435,37 @@ function UsersTab({
-

Users

-

{snapshot.userRecords.length} accounts in current profile

+

{text.users.title}

+

+ {snapshot.userRecords.length} {text.users.accountsInProfile} +

- {snapshot.users.live} live - {snapshot.users.nearQuota} near quota - {snapshot.users.exceeded} exceeded + {snapshot.users.live} {text.users.live} + {snapshot.users.nearQuota} {text.users.nearQuota} + {snapshot.users.exceeded} {text.users.exceeded}
{assignableServices.length === 0 ? ( -

Enable an assignable service in System before creating new users.

+

{text.users.enableServiceHint}

) : null}
- - - - - - - - + + + + + + + + @@ -431,7 +473,7 @@ function UsersTab({ const service = servicesById.get(user.serviceId); const endpoint = service ? `${snapshot.system.publicHost}:${service.port}` - : 'service missing'; + : text.common.serviceMissing; const proxyLink = service ? buildProxyLink( user.username, @@ -453,17 +495,17 @@ function UsersTab({ - + @@ -508,6 +550,7 @@ function UsersTab({ services={assignableServices} onClose={() => setIsModalOpen(false)} onCreate={onCreateUser} + preferences={preferences} /> ) : null} @@ -519,6 +562,7 @@ function UsersTab({ await onDeleteUser(deleteTarget.id); setDeleteTargetId(null); }} + preferences={preferences} /> ) : null} @@ -526,9 +570,24 @@ function UsersTab({ } export default function App() { + const [preferences, setPreferences] = useState(() => loadPanelPreferences()); const [session, setSession] = useState(() => loadStoredSession()); const [activeTab, setActiveTab] = useState('dashboard'); const [snapshot, setSnapshot] = useState(fallbackDashboardSnapshot); + const text = getPanelText(preferences.language); + + useEffect(() => { + applyPanelTheme(preferences.theme); + savePanelPreferences(preferences); + }, [preferences]); + + useEffect(() => { + if (preferences.theme !== 'system') { + return; + } + + return observeSystemTheme(() => applyPanelTheme('system')); + }, [preferences.theme]); const resetSession = () => { clearStoredSession(); @@ -605,7 +664,7 @@ export default function App() { }, [session]); if (!session) { - return ; + return ; } const mutateSnapshot = async ( @@ -760,6 +819,7 @@ export default function App() { } if (!payload) { + const nextServiceIds = new Set(input.services.map((service) => service.id)); setSnapshot((current) => withDerivedSnapshot({ ...current, @@ -767,6 +827,7 @@ export default function App() { ...current.service, lastEvent: 'System configuration updated from panel', }, + userRecords: current.userRecords.filter((user) => nextServiceIds.has(user.serviceId)), system: { ...input, previewConfig: current.system.previewConfig, @@ -788,23 +849,19 @@ export default function App() {
- Status - {snapshot.service.status} + {text.common.status} + {text.status[snapshot.service.status]}
- Version + {text.common.version} {snapshot.service.versionLabel}
- Users + {text.common.users} {snapshot.users.total}
-
@@ -817,21 +874,31 @@ export default function App() { className={activeTab === tab.id ? 'tab-button active' : 'tab-button'} onClick={() => setActiveTab(tab.id)} > - {tab.label} + {text.tabs[tab.textKey]} ))} - {activeTab === 'dashboard' ? : null} + {activeTab === 'dashboard' ? ( + + ) : null} {activeTab === 'users' ? ( + ) : null} + {activeTab === 'system' ? ( + ) : null} - {activeTab === 'system' ? : null} ); } @@ -864,6 +931,26 @@ function withDerivedSnapshot(snapshot: DashboardSnapshot): DashboardSnapshot { }; } +function formatQuotaStateLabel( + usedBytes: number, + quotaBytes: number | null, + language: PanelPreferences['language'], +): string { + const text = getPanelText(language); + + if (quotaBytes === null) { + return text.common.unlimited; + } + + const remaining = quotaBytes - usedBytes; + + if (remaining <= 0) { + return text.common.exceeded; + } + + return `${formatBytes(remaining)} ${text.common.left}`; +} + async function requestSnapshot(request: () => Promise): Promise { try { const response = await request(); diff --git a/src/SystemTab.tsx b/src/SystemTab.tsx index e060cb5..9823934 100644 --- a/src/SystemTab.tsx +++ b/src/SystemTab.tsx @@ -1,22 +1,48 @@ -import { FormEvent, useEffect, useState } from 'react'; +import { FormEvent, useEffect, useMemo, useState } from 'react'; import type { DashboardSnapshot, ProxyServiceRecord, SystemSettings, UpdateSystemInput } from './shared/contracts'; +import type { PanelLanguage, PanelPreferences, PanelTheme } from './lib/panelPreferences'; +import { getPanelText, getThemeLabel } from './lib/panelText'; import { getProtocolForCommand, validateSystemInput } from './shared/validation'; interface SystemTabProps { snapshot: DashboardSnapshot; + preferences: PanelPreferences; + onPreferencesChange: (next: PanelPreferences) => void; onSaveSystem: (input: UpdateSystemInput) => Promise; } -export default function SystemTab({ snapshot, onSaveSystem }: SystemTabProps) { +export default function SystemTab({ + snapshot, + preferences, + onPreferencesChange, + onSaveSystem, +}: SystemTabProps) { const [draft, setDraft] = useState(() => cloneSystemSettings(snapshot.system)); const [error, setError] = useState(''); const [isSaving, setIsSaving] = useState(false); + const [removeServiceId, setRemoveServiceId] = useState(null); + const text = getPanelText(preferences.language); useEffect(() => { setDraft(cloneSystemSettings(snapshot.system)); setError(''); }, [snapshot.system]); + const linkedUsersByService = useMemo(() => { + const result = new Map(); + + snapshot.userRecords.forEach((user) => { + const usernames = result.get(user.serviceId) ?? []; + usernames.push(user.username); + result.set(user.serviceId, usernames); + }); + + return result; + }, [snapshot.userRecords]); + + const removeTarget = draft.services.find((service) => service.id === removeServiceId) ?? null; + const removeTargetUsers = removeTarget ? linkedUsersByService.get(removeTarget.id) ?? [] : []; + const updateService = (serviceId: string, updater: (service: ProxyServiceRecord) => ProxyServiceRecord) => { setDraft((current) => ({ ...current, @@ -30,7 +56,9 @@ export default function SystemTab({ snapshot, onSaveSystem }: SystemTabProps) { setError(''); try { - const validated = validateSystemInput(draft, snapshot.userRecords); + const nextServiceIds = new Set(draft.services.map((service) => service.id)); + const remainingUsers = snapshot.userRecords.filter((user) => nextServiceIds.has(user.serviceId)); + const validated = validateSystemInput(draft, remainingUsers); await onSaveSystem(validated); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : 'Unable to save system settings.'); @@ -40,204 +68,232 @@ export default function SystemTab({ snapshot, onSaveSystem }: SystemTabProps) { }; return ( -
-
-
-
-

Panel settings

-
-
- - - - -
-

- These values describe how the panel generates and reloads the 3proxy config. Saving keeps - existing users attached only to enabled assignable services. -

-
- -
-
-

Services

- -
-
- {draft.services.map((service, index) => ( -
-
-
- Service {index + 1} -

{service.id}

-
- +
+
+
+ {draft.services.map((service, index) => ( +
+
+
+ + {text.settings.serviceLabel} {index + 1} + +

{service.id}

+
+
-
- - -
-
- ))} -
- {error ?

{error}

: null} -
- - -
-
+ {text.common.remove} + + +
+ + + + +
+
+ + +
+
+ ))} + + {error ?

{error}

: null} +
+ + +
+ -
-
-

Generated config

-
-
{snapshot.system.previewConfig}
-
- - +
+
+

{text.settings.generatedConfig}

+
+
{snapshot.system.previewConfig}
+
+ + + + {removeTarget ? ( +
setRemoveServiceId(null)}> +
event.stopPropagation()} + > +
+

{text.settings.serviceRemoveTitle}

+
+

+ {removeTarget.name}{' '} + {removeTargetUsers.length > 0 ? text.settings.removeWarningUsers : text.settings.removeWarningNone} +

+ {removeTargetUsers.length > 0 ? ( +

+ {text.settings.removeWarningCount} {removeTargetUsers.join(', ')} +

+ ) : null} +
+ + +
+
+
+ ) : null} + ); } diff --git a/src/app.css b/src/app.css index 936b8b6..a9b17df 100644 --- a/src/app.css +++ b/src/app.css @@ -22,6 +22,23 @@ --shadow: 0 1px 2px rgba(17, 24, 39, 0.04); } +:root[data-theme='dark'] { + color-scheme: dark; + --page-bg: #111827; + --surface: #18212f; + --surface-muted: #1f2937; + --border: #334155; + --border-strong: #475569; + --text: #f8fafc; + --muted: #94a3b8; + --accent: #60a5fa; + --accent-muted: rgba(96, 165, 250, 0.15); + --success: #4ade80; + --warning: #fbbf24; + --danger: #f87171; + --shadow: 0 1px 2px rgba(2, 6, 23, 0.35); +} + * { box-sizing: border-box; } @@ -374,13 +391,13 @@ button, .usage-bar { height: 8px; border-radius: 999px; - background: #eceff3; + background: color-mix(in srgb, var(--border) 70%, transparent); overflow: hidden; } .usage-bar div { height: 100%; - background: #94a3b8; + background: color-mix(in srgb, var(--accent) 55%, var(--muted)); } .event-row, @@ -429,12 +446,28 @@ button, } .table-note, -.system-hint, .service-editor-header p { margin: 0; color: var(--muted); } +.settings-toolbar { + display: flex; + align-items: end; + gap: 12px; + flex-wrap: wrap; +} + +.compact-field { + min-width: 220px; + flex: 0 1 240px; +} + +.compact-field span { + color: var(--muted); + font-size: 12px; +} + .toolbar-actions { flex-wrap: wrap; } @@ -548,10 +581,6 @@ tbody tr:last-child td { display: block; } -.system-settings-card { - gap: 12px; -} - .system-fields, .service-editor-grid { display: grid; @@ -610,8 +639,8 @@ pre { overflow: auto; border: 1px solid var(--border); border-radius: 10px; - background: #fbfbfc; - color: #1f2937; + background: color-mix(in srgb, var(--surface-muted) 80%, var(--surface)); + color: var(--text); font: 13px/1.55 Consolas, "Courier New", monospace; } @@ -679,7 +708,8 @@ pre { } .shell-header, - .table-toolbar { + .table-toolbar, + .settings-toolbar { flex-direction: column; align-items: flex-start; } diff --git a/src/data/mockDashboard.ts b/src/data/mockDashboard.ts index 9cad935..14f08b7 100644 --- a/src/data/mockDashboard.ts +++ b/src/data/mockDashboard.ts @@ -88,16 +88,6 @@ export const dashboardSnapshot: ControlPlaneState = { assignable: true, }, { - id: 'admin', - name: 'Admin', - command: 'admin', - protocol: 'http', - description: 'Restricted admin visibility endpoint.', - port: 8081, - enabled: true, - assignable: false, - }, - { id: 'proxy', name: 'HTTP proxy', command: 'proxy', diff --git a/src/lib/panelPreferences.ts b/src/lib/panelPreferences.ts new file mode 100644 index 0000000..35f530a --- /dev/null +++ b/src/lib/panelPreferences.ts @@ -0,0 +1,74 @@ +export type PanelLanguage = 'en' | 'ru'; +export type PanelTheme = 'light' | 'dark' | 'system'; + +export interface PanelPreferences { + language: PanelLanguage; + theme: PanelTheme; +} + +const PREFERENCES_KEY = '3proxy-ui-panel-preferences-v2'; + +export const defaultPanelPreferences: PanelPreferences = { + language: 'en', + theme: 'light', +}; + +export function loadPanelPreferences(): PanelPreferences { + try { + const raw = window.localStorage.getItem(PREFERENCES_KEY); + + if (!raw) { + return defaultPanelPreferences; + } + + const parsed = JSON.parse(raw) as Partial; + + return { + language: parsed.language === 'ru' ? 'ru' : 'en', + theme: isPanelTheme(parsed.theme) ? parsed.theme : defaultPanelPreferences.theme, + }; + } catch { + return defaultPanelPreferences; + } +} + +export function savePanelPreferences(preferences: PanelPreferences): void { + window.localStorage.setItem(PREFERENCES_KEY, JSON.stringify(preferences)); +} + +export function applyPanelTheme(theme: PanelTheme): void { + document.documentElement.dataset.theme = resolvePanelTheme(theme); +} + +export function observeSystemTheme(onChange: () => void): () => void { + if (typeof window.matchMedia !== 'function') { + return () => undefined; + } + + const media = window.matchMedia('(prefers-color-scheme: dark)'); + const listener = () => onChange(); + + if (typeof media.addEventListener === 'function') { + media.addEventListener('change', listener); + return () => media.removeEventListener('change', listener); + } + + media.addListener(listener); + return () => media.removeListener(listener); +} + +function resolvePanelTheme(theme: PanelTheme): 'light' | 'dark' { + if (theme === 'light' || theme === 'dark') { + return theme; + } + + if (typeof window.matchMedia === 'function') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + + return 'light'; +} + +function isPanelTheme(value: unknown): value is PanelTheme { + return value === 'light' || value === 'dark' || value === 'system'; +} diff --git a/src/lib/panelText.ts b/src/lib/panelText.ts new file mode 100644 index 0000000..0d22d8c --- /dev/null +++ b/src/lib/panelText.ts @@ -0,0 +1,243 @@ +import type { PanelLanguage, PanelTheme } from './panelPreferences'; + +const text = { + en: { + tabs: { + dashboard: 'Dashboard', + users: 'Users', + settings: 'Settings', + }, + auth: { + title: '3proxy UI', + subtitle: 'Sign in to the control panel.', + login: 'Login', + password: 'Password', + open: 'Open panel', + opening: 'Opening...', + }, + common: { + close: 'Close', + cancel: 'Cancel', + reset: 'Reset', + save: 'Save', + saveSettings: 'Save settings', + signOut: 'Sign out', + addService: 'Add service', + remove: 'Remove', + delete: 'Delete', + copy: 'Copy', + copied: 'Copied', + createUser: 'Create user', + endpoint: 'Endpoint', + protocol: 'Protocol', + status: 'Status', + version: 'Version', + users: 'Users', + enabled: 'Enabled', + assignable: 'Assignable to users', + unavailable: 'Unavailable', + unknownService: 'Unknown service', + serviceMissing: 'service missing', + optional: 'Optional', + activeUsers: 'Active users', + total: 'Total', + connections: 'Connections', + process: 'Process', + uptime: 'Uptime', + lastEvent: 'Last event', + left: 'left', + unlimited: 'Unlimited', + exceeded: 'Exceeded', + language: 'Panel language', + theme: 'Panel style', + english: 'English', + russian: 'Russian', + light: 'Light', + dark: 'Dark', + system: 'System', + }, + dashboard: { + service: 'Service', + traffic: 'Traffic', + dailyUsage: 'Daily usage', + attention: 'Attention', + start: 'Start', + restart: 'Restart', + }, + users: { + title: 'Users', + accountsInProfile: 'accounts in current profile', + newUser: 'New user', + live: 'live', + nearQuota: 'near quota', + exceeded: 'exceeded', + enableServiceHint: 'Enable an assignable service in Settings before creating new users.', + user: 'User', + endpoint: 'Endpoint', + used: 'Used', + remaining: 'Remaining', + share: 'Share', + proxy: 'Proxy', + actions: 'Actions', + addUser: 'Add user', + username: 'Username', + password: 'Password', + service: 'Service', + quotaMb: 'Quota (MB)', + pause: 'Pause', + resume: 'Resume', + deleteTitle: 'Delete user', + deletePrompt: 'Remove profile', + deletePromptSuffix: '? This action will delete the user entry from the current panel state.', + deleteAction: 'Delete user', + }, + settings: { + title: 'Services', + generatedConfig: 'Generated config', + serviceLabel: 'Service', + serviceType: 'Type', + typeSocks: 'SOCKS5 proxy', + typeProxy: 'HTTP proxy', + name: 'Name', + port: 'Port', + description: 'Description', + serviceRemoveTitle: 'Delete service', + removeWarningNone: 'Delete this service from the panel configuration?', + removeWarningUsers: 'Delete this service and remove all linked users?', + removeWarningCount: 'Linked users to be removed:', + }, + status: { + live: 'live', + warn: 'warn', + fail: 'fail', + idle: 'idle', + paused: 'paused', + enabled: 'enabled', + disabled: 'disabled', + }, + }, + ru: { + tabs: { + dashboard: 'Панель', + users: 'Пользователи', + settings: 'Настройки', + }, + auth: { + title: '3proxy UI', + subtitle: 'Войдите в панель управления.', + login: 'Логин', + password: 'Пароль', + open: 'Открыть панель', + opening: 'Открываем...', + }, + common: { + close: 'Закрыть', + cancel: 'Отмена', + reset: 'Сбросить', + save: 'Сохранить', + saveSettings: 'Сохранить настройки', + signOut: 'Выйти', + addService: 'Добавить сервис', + remove: 'Удалить', + delete: 'Удалить', + copy: 'Копировать', + copied: 'Скопировано', + createUser: 'Создать пользователя', + endpoint: 'Точка входа', + protocol: 'Протокол', + status: 'Статус', + version: 'Версия', + users: 'Пользователи', + enabled: 'Включен', + assignable: 'Можно назначать пользователям', + unavailable: 'Недоступно', + unknownService: 'Неизвестный сервис', + serviceMissing: 'сервис отсутствует', + optional: 'Необязательно', + activeUsers: 'Активные пользователи', + total: 'Всего', + connections: 'Соединения', + process: 'Процесс', + uptime: 'Время работы', + lastEvent: 'Последнее событие', + left: 'осталось', + unlimited: 'Без лимита', + exceeded: 'Превышено', + language: 'Язык панели', + theme: 'Стиль панели', + english: 'Английский', + russian: 'Русский', + light: 'Светлый', + dark: 'Темный', + system: 'Системный', + }, + dashboard: { + service: 'Сервис', + traffic: 'Трафик', + dailyUsage: 'Дневное использование', + attention: 'Внимание', + start: 'Запустить', + restart: 'Перезапустить', + }, + users: { + title: 'Пользователи', + accountsInProfile: 'аккаунтов в текущем профиле', + newUser: 'Новый пользователь', + live: 'в работе', + nearQuota: 'близко к лимиту', + exceeded: 'превышено', + enableServiceHint: 'Сначала включите назначаемый сервис во вкладке Настройки.', + user: 'Пользователь', + endpoint: 'Точка входа', + used: 'Использовано', + remaining: 'Остаток', + share: 'Доля', + proxy: 'Прокси', + actions: 'Действия', + addUser: 'Добавить пользователя', + username: 'Имя пользователя', + password: 'Пароль', + service: 'Сервис', + quotaMb: 'Лимит (МБ)', + pause: 'Пауза', + resume: 'Возобновить', + deleteTitle: 'Удаление пользователя', + deletePrompt: 'Удалить профиль', + deletePromptSuffix: '? Это действие удалит пользователя из текущего состояния панели.', + deleteAction: 'Удалить пользователя', + }, + settings: { + title: 'Сервисы', + generatedConfig: 'Сгенерированный конфиг', + serviceLabel: 'Сервис', + serviceType: 'Тип', + typeSocks: 'SOCKS5 прокси', + typeProxy: 'HTTP прокси', + name: 'Название', + port: 'Порт', + description: 'Описание', + serviceRemoveTitle: 'Удаление сервиса', + removeWarningNone: 'Удалить этот сервис из конфигурации панели?', + removeWarningUsers: 'Удалить этот сервис и всех связанных с ним пользователей?', + removeWarningCount: 'Будут удалены пользователи:', + }, + status: { + live: 'в работе', + warn: 'предупреждение', + fail: 'ошибка', + idle: 'ожидание', + paused: 'пауза', + enabled: 'включен', + disabled: 'выключен', + }, + }, +} as const; + +export function getPanelText(language: PanelLanguage) { + return text[language]; +} + +export function getThemeLabel(language: PanelLanguage, theme: PanelTheme): string { + const t = getPanelText(language); + return theme === 'light' ? t.common.light : theme === 'dark' ? t.common.dark : t.common.system; +} diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index f90c22a..b76d025 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -1,7 +1,7 @@ import type { ServiceState } from '../lib/3proxy'; export type ServiceProtocol = 'socks5' | 'http'; -export type ServiceCommand = 'socks' | 'proxy' | 'admin'; +export type ServiceCommand = 'socks' | 'proxy'; export interface DailyTrafficBucket { day: string; diff --git a/src/shared/validation.ts b/src/shared/validation.ts index 4558ab9..faf5baf 100644 --- a/src/shared/validation.ts +++ b/src/shared/validation.ts @@ -11,7 +11,7 @@ const TOKEN_PATTERN = /^[A-Za-z0-9._~!@#$%^&*+=:/-]+$/; const SERVICE_ID_PATTERN = /^[A-Za-z0-9._-]+$/; const HOST_PATTERN = /^[A-Za-z0-9.-]+$/; -const COMMANDS: ServiceCommand[] = ['socks', 'proxy', 'admin']; +const COMMANDS: ServiceCommand[] = ['socks', 'proxy']; const MB = 1024 * 1024; @@ -113,10 +113,6 @@ export function validateSystemInput( } seenPorts.set(port, label); - if (command === 'admin' && assignable) { - throw new Error('Admin services cannot be assignable.'); - } - return { id, name, @@ -125,7 +121,7 @@ export function validateSystemInput( protocol: getProtocolForCommand(command), port, enabled, - assignable: command === 'admin' ? false : assignable, + assignable, }; });
UserEndpointStatusUsedRemainingShareProxyActions{text.users.user}{text.users.endpoint}{text.common.status}{text.users.used}{text.users.remaining}{text.users.share}{text.users.proxy}{text.users.actions}
- {service?.name ?? 'Unknown service'} + {service?.name ?? text.common.unknownService} {endpoint}
- {displayStatus} + {text.status[displayStatus]} {formatBytes(user.usedBytes)}{formatQuotaState(user.usedBytes, user.quotaBytes)}{formatQuotaStateLabel(user.usedBytes, user.quotaBytes, preferences.language)} {formatTrafficShare(user.usedBytes, snapshot.traffic.totalBytes)} @@ -482,14 +524,14 @@ function UsersTab({ className="button-secondary button-small" onClick={() => onTogglePause(user.id)} > - {user.paused ? 'Resume' : 'Pause'} + {user.paused ? text.users.resume : text.users.pause}