From 15e32799d4fa464977b2b3a87f1fda61200b4bdc Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Fri, 21 Aug 2026 01:22:53 +0200 Subject: [PATCH] ADD - add SQL phone configurator --- README.md | 19 + .../src/components/AdminConfigValueEditor.vue | 528 +++++++++++++ .../components/AdminPanel.contract.test.ts | 70 +- frontend/src/components/AdminPanel.vue | 734 +++++++++++++++++- frontend/src/stores/admin.ts | 40 + frontend/src/stores/phone.ts | 53 +- frontend/src/types/admin.ts | 31 + frontend/testserver/index.cjs | 218 ++++++ sky_phone/config/config.lua | 8 +- sky_phone/config/locales/de.lua | 13 +- sky_phone/config/locales/en.lua | 13 +- sky_phone/config/media.lua | 5 +- sky_phone/fxmanifest.lua | 5 +- sky_phone/source/client/nui_server_bridge.lua | 2 +- .../source/client/phone_configurator.lua | 53 ++ sky_phone/source/server/admin.lua | 46 ++ sky_phone/source/server/db_migrate.lua | 2 + .../source/server/phone_configurator.lua | 573 ++++++++++++++ .../server/phone_configurator_schema.lua | 23 + sky_phone/sql/install.sql | 12 + 20 files changed, 2423 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/AdminConfigValueEditor.vue create mode 100644 sky_phone/source/client/phone_configurator.lua create mode 100644 sky_phone/source/server/phone_configurator.lua create mode 100644 sky_phone/source/server/phone_configurator_schema.lua diff --git a/README.md b/README.md index 2334acd..88eff1f 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,25 @@ The files contain clearly separated sections for: Restart `sky_phone` after changing Lua configuration. +### In-game phone configurator + +Set the switch at the beginning of `config/config.lua` to use SQL-backed configuration: + +```lua +Config.PhoneConfigurator = { + Enabled = true, +} +``` + +When enabled, `config.lua` and the server-only `media.lua` are first-run defaults. Sky Phone creates +the `sky_phone_configurator` table automatically, loads its saved values before framework and phone +modules initialize, and exposes the editor through `/phonepanel`. Nothing autosaves: stage changes +in the Phone Configurator tool and press the green check. Restart `sky_phone` after changing +startup-bound settings such as framework, inventory, command, item, or provider configuration. + +Media API keys and server peppers are never returned in plaintext to the NUI. Existing secrets are +shown only as configured and are replaced only when an administrator enters a new value. + ### Language Available locales: diff --git a/frontend/src/components/AdminConfigValueEditor.vue b/frontend/src/components/AdminConfigValueEditor.vue new file mode 100644 index 0000000..a4f4d5d --- /dev/null +++ b/frontend/src/components/AdminConfigValueEditor.vue @@ -0,0 +1,528 @@ + + + + + diff --git a/frontend/src/components/AdminPanel.contract.test.ts b/frontend/src/components/AdminPanel.contract.test.ts index 76ba0fc..ae38404 100644 --- a/frontend/src/components/AdminPanel.contract.test.ts +++ b/frontend/src/components/AdminPanel.contract.test.ts @@ -54,6 +54,28 @@ const schema = readFileSync( new URL('../../../sky_phone/sql/install.sql', import.meta.url), 'utf8', ) +const manifest = readFileSync( + new URL('../../../sky_phone/fxmanifest.lua', import.meta.url), + 'utf8', +) +const configuratorServer = readFileSync( + new URL( + '../../../sky_phone/source/server/phone_configurator.lua', + import.meta.url, + ), + 'utf8', +) +const configuratorClient = readFileSync( + new URL( + '../../../sky_phone/source/client/phone_configurator.lua', + import.meta.url, + ), + 'utf8', +) +const configuratorValueEditor = readFileSync( + new URL('./AdminConfigValueEditor.vue', import.meta.url), + 'utf8', +) describe('standalone admin panel contracts', () => { it('renders as a dedicated full-screen editor outside the phone shell', () => { @@ -91,6 +113,7 @@ describe('standalone admin panel contracts', () => { 'calls', 'moderation', 'audit', + 'configurator', ]) { expect(source).toContain(`selectTab('${tab}')`) expect(source).toContain(`t('tabs.${tab}')`) @@ -127,6 +150,8 @@ describe('standalone admin panel contracts', () => { 'admin:reset-passcode', 'admin:change-number', 'admin:factory-reset', + 'admin:configurator', + 'admin:save-configurator', ]) { expect(store).toContain(endpoint) } @@ -169,7 +194,7 @@ describe('standalone admin panel contracts', () => { }) it('opens directly from the configurable command with dedicated focus', () => { - expect(config).toContain('Command = "phoneadmin"') + expect(config).toContain('Command = "phonepanel"') expect(server).toContain( 'RegisterCommand(Config.AdminPanel.Command, function(command_source)', ) @@ -203,4 +228,47 @@ describe('standalone admin panel contracts', () => { 'CREATE TABLE IF NOT EXISTS `sky_phone_admin_audit`', ) }) + + it('loads the SQL phone configurator before framework-owned configuration is read', () => { + expect(config).toMatch( + /Config\.PhoneConfigurator\s*=\s*\{[\s\S]*?Enabled\s*=\s*false[\s\S]*?Config\.Bridge\s*=/, + ) + expect(schema).toContain( + 'CREATE TABLE IF NOT EXISTS `sky_phone_configurator`', + ) + expect( + manifest.indexOf("'source/server/phone_configurator.lua'"), + ).toBeLessThan(manifest.indexOf("'source/bridge/server/framework.lua'")) + expect( + manifest.indexOf("'source/client/phone_configurator.lua'"), + ).toBeLessThan(manifest.indexOf("'source/bridge/client/framework.lua'")) + expect(configuratorServer).toContain('AND `revision` = ?') + expect(configuratorServer).toContain('configurator_enabled') + expect(configuratorServer).toMatch( + /for key, value in pairs\(Config\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/, + ) + expect(configuratorServer).toContain( + 'default_media = serialize_value(Config.Media)', + ) + expect(configuratorServer).toContain( + 'build_sections("config", stored_config', + ) + expect(configuratorServer).toContain('build_sections("media", stored_media') + expect(configuratorServer).toContain('sensitive_path') + expect(configuratorServer).toContain('restore_redacted_values') + expect(configuratorServer).not.toContain('Config.Media = client_payload') + expect(configuratorClient).toContain( + 'Bridge.Callbacks.Trigger("sky_phone:configurator:runtime"', + ) + expect(source).toContain('class="admin-panel-rail__configurator"') + expect(source).toContain( + '.admin-panel-rail .admin-panel-rail__configurator', + ) + expect(source).toContain('('overview') const playerQuery = ref('') const appQuery = ref('') +const configuratorQuery = ref('') +const selectedConfiguratorSection = ref('') const selectedImei = ref('') const drafts = ref>>({}) +const configuratorDrafts = ref>({}) const saving = ref(false) const revealDialogImei = ref('') const deviceAction = ref(null) @@ -138,12 +152,18 @@ const manageableApps = computed(() => { ) }) -const pendingCount = computed(() => +const appPendingCount = computed(() => Object.values(drafts.value).reduce( (total, changes) => total + Object.keys(changes).length, 0, ), ) +const configuratorPendingCount = computed( + () => Object.keys(configuratorDrafts.value).length, +) +const pendingCount = computed( + () => appPendingCount.value + configuratorPendingCount.value, +) const hasChanges = computed(() => pendingCount.value > 0) const selectedDeviceChanges = computed(() => selectedDevice.value @@ -165,6 +185,37 @@ const selectedCalls = computed(() => ? (admin.deviceActivity[selectedDevice.value.imei]?.calls ?? []) : [], ) +const filteredConfiguratorSections = computed(() => { + const sections = admin.configurator?.sections ?? [] + const needle = configuratorQuery.value.trim().toLocaleLowerCase(phone.lang) + if (!needle) return sections + return sections.filter( + (section) => + section.label.toLocaleLowerCase(phone.lang).includes(needle) || + section.scope.includes(needle) || + section.fields.some( + (field) => + field.label.toLocaleLowerCase(phone.lang).includes(needle) || + field.path.toLocaleLowerCase(phone.lang).includes(needle), + ), + ) +}) +const filteredConfiguratorFieldCount = computed(() => + filteredConfiguratorSections.value.reduce( + (total, section) => total + section.fields.length, + 0, + ), +) +const activeConfiguratorSection = computed(() => { + const sections = filteredConfiguratorSections.value + return ( + sections.find( + (section) => section.id === selectedConfiguratorSection.value, + ) ?? + sections[0] ?? + null + ) +}) watch( () => admin.selectedPlayer, @@ -175,6 +226,19 @@ watch( }, ) +watch( + () => admin.configurator?.sections, + (sections) => { + if ( + !sections?.some( + (section) => section.id === selectedConfiguratorSection.value, + ) + ) { + selectedConfiguratorSection.value = sections?.[0]?.id ?? '' + } + }, +) + watch( [tab, selectedImei, () => admin.selectedPlayer?.source], ([currentTab, imei, source]) => { @@ -195,6 +259,28 @@ function t(key: string, params?: Record): string { return phone.t('AdminPanel.' + key, params) } +const configuratorEditorLabels = computed(() => ({ + addField: t('configurator.table.addField'), + addRow: t('configurator.table.addRow'), + configuredSecret: t('configurator.secretConfigured'), + convertToList: t('configurator.table.convertToList'), + convertToTable: t('configurator.table.convertToTable'), + emptyList: t('configurator.table.emptyList'), + emptyTable: t('configurator.table.emptyTable'), + keyPlaceholder: t('configurator.table.keyPlaceholder'), + list: t('configurator.table.list'), + remove: t('configurator.table.remove'), + table: t('configurator.table.table'), + types: { + boolean: t('configurator.table.types.boolean'), + list: t('configurator.table.types.list'), + number: t('configurator.table.types.number'), + string: t('configurator.table.types.string'), + table: t('configurator.table.types.table'), + }, + vector: t('configurator.table.vector'), +})) + function showToast( message: string, tone: 'error' | 'success' = 'success', @@ -307,6 +393,11 @@ function queueAction(action: PendingAction): void { function selectTab(nextTab: AdminTab): void { tab.value = nextTab + if (nextTab === 'configurator' && !admin.configurator) { + void admin.loadConfigurator().then((loaded) => { + if (!loaded) showToast(errorText(), 'error') + }) + } } function selectAccent(color: (typeof accentOptions)[number]['color']): void { @@ -327,11 +418,96 @@ async function runAction(action: PendingAction): Promise { async function discardAndContinue(): Promise { const action = pendingAction.value drafts.value = {} + configuratorDrafts.value = {} pendingAction.value = null discardDialog.value = false if (action) await runAction(action) } +function configuratorFieldKey(field: AdminConfiguratorField): string { + return `${field.scope}:${field.path}` +} + +function configuratorFieldValue(field: AdminConfiguratorField): unknown { + const key = configuratorFieldKey(field) + if (Object.prototype.hasOwnProperty.call(configuratorDrafts.value, key)) { + return configuratorDrafts.value[key] + } + return field.value +} + +function updateConfiguratorField( + field: AdminConfiguratorField, + value: unknown, +): void { + const key = configuratorFieldKey(field) + const matchesInitial = + field.type === 'json' + ? JSON.stringify(value) === JSON.stringify(field.value) + : field.type === 'number' + ? String(value) === String(field.value) + : value === field.value + if (!field.sensitive && matchesInitial) { + delete configuratorDrafts.value[key] + return + } + configuratorDrafts.value[key] = value +} + +function updateConfiguratorInput( + field: AdminConfiguratorField, + event: Event, +): void { + const target = event.target + if (target instanceof HTMLInputElement) { + updateConfiguratorField(field, target.value) + } +} + +function updateConfiguratorToggle( + field: AdminConfiguratorField, + event: Event, +): void { + const target = event.target + if (target instanceof HTMLInputElement) { + updateConfiguratorField(field, target.checked) + } +} + +function findConfiguratorField(key: string): AdminConfiguratorField | null { + for (const section of admin.configurator?.sections ?? []) { + const field = section.fields.find( + (candidate) => configuratorFieldKey(candidate) === key, + ) + if (field) return field + } + return null +} + +function buildConfiguratorChanges(): AdminConfiguratorChange[] | null { + const changes: AdminConfiguratorChange[] = [] + for (const [key, draft] of Object.entries(configuratorDrafts.value)) { + const field = findConfiguratorField(key) + if (!field) return null + + let value: unknown = draft + if (field.type === 'number') { + value = Number(draft) + if (!Number.isFinite(value)) return null + } else if (field.type === 'json') { + value = draft + if (!value || typeof value !== 'object') return null + } else if (field.type === 'string') { + value = String(draft) + } else if (field.type === 'boolean' && typeof value !== 'boolean') { + return null + } + + changes.push({ path: field.path, scope: field.scope, value }) + } + return changes +} + function cancelDiscard(): void { discardDialog.value = false pendingAction.value = null @@ -339,8 +515,35 @@ function cancelDiscard(): void { async function saveChanges(): Promise { const player = admin.selectedPlayer - if (!player || !hasChanges.value || saving.value) return + if (!hasChanges.value || saving.value) return saving.value = true + + if (configuratorPendingCount.value) { + const changes = buildConfiguratorChanges() + if (!changes) { + saving.value = false + showToast(t('configurator.invalidValue'), 'error') + return + } + const response = await admin.saveConfigurator(changes) + if (!response.success) { + saving.value = false + showToast(errorText(response.error), 'error') + return + } + configuratorDrafts.value = {} + } + + if (!appPendingCount.value) { + saving.value = false + showToast(t('configurator.saved')) + return + } + if (!player) { + saving.value = false + showToast(t('editor.saveFailed'), 'error') + return + } const pendingDevices = Object.entries(drafts.value) for (const [imei, deviceChanges] of pendingDevices) { const device = admin.selectedPlayer?.devices.find( @@ -500,7 +703,9 @@ onBeforeUnmount(() => { {{ - admin.selectedPlayer?.name || t('editor.noSelection') + tab === 'configurator' + ? t('configurator.context') + : admin.selectedPlayer?.name || t('editor.noSelection') }} @@ -615,6 +820,16 @@ onBeforeUnmount(() => { > +