ADD - add SQL phone configurator

This commit is contained in:
Leon.Schmidt
2026-08-21 01:22:53 +02:00
parent d6b03ac2b7
commit 15e32799d4
20 changed files with 2423 additions and 25 deletions
+19
View File
@@ -211,6 +211,25 @@ The files contain clearly separated sections for:
Restart `sky_phone` after changing Lua configuration. 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 ### Language
Available locales: Available locales:
@@ -0,0 +1,528 @@
<script setup lang="ts">
import { Plus, Rows3, TableProperties, Trash2 } from 'lucide-vue-next'
import { computed, ref } from 'vue'
export type AdminConfigEditorLabels = {
addField: string
addRow: string
configuredSecret: string
convertToList: string
convertToTable: string
emptyList: string
emptyTable: string
keyPlaceholder: string
list: string
remove: string
table: string
types: {
boolean: string
list: string
number: string
string: string
table: string
}
vector: string
}
defineOptions({ name: 'AdminConfigValueEditor' })
const props = withDefaults(
defineProps<{
ariaLabel?: string
depth?: number
disabled?: boolean
labels: AdminConfigEditorLabels
modelValue: unknown
}>(),
{ ariaLabel: '', depth: 0, disabled: false },
)
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
type ValueKind = 'boolean' | 'list' | 'number' | 'string' | 'table'
const newArrayKind = ref<ValueKind>('string')
const newObjectKey = ref('')
const newObjectKind = ref<ValueKind>('string')
const isList = computed(() => Array.isArray(props.modelValue))
const listValue = computed<unknown[]>(() =>
Array.isArray(props.modelValue) ? props.modelValue : [],
)
const isTable = computed(
() =>
props.modelValue !== null &&
typeof props.modelValue === 'object' &&
!Array.isArray(props.modelValue),
)
const tableValue = computed<Record<string, unknown>>(() =>
isTable.value ? (props.modelValue as Record<string, unknown>) : {},
)
const vectorType = computed(() => {
const value = tableValue.value.__skyType
return typeof value === 'string' && /^vector[234]$/.test(value) ? value : ''
})
const tableEntries = computed(() =>
Object.entries(tableValue.value).filter(([key]) => key !== '__skyType'),
)
const isMaskedSecret = computed(() => props.modelValue === '***REDACTED***')
function blankValue(kind: ValueKind): unknown {
if (kind === 'boolean') return false
if (kind === 'number') return 0
if (kind === 'list') return []
if (kind === 'table') return {}
return ''
}
function blankLike(value: unknown): unknown {
if (Array.isArray(value)) return []
if (value !== null && typeof value === 'object') {
const blank: Record<string, unknown> = {}
for (const [key, child] of Object.entries(value)) {
blank[key] = key === '__skyType' ? child : blankLike(child)
}
return blank
}
if (typeof value === 'boolean') return false
if (typeof value === 'number') return 0
return ''
}
function updateScalar(event: Event): void {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
if (typeof props.modelValue === 'number') {
const value = Number(target.value)
if (Number.isFinite(value)) emit('update:modelValue', value)
return
}
emit('update:modelValue', target.value)
}
function updateBoolean(event: Event): void {
const target = event.target
if (target instanceof HTMLInputElement) {
emit('update:modelValue', target.checked)
}
}
function addListRow(): void {
const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : []
rows.push(rows.length ? blankLike(rows[0]) : blankValue(newArrayKind.value))
emit('update:modelValue', rows)
}
function updateListRow(index: number, value: unknown): void {
const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : []
rows[index] = value
emit('update:modelValue', rows)
}
function removeListRow(index: number): void {
const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : []
rows.splice(index, 1)
emit('update:modelValue', rows)
}
function updateTableField(key: string, value: unknown): void {
emit('update:modelValue', { ...tableValue.value, [key]: value })
}
function removeTableField(key: string): void {
const next = { ...tableValue.value }
delete next[key]
emit('update:modelValue', next)
}
function addTableField(): void {
const key = newObjectKey.value.trim()
if (!key || Object.prototype.hasOwnProperty.call(tableValue.value, key))
return
emit('update:modelValue', {
...tableValue.value,
[key]: blankValue(newObjectKind.value),
})
newObjectKey.value = ''
}
</script>
<template>
<div
v-if="isList"
class="config-structured-editor"
:class="{ 'is-nested': depth > 0 }"
>
<header class="config-structured-editor__bar">
<span><Rows3 :size="14" />{{ labels.list }}</span>
<div>
<select
v-if="!listValue.length"
v-model="newArrayKind"
:disabled="disabled"
>
<option value="string">{{ labels.types.string }}</option>
<option value="number">{{ labels.types.number }}</option>
<option value="boolean">{{ labels.types.boolean }}</option>
<option value="table">{{ labels.types.table }}</option>
</select>
<button
v-if="!listValue.length"
type="button"
:disabled="disabled"
:title="labels.convertToTable"
@click="emit('update:modelValue', {})"
>
<TableProperties :size="13" />
</button>
<button type="button" :disabled="disabled" @click="addListRow">
<Plus :size="13" />{{ labels.addRow }}
</button>
</div>
</header>
<div v-if="listValue.length" class="config-structured-editor__rows">
<div
v-for="(row, index) in listValue"
:key="index"
class="config-structured-editor__row"
>
<span class="config-structured-editor__index">{{ index + 1 }}</span>
<AdminConfigValueEditor
:model-value="row"
:aria-label="`${ariaLabel} ${index + 1}`"
:labels="labels"
:disabled="disabled"
:depth="depth + 1"
@update:model-value="updateListRow(index, $event)"
/>
<button
type="button"
class="config-structured-editor__remove"
:disabled="disabled"
:title="labels.remove"
@click="removeListRow(index)"
>
<Trash2 :size="13" />
</button>
</div>
</div>
<div v-else class="config-structured-editor__empty">
{{ labels.emptyList }}
</div>
</div>
<div
v-else-if="isTable"
class="config-structured-editor"
:class="{ 'is-nested': depth > 0 }"
>
<header class="config-structured-editor__bar">
<span>
<TableProperties :size="14" />
{{
vectorType ? `${labels.vector} ${vectorType.slice(-1)}` : labels.table
}}
</span>
<button
v-if="!tableEntries.length && !vectorType"
type="button"
:disabled="disabled"
:title="labels.convertToList"
@click="emit('update:modelValue', [])"
>
<Rows3 :size="13" />{{ labels.convertToList }}
</button>
</header>
<div
v-if="tableEntries.length"
class="config-structured-editor__properties"
>
<div
v-for="([key, value], index) in tableEntries"
:key="key"
class="config-structured-editor__property"
>
<span class="config-structured-editor__index">{{ index + 1 }}</span>
<strong>{{ key }}</strong>
<AdminConfigValueEditor
:model-value="value"
:aria-label="`${ariaLabel} ${key}`"
:labels="labels"
:disabled="disabled"
:depth="depth + 1"
@update:model-value="updateTableField(key, $event)"
/>
<button
v-if="!vectorType"
type="button"
class="config-structured-editor__remove"
:disabled="disabled"
:title="labels.remove"
@click="removeTableField(key)"
>
<Trash2 :size="13" />
</button>
</div>
</div>
<div v-else class="config-structured-editor__empty">
{{ labels.emptyTable }}
</div>
<form
v-if="!vectorType"
class="config-structured-editor__add-field"
@submit.prevent="addTableField"
>
<input
v-model="newObjectKey"
type="text"
:disabled="disabled"
:placeholder="labels.keyPlaceholder"
/>
<select v-model="newObjectKind" :disabled="disabled">
<option value="string">{{ labels.types.string }}</option>
<option value="number">{{ labels.types.number }}</option>
<option value="boolean">{{ labels.types.boolean }}</option>
<option value="list">{{ labels.types.list }}</option>
<option value="table">{{ labels.types.table }}</option>
</select>
<button type="submit" :disabled="disabled || !newObjectKey.trim()">
<Plus :size="13" />{{ labels.addField }}
</button>
</form>
</div>
<label
v-else-if="typeof modelValue === 'boolean'"
class="config-value-toggle"
>
<input
type="checkbox"
:aria-label="ariaLabel"
:checked="modelValue"
:disabled="disabled"
@change="updateBoolean"
/>
<i></i>
</label>
<input
v-else
class="config-value-input"
:aria-label="ariaLabel"
:type="
isMaskedSecret
? 'password'
: typeof modelValue === 'number'
? 'number'
: 'text'
"
:value="isMaskedSecret ? '' : String(modelValue ?? '')"
:placeholder="isMaskedSecret ? labels.configuredSecret : ''"
:disabled="disabled"
:autocomplete="isMaskedSecret ? 'new-password' : 'off'"
@input="updateScalar"
/>
</template>
<style scoped>
.config-structured-editor {
min-width: 0;
overflow: hidden;
border-radius: 4px;
background: #181b18;
outline: 1px solid rgba(255, 255, 255, 0.065);
}
.config-structured-editor.is-nested {
background: #151715;
}
.config-structured-editor__bar {
min-height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 5px 7px;
background: rgba(255, 255, 255, 0.025);
}
.config-structured-editor__bar > span,
.config-structured-editor__bar > div {
display: flex;
align-items: center;
gap: 6px;
}
.config-structured-editor__bar > span {
color: var(--admin-muted);
font-size: 8px;
font-weight: 650;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.config-structured-editor button,
.config-structured-editor select,
.config-structured-editor input,
.config-value-input {
border: 0;
border-radius: 3px;
outline: 1px solid rgba(255, 255, 255, 0.075);
color: var(--admin-text);
background: #212421;
font: inherit;
font-size: 9px;
}
.config-structured-editor button {
min-height: 23px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0 7px;
color: var(--admin-green);
cursor: pointer;
}
.config-structured-editor button:hover:not(:disabled) {
background: color-mix(in srgb, var(--admin-green) 12%, #212421);
}
.config-structured-editor button:disabled,
.config-structured-editor input:disabled,
.config-structured-editor select:disabled,
.config-value-input:disabled {
opacity: 0.45;
cursor: default;
}
.config-structured-editor select {
height: 23px;
padding: 0 5px;
}
.config-structured-editor__rows,
.config-structured-editor__properties {
display: grid;
gap: 1px;
background: #0d0f0d;
}
.config-structured-editor__row,
.config-structured-editor__property {
display: grid;
grid-template-columns: 24px minmax(0, 1fr) 27px;
align-items: center;
gap: 6px;
padding: 5px 6px;
background: #1a1d1a;
}
.config-structured-editor__property {
grid-template-columns: 24px minmax(80px, 0.35fr) minmax(150px, 1fr) 27px;
}
.config-structured-editor__property > strong {
overflow: hidden;
color: #c9cec9;
font-size: 9px;
font-weight: 550;
text-overflow: ellipsis;
white-space: nowrap;
}
.config-structured-editor__index {
color: var(--admin-dim);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 8px;
text-align: center;
}
.config-structured-editor .config-structured-editor__remove {
width: 25px;
padding: 0;
color: #b66a6a;
}
.config-structured-editor__empty {
padding: 13px 10px;
color: var(--admin-dim);
font-size: 9px;
text-align: center;
}
.config-structured-editor__add-field {
display: grid;
grid-template-columns: minmax(100px, 1fr) 90px auto;
gap: 6px;
padding: 6px;
background: #151715;
}
.config-structured-editor__add-field input {
min-width: 0;
height: 25px;
padding: 0 7px;
}
.config-value-input {
width: 100%;
min-width: 0;
height: 29px;
padding: 0 8px;
}
.config-value-input:focus,
.config-structured-editor input:focus,
.config-structured-editor select:focus {
outline-color: color-mix(in srgb, var(--admin-green) 45%, transparent);
}
.config-value-toggle {
position: relative;
justify-self: end;
width: 32px;
height: 18px;
}
.config-value-toggle input {
position: absolute;
inset: 0;
z-index: 1;
margin: 0;
opacity: 0;
cursor: pointer;
}
.config-value-toggle i {
position: absolute;
inset: 0;
border-radius: 999px;
background: #393d39;
}
.config-value-toggle i::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #c7ccc7;
transition: transform 150ms ease;
}
.config-value-toggle input:checked + i {
background: color-mix(in srgb, var(--admin-green) 72%, #1f321f);
}
.config-value-toggle input:checked + i::after {
transform: translateX(14px);
background: #f4f7f4;
}
</style>
@@ -54,6 +54,28 @@ const schema = readFileSync(
new URL('../../../sky_phone/sql/install.sql', import.meta.url), new URL('../../../sky_phone/sql/install.sql', import.meta.url),
'utf8', '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', () => { describe('standalone admin panel contracts', () => {
it('renders as a dedicated full-screen editor outside the phone shell', () => { it('renders as a dedicated full-screen editor outside the phone shell', () => {
@@ -91,6 +113,7 @@ describe('standalone admin panel contracts', () => {
'calls', 'calls',
'moderation', 'moderation',
'audit', 'audit',
'configurator',
]) { ]) {
expect(source).toContain(`selectTab('${tab}')`) expect(source).toContain(`selectTab('${tab}')`)
expect(source).toContain(`t('tabs.${tab}')`) expect(source).toContain(`t('tabs.${tab}')`)
@@ -127,6 +150,8 @@ describe('standalone admin panel contracts', () => {
'admin:reset-passcode', 'admin:reset-passcode',
'admin:change-number', 'admin:change-number',
'admin:factory-reset', 'admin:factory-reset',
'admin:configurator',
'admin:save-configurator',
]) { ]) {
expect(store).toContain(endpoint) expect(store).toContain(endpoint)
} }
@@ -169,7 +194,7 @@ describe('standalone admin panel contracts', () => {
}) })
it('opens directly from the configurable command with dedicated focus', () => { it('opens directly from the configurable command with dedicated focus', () => {
expect(config).toContain('Command = "phoneadmin"') expect(config).toContain('Command = "phonepanel"')
expect(server).toContain( expect(server).toContain(
'RegisterCommand(Config.AdminPanel.Command, function(command_source)', '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`', '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('<AdminConfigValueEditor')
expect(configuratorValueEditor).toContain('function addListRow()')
expect(configuratorValueEditor).toContain('function addTableField()')
expect(configuratorValueEditor).toContain('function removeListRow(')
expect(configuratorValueEditor).toContain('function removeTableField(')
expect(configuratorValueEditor).not.toContain('<textarea')
})
}) })
+729 -5
View File
@@ -21,6 +21,7 @@ import {
Save, Save,
ScrollText, ScrollText,
Search, Search,
Settings2,
ShieldAlert, ShieldAlert,
Smartphone, Smartphone,
Trash2, Trash2,
@@ -43,13 +44,22 @@ import {
} from '@/config/apps' } from '@/config/apps'
import { useAdminStore } from '@/stores/admin' import { useAdminStore } from '@/stores/admin'
import { usePhoneStore } from '@/stores/phone' import { usePhoneStore } from '@/stores/phone'
import type { AdminAuditEntry, AdminDevice } from '@/types/admin' import type {
AdminAuditEntry,
AdminConfiguratorChange,
AdminConfiguratorField,
AdminDevice,
} from '@/types/admin'
import type { LaunchablePhoneAppDefinition } from '@/types/apps' import type { LaunchablePhoneAppDefinition } from '@/types/apps'
import { SkyButton } from '@/ui' import { SkyButton } from '@/ui'
import { copyText } from '@/utils/clipboard' import { copyText } from '@/utils/clipboard'
import { parseDatabaseDate } from '@/utils/date' import { parseDatabaseDate } from '@/utils/date'
import { nuiCall } from '@/utils/nui' import { nuiCall } from '@/utils/nui'
import AdminConfigValueEditor, {
type AdminConfigEditorLabels,
} from './AdminConfigValueEditor.vue'
type AdminTab = type AdminTab =
| 'overview' | 'overview'
| 'players' | 'players'
@@ -60,6 +70,7 @@ type AdminTab =
| 'calls' | 'calls'
| 'moderation' | 'moderation'
| 'audit' | 'audit'
| 'configurator'
type DeviceAction = 'reset-passcode' | 'change-number' | 'factory-reset' type DeviceAction = 'reset-passcode' | 'change-number' | 'factory-reset'
type PendingAction = { kind: 'close' } | { kind: 'player'; source: number } type PendingAction = { kind: 'close' } | { kind: 'player'; source: number }
@@ -69,8 +80,11 @@ const phone = usePhoneStore()
const tab = ref<AdminTab>('overview') const tab = ref<AdminTab>('overview')
const playerQuery = ref('') const playerQuery = ref('')
const appQuery = ref('') const appQuery = ref('')
const configuratorQuery = ref('')
const selectedConfiguratorSection = ref('')
const selectedImei = ref('') const selectedImei = ref('')
const drafts = ref<Record<string, Record<string, boolean>>>({}) const drafts = ref<Record<string, Record<string, boolean>>>({})
const configuratorDrafts = ref<Record<string, unknown>>({})
const saving = ref(false) const saving = ref(false)
const revealDialogImei = ref('') const revealDialogImei = ref('')
const deviceAction = ref<DeviceAction | null>(null) const deviceAction = ref<DeviceAction | null>(null)
@@ -138,12 +152,18 @@ const manageableApps = computed(() => {
) )
}) })
const pendingCount = computed(() => const appPendingCount = computed(() =>
Object.values(drafts.value).reduce( Object.values(drafts.value).reduce(
(total, changes) => total + Object.keys(changes).length, (total, changes) => total + Object.keys(changes).length,
0, 0,
), ),
) )
const configuratorPendingCount = computed(
() => Object.keys(configuratorDrafts.value).length,
)
const pendingCount = computed(
() => appPendingCount.value + configuratorPendingCount.value,
)
const hasChanges = computed(() => pendingCount.value > 0) const hasChanges = computed(() => pendingCount.value > 0)
const selectedDeviceChanges = computed(() => const selectedDeviceChanges = computed(() =>
selectedDevice.value selectedDevice.value
@@ -165,6 +185,37 @@ const selectedCalls = computed(() =>
? (admin.deviceActivity[selectedDevice.value.imei]?.calls ?? []) ? (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( watch(
() => admin.selectedPlayer, () => 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( watch(
[tab, selectedImei, () => admin.selectedPlayer?.source], [tab, selectedImei, () => admin.selectedPlayer?.source],
([currentTab, imei, source]) => { ([currentTab, imei, source]) => {
@@ -195,6 +259,28 @@ function t(key: string, params?: Record<string, string>): string {
return phone.t('AdminPanel.' + key, params) return phone.t('AdminPanel.' + key, params)
} }
const configuratorEditorLabels = computed<AdminConfigEditorLabels>(() => ({
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( function showToast(
message: string, message: string,
tone: 'error' | 'success' = 'success', tone: 'error' | 'success' = 'success',
@@ -307,6 +393,11 @@ function queueAction(action: PendingAction): void {
function selectTab(nextTab: AdminTab): void { function selectTab(nextTab: AdminTab): void {
tab.value = nextTab 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 { function selectAccent(color: (typeof accentOptions)[number]['color']): void {
@@ -327,11 +418,96 @@ async function runAction(action: PendingAction): Promise<void> {
async function discardAndContinue(): Promise<void> { async function discardAndContinue(): Promise<void> {
const action = pendingAction.value const action = pendingAction.value
drafts.value = {} drafts.value = {}
configuratorDrafts.value = {}
pendingAction.value = null pendingAction.value = null
discardDialog.value = false discardDialog.value = false
if (action) await runAction(action) 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 { function cancelDiscard(): void {
discardDialog.value = false discardDialog.value = false
pendingAction.value = null pendingAction.value = null
@@ -339,8 +515,35 @@ function cancelDiscard(): void {
async function saveChanges(): Promise<void> { async function saveChanges(): Promise<void> {
const player = admin.selectedPlayer const player = admin.selectedPlayer
if (!player || !hasChanges.value || saving.value) return if (!hasChanges.value || saving.value) return
saving.value = true 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) const pendingDevices = Object.entries(drafts.value)
for (const [imei, deviceChanges] of pendingDevices) { for (const [imei, deviceChanges] of pendingDevices) {
const device = admin.selectedPlayer?.devices.find( const device = admin.selectedPlayer?.devices.find(
@@ -500,7 +703,9 @@ onBeforeUnmount(() => {
</span> </span>
<ChevronRight :size="14" /> <ChevronRight :size="14" />
<strong>{{ <strong>{{
admin.selectedPlayer?.name || t('editor.noSelection') tab === 'configurator'
? t('configurator.context')
: admin.selectedPlayer?.name || t('editor.noSelection')
}}</strong> }}</strong>
</div> </div>
@@ -615,6 +820,16 @@ onBeforeUnmount(() => {
> >
<ScrollText :size="19" /> <ScrollText :size="19" />
</button> </button>
<button
type="button"
class="admin-panel-rail__configurator"
:class="{ 'is-active': tab === 'configurator' }"
:aria-label="t('tabs.configurator')"
:title="t('tabs.configurator')"
@click="selectTab('configurator')"
>
<Settings2 :size="19" />
</button>
</nav> </nav>
<aside class="admin-panel-directory"> <aside class="admin-panel-directory">
@@ -691,6 +906,62 @@ onBeforeUnmount(() => {
</span> </span>
<ChevronRight :size="14" /> <ChevronRight :size="14" />
</button> </button>
<button type="button" @click="selectTab('configurator')">
<Settings2 :size="17" />
<span>
<strong>{{ t('tabs.configurator') }}</strong>
<small>{{ t('overview.configuratorFeature') }}</small>
</span>
<ChevronRight :size="14" />
</button>
</div>
</template>
<template v-else-if="tab === 'configurator'">
<div class="admin-panel-directory__header">
<div>
<span>{{ t('configurator.eyebrow') }}</span>
<h2>{{ t('configurator.sections') }}</h2>
</div>
<strong>{{ filteredConfiguratorFieldCount }}</strong>
</div>
<label class="admin-panel-search">
<Search :size="16" />
<input
v-model="configuratorQuery"
type="search"
:placeholder="t('configurator.search')"
:aria-label="t('configurator.search')"
/>
</label>
<div class="admin-panel-config-sections">
<button
v-for="section in filteredConfiguratorSections"
:key="section.id"
type="button"
:class="{
'is-active': activeConfiguratorSection?.id === section.id,
}"
@click="selectedConfiguratorSection = section.id"
>
<Settings2 :size="16" />
<span>
<strong>{{ section.label }}</strong>
<small>{{
section.scope === 'media'
? t('configurator.mediaScope')
: t('configurator.configScope')
}}</small>
</span>
<em>{{ section.fields.length }}</em>
</button>
<div
v-if="!filteredConfiguratorSections.length"
class="admin-panel-empty-list"
>
<Search :size="24" />
<strong>{{ t('configurator.noResults') }}</strong>
</div>
</div> </div>
</template> </template>
@@ -877,6 +1148,142 @@ onBeforeUnmount(() => {
</article> </article>
</section> </section>
<section
v-else-if="tab === 'configurator'"
class="admin-panel-editor__scroll"
>
<div
v-if="admin.configuratorLoading && !admin.configurator"
class="admin-panel-loading"
>
<LoaderCircle :size="26" class="is-spinning" />
<span>{{ t('configurator.loading') }}</span>
</div>
<template v-else-if="admin.configurator">
<div class="admin-panel-page-heading">
<div class="admin-panel-heading-icon">
<Settings2 :size="23" />
</div>
<div class="admin-panel-config-heading-copy">
<span>{{ t('configurator.eyebrow') }}</span>
<h1>{{ t('configurator.title') }}</h1>
<p>{{ t('configurator.body') }}</p>
</div>
<div class="admin-panel-config-meta">
<span>SQL</span>
<strong>R{{ admin.configurator.revision }}</strong>
</div>
</div>
<article
v-if="!admin.configurator.enabled"
class="admin-panel-config-disabled"
>
<TriangleAlert :size="20" />
<div>
<strong>{{ t('configurator.disabledTitle') }}</strong>
<p>{{ t('configurator.disabledBody') }}</p>
<code>Config.PhoneConfigurator.Enabled = true</code>
</div>
</article>
<article class="admin-panel-config-notice">
<Save :size="18" />
<div>
<strong>{{ t('configurator.manualSave') }}</strong>
<p>{{ t('configurator.restartNotice') }}</p>
</div>
</article>
<section
v-if="activeConfiguratorSection"
class="admin-panel-config-workspace"
>
<header>
<div>
<span>{{
activeConfiguratorSection.scope === 'media'
? t('configurator.mediaScope')
: t('configurator.configScope')
}}</span>
<h2>{{ activeConfiguratorSection.label }}</h2>
</div>
<strong>{{
t('configurator.fieldCount', {
count: String(activeConfiguratorSection.fields.length),
})
}}</strong>
</header>
<div class="admin-panel-config-fields">
<div
v-for="field in activeConfiguratorSection.fields"
:key="configuratorFieldKey(field)"
class="admin-panel-config-field"
:class="{
'is-dirty': Object.prototype.hasOwnProperty.call(
configuratorDrafts,
configuratorFieldKey(field),
),
}"
>
<span class="admin-panel-config-field__copy">
<strong>{{ field.label }}</strong>
<small>{{ field.path }}</small>
</span>
<span
v-if="field.type === 'boolean'"
class="admin-panel-config-toggle"
>
<input
type="checkbox"
:aria-label="`${field.label} ${field.path}`"
:checked="Boolean(configuratorFieldValue(field))"
:disabled="!admin.configurator.enabled"
@change="updateConfiguratorToggle(field, $event)"
/>
<i></i>
</span>
<AdminConfigValueEditor
v-else-if="field.type === 'json'"
:model-value="configuratorFieldValue(field)"
:aria-label="`${field.label} ${field.path}`"
:labels="configuratorEditorLabels"
:disabled="!admin.configurator.enabled"
@update:model-value="
updateConfiguratorField(field, $event)
"
/>
<input
v-else
:aria-label="`${field.label} ${field.path}`"
:type="
field.sensitive
? 'password'
: field.type === 'number'
? 'number'
: 'text'
"
:value="String(configuratorFieldValue(field) ?? '')"
:placeholder="
field.sensitive && field.configured
? t('configurator.secretConfigured')
: ''
"
:disabled="!admin.configurator.enabled"
:autocomplete="field.sensitive ? 'new-password' : 'off'"
@input="updateConfiguratorInput(field, $event)"
/>
</div>
</div>
</section>
</template>
</section>
<section <section
v-else-if="tab === 'audit'" v-else-if="tab === 'audit'"
class="admin-panel-editor__scroll" class="admin-panel-editor__scroll"
@@ -1829,6 +2236,10 @@ button:disabled {
background: #222522; background: #222522;
} }
.admin-panel-rail .admin-panel-rail__configurator {
margin-top: auto;
}
.admin-panel-rail button.is-active { .admin-panel-rail button.is-active {
color: #f4f6f4; color: #f4f6f4;
background: var(--admin-nav-active); background: var(--admin-nav-active);
@@ -1987,7 +2398,8 @@ button:disabled {
} }
.admin-panel-player-list, .admin-panel-player-list,
.admin-panel-audit-mini-list { .admin-panel-audit-mini-list,
.admin-panel-config-sections {
height: calc(100% - 115px); height: calc(100% - 115px);
overflow-y: auto; overflow-y: auto;
padding: 0 8px 16px; padding: 0 8px 16px;
@@ -1995,6 +2407,79 @@ button:disabled {
scrollbar-width: thin; scrollbar-width: thin;
} }
.admin-panel-config-sections {
height: calc(100% - 111px);
overflow-y: auto;
padding: 0 8px 16px;
scrollbar-color: #343834 transparent;
scrollbar-width: thin;
}
.admin-panel-config-sections > button {
width: 100%;
min-height: 44px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 7px 9px;
border: 0;
border-radius: 3px;
color: var(--admin-muted);
background: transparent;
text-align: left;
cursor: pointer;
}
.admin-panel-config-sections > button:hover {
color: var(--admin-text);
background: var(--admin-row-hover);
}
.admin-panel-config-sections > button.is-active {
color: var(--admin-green);
background: var(--admin-row-active);
box-shadow: inset 2px 0 var(--admin-green);
}
.admin-panel-config-sections > button > svg {
color: var(--admin-green);
}
.admin-panel-config-sections > button > span {
display: grid;
min-width: 0;
gap: 2px;
}
.admin-panel-config-sections strong,
.admin-panel-config-sections small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-panel-config-sections strong {
color: var(--admin-text);
font-size: 10px;
font-weight: 600;
}
.admin-panel-config-sections small,
.admin-panel-config-sections em {
color: var(--admin-muted);
font-size: 8px;
font-style: normal;
}
.admin-panel-config-sections em {
min-width: 23px;
padding: 3px 5px;
border-radius: 4px;
background: #1d201d;
text-align: center;
}
.admin-panel-player-list > button { .admin-panel-player-list > button {
width: 100%; width: 100%;
display: grid; display: grid;
@@ -2829,6 +3314,245 @@ button:disabled {
padding: 2px 0; padding: 2px 0;
} }
.admin-panel-config-heading-copy {
display: grid;
gap: 2px;
}
.admin-panel-config-meta {
display: flex;
align-items: center;
gap: 7px;
margin-left: auto;
padding: 5px 8px;
border-radius: 4px;
color: var(--admin-muted);
background: #171917;
font-size: 9px;
}
.admin-panel-config-meta strong {
color: var(--admin-green);
font-size: 9px;
}
.admin-panel-config-disabled,
.admin-panel-config-notice {
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
align-items: start;
gap: 9px;
margin-bottom: 8px;
padding: 10px 11px;
border-radius: 3px;
background: linear-gradient(90deg, rgba(240, 162, 75, 0.13), transparent 80%);
}
.admin-panel-config-notice {
background: linear-gradient(
90deg,
color-mix(in srgb, var(--admin-green) 11%, transparent),
transparent 82%
);
}
.admin-panel-config-disabled > svg {
color: #f0a24b;
}
.admin-panel-config-notice > svg {
color: var(--admin-green);
}
.admin-panel-config-disabled div,
.admin-panel-config-notice div {
display: grid;
gap: 3px;
}
.admin-panel-config-disabled strong,
.admin-panel-config-notice strong {
font-size: 10px;
}
.admin-panel-config-disabled p,
.admin-panel-config-notice p {
margin: 0;
color: var(--admin-muted);
font-size: 9px;
line-height: 1.45;
}
.admin-panel-config-disabled code {
width: fit-content;
margin-top: 3px;
padding: 4px 6px;
border-radius: 3px;
color: #f6c889;
background: rgba(0, 0, 0, 0.25);
font-size: 9px;
}
.admin-panel-config-workspace {
margin-top: 10px;
overflow: hidden;
border-radius: 3px;
background: #111311;
}
.admin-panel-config-workspace > header {
min-height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 9px 12px;
background: #171917;
}
.admin-panel-config-workspace > header > div {
display: grid;
gap: 2px;
}
.admin-panel-config-workspace > header span,
.admin-panel-config-workspace > header > strong {
color: var(--admin-muted);
font-size: 8px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.admin-panel-config-workspace h2 {
margin: 0;
font-size: 13px;
font-weight: 600;
}
.admin-panel-config-fields {
display: grid;
gap: 1px;
background: #0b0d0c;
}
.admin-panel-config-field {
min-height: 51px;
display: grid;
grid-template-columns: minmax(170px, 0.72fr) minmax(230px, 1.28fr);
align-items: center;
gap: 14px;
padding: 8px 12px;
background: #121412;
}
.admin-panel-config-field:hover {
background: var(--admin-row-hover);
}
.admin-panel-config-field.is-dirty {
background: var(--admin-row-active);
box-shadow: inset 2px 0 var(--admin-green);
}
.admin-panel-config-field__copy {
display: grid;
min-width: 0;
gap: 3px;
}
.admin-panel-config-field__copy strong,
.admin-panel-config-field__copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-panel-config-field__copy strong {
font-size: 10px;
font-weight: 600;
}
.admin-panel-config-field__copy small {
color: var(--admin-muted);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 8px;
}
.admin-panel-config-field > input {
width: 100%;
min-width: 0;
border: 0;
border-radius: 4px;
outline: 1px solid rgba(255, 255, 255, 0.07);
color: var(--admin-text);
background: #1b1e1b;
font: inherit;
font-size: 10px;
}
.admin-panel-config-field > input {
height: 31px;
padding: 0 9px;
}
.admin-panel-config-field > input:focus {
outline-color: color-mix(in srgb, var(--admin-green) 45%, transparent);
}
.admin-panel-config-field > input:disabled {
opacity: 0.45;
}
.admin-panel-config-toggle {
position: relative;
justify-self: end;
width: 32px;
height: 18px;
}
.admin-panel-config-toggle input {
position: absolute;
inset: 0;
z-index: 1;
margin: 0;
opacity: 0;
cursor: pointer;
}
.admin-panel-config-toggle i {
position: absolute;
inset: 0;
border-radius: 999px;
background: #393d39;
transition: background 150ms ease;
}
.admin-panel-config-toggle i::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #c7ccc7;
transition: transform 150ms ease;
}
.admin-panel-config-toggle input:checked + i {
background: color-mix(in srgb, var(--admin-green) 72%, #1f321f);
}
.admin-panel-config-toggle input:checked + i::after {
transform: translateX(14px);
background: #f4f7f4;
}
.admin-panel-config-toggle input:disabled + i {
opacity: 0.45;
}
.admin-panel-heading-icon, .admin-panel-heading-icon,
.admin-panel-empty-editor__icon { .admin-panel-empty-editor__icon {
width: 44px; width: 44px;
+40
View File
@@ -5,6 +5,8 @@ import type {
AdminActivityResponse, AdminActivityResponse,
AdminBootstrap, AdminBootstrap,
AdminCallActivity, AdminCallActivity,
AdminConfigurator,
AdminConfiguratorChange,
AdminCredential, AdminCredential,
AdminMessageActivity, AdminMessageActivity,
AdminPlayerDetail, AdminPlayerDetail,
@@ -20,6 +22,8 @@ export const useAdminStore = defineStore('admin', {
actionKey: '', actionKey: '',
activityKey: '', activityKey: '',
audit: [] as AdminAuditEntry[], audit: [] as AdminAuditEntry[],
configurator: null as AdminConfigurator | null,
configuratorLoading: false,
detailLoading: false, detailLoading: false,
error: '', error: '',
initialized: false, initialized: false,
@@ -134,6 +138,42 @@ export const useAdminStore = defineStore('admin', {
this.error = '' this.error = ''
return true return true
}, },
async loadConfigurator(): Promise<boolean> {
this.configuratorLoading = true
const response = await nuiCall<AdminConfigurator>('admin:configurator')
this.configuratorLoading = false
if (!response.success || !response.data) {
this.error = response.error ?? 'request_failed'
return false
}
this.configurator = response.data
this.error = ''
return true
},
async saveConfigurator(
changes: AdminConfiguratorChange[],
): Promise<NuiResponse<AdminConfigurator>> {
const current = this.configurator
if (!current) return { error: 'request_failed', success: false }
this.actionKey = 'configurator:save'
const response = await nuiCall<AdminConfigurator>(
'admin:save-configurator',
{
changes,
revision: current.revision,
},
)
this.actionKey = ''
if (response.success && response.data) {
this.configurator = response.data
this.error = ''
} else {
if (response.data) this.configurator = response.data
this.error = response.error ?? 'request_failed'
}
return response
},
async resetPasscode( async resetPasscode(
source: number, source: number,
imei: string, imei: string,
+51 -2
View File
@@ -821,6 +821,7 @@ const adminPanelFallbackLocales = {
calls: 'Calls', calls: 'Calls',
moderation: 'Moderation', moderation: 'Moderation',
audit: 'Audit', audit: 'Audit',
configurator: 'Phone configurator',
}, },
overview: { overview: {
eyebrow: 'Server', eyebrow: 'Server',
@@ -843,6 +844,7 @@ const adminPanelFallbackLocales = {
callFeature: 'Review recent call activity', callFeature: 'Review recent call activity',
moderationFeature: 'Reset access, number, or device data', moderationFeature: 'Reset access, number, or device data',
auditFeature: 'Review sensitive admin actions', auditFeature: 'Review sensitive admin actions',
configuratorFeature: 'Manage config.lua and media.lua through SQL',
}, },
appearance: { appearance: {
eyebrow: 'Appearance', eyebrow: 'Appearance',
@@ -856,6 +858,48 @@ const adminPanelFallbackLocales = {
red: 'Red', red: 'Red',
}, },
}, },
configurator: {
context: 'Runtime configuration',
eyebrow: 'System tool',
sections: 'Configuration',
search: 'Search settings or paths',
configScope: 'config.lua',
mediaScope: 'media.lua',
noResults: 'No matching settings',
loading: 'Loading SQL configuration...',
title: 'Phone configurator',
body: 'Manage phone and media settings from the protected admin workspace.',
disabledTitle: 'SQL configuration is not active',
disabledBody:
'Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.',
manualSave: 'Manual save',
restartNotice:
'Nothing is written automatically. Press the green check to persist all staged values. Startup-bound settings take full effect after restarting sky_phone.',
fieldCount: '{count} fields',
secretConfigured: 'Secret configured · enter a replacement',
invalidValue: 'Check the highlighted table or number value.',
saved: 'SQL configuration saved.',
table: {
list: 'List',
table: 'Key table',
vector: 'Vector',
addRow: 'Add row',
addField: 'Add field',
remove: 'Remove',
emptyList: 'No rows yet. Add the first row with plus.',
emptyTable: 'No fields yet. Add the first key below.',
keyPlaceholder: 'New key',
convertToList: 'Use list',
convertToTable: 'Use key table',
types: {
string: 'Text',
number: 'Number',
boolean: 'Switch',
list: 'List',
table: 'Table',
},
},
},
players: { players: {
eyebrow: 'Active sessions', eyebrow: 'Active sessions',
title: 'Online players', title: 'Online players',
@@ -1006,7 +1050,8 @@ const adminPanelFallbackLocales = {
noAutoSave: 'Manual save', noAutoSave: 'Manual save',
noAutoSaveBody: 'Changes stay local until the green check is pressed.', noAutoSaveBody: 'Changes stay local until the green check is pressed.',
discardTitle: 'Discard unsaved changes?', discardTitle: 'Discard unsaved changes?',
discardBody: 'Your staged app changes have not been saved.', discardBody:
'Your staged app or configuration changes have not been saved.',
keepEditing: 'Keep editing', keepEditing: 'Keep editing',
discard: 'Discard changes', discard: 'Discard changes',
saveFailed: 'Some changes could not be saved.', saveFailed: 'Some changes could not be saved.',
@@ -1028,6 +1073,7 @@ const adminPanelFallbackLocales = {
reset_passcode: 'Passcode reset', reset_passcode: 'Passcode reset',
change_number: 'Phone number changed', change_number: 'Phone number changed',
factory_reset: 'Phone factory reset', factory_reset: 'Phone factory reset',
save_configuration: 'Configuration saved',
}, },
}, },
errors: { errors: {
@@ -1038,7 +1084,10 @@ const adminPanelFallbackLocales = {
invalid_app: 'That app is not registered on the server.', invalid_app: 'That app is not registered on the server.',
app_protected: 'This system app cannot be removed.', app_protected: 'This system app cannot be removed.',
revision_conflict: revision_conflict:
'The phone changed in the meantime. Refresh and try again.', 'The data changed in the meantime. Reopen the section and try again.',
configurator_disabled: 'Enable the phone configurator in config.lua first.',
invalid_field: 'That configuration field is no longer available.',
invalid_value: 'A configuration value is invalid.',
account_not_found: 'No iFruit account is linked to this phone.', account_not_found: 'No iFruit account is linked to this phone.',
invalid_phone_number: invalid_phone_number:
'Enter a phone number in the configured server format.', 'Enter a phone number in the configured server format.',
+31
View File
@@ -111,3 +111,34 @@ export type AdminCallActivity = {
export type AdminActivityResponse = export type AdminActivityResponse =
| { entries: AdminMessageActivity[]; kind: 'messages' } | { entries: AdminMessageActivity[]; kind: 'messages' }
| { entries: AdminCallActivity[]; kind: 'calls' } | { entries: AdminCallActivity[]; kind: 'calls' }
export type AdminConfiguratorField = {
configured?: boolean
label: string
path: string
scope: 'config' | 'media'
sensitive: boolean
type: 'boolean' | 'json' | 'number' | 'string'
value: unknown
}
export type AdminConfiguratorSection = {
fields: AdminConfiguratorField[]
id: string
label: string
scope: 'config' | 'media'
}
export type AdminConfigurator = {
enabled: boolean
revision: number
sections: AdminConfiguratorSection[]
updatedAt: string | null
updatedBy: string | null
}
export type AdminConfiguratorChange = {
path: string
scope: 'config' | 'media'
value: unknown
}
+218
View File
@@ -4742,11 +4742,204 @@ function adminMockBootstrap() {
} }
} }
const adminMockConfigurator = {
enabled: true,
revision: 4,
sections: [
{
id: 'config:Bridge',
label: 'Bridge',
scope: 'config',
fields: [
{
label: 'Framework',
path: 'Bridge.Framework',
scope: 'config',
sensitive: false,
type: 'string',
value: 'auto',
},
{
label: 'Inventory',
path: 'Bridge.Inventory',
scope: 'config',
sensitive: false,
type: 'string',
value: 'auto',
},
{
label: 'Locale',
path: 'Bridge.Locale',
scope: 'config',
sensitive: false,
type: 'string',
value: 'de',
},
{
label: 'Callback Timeout',
path: 'Bridge.CallbackTimeout',
scope: 'config',
sensitive: false,
type: 'number',
value: 15000,
},
{
label: 'Debug',
path: 'Bridge.Debug',
scope: 'config',
sensitive: false,
type: 'boolean',
value: false,
},
],
},
{
id: 'config:Phone',
label: 'Phone',
scope: 'config',
fields: [
{
label: 'Item',
path: 'Phone.Item',
scope: 'config',
sensitive: false,
type: 'string',
value: 'phone',
},
{
label: 'Unique',
path: 'Phone.Unique',
scope: 'config',
sensitive: false,
type: 'boolean',
value: true,
},
{
label: 'Keybind',
path: 'Phone.Keybind',
scope: 'config',
sensitive: false,
type: 'string',
value: 'F1',
},
{
label: 'Device Name',
path: 'Phone.DeviceName',
scope: 'config',
sensitive: false,
type: 'string',
value: 'iFruit Phone',
},
],
},
{
id: 'config:Companies',
label: 'Companies',
scope: 'config',
fields: [
{
label: 'Enabled',
path: 'Companies.Enabled',
scope: 'config',
sensitive: false,
type: 'boolean',
value: true,
},
{
label: 'Categories',
path: 'Companies.Categories',
scope: 'config',
sensitive: false,
type: 'json',
value: ['public_services', 'vehicles', 'transport'],
},
],
},
{
id: 'media:FiveManage',
label: 'Five Manage',
scope: 'media',
fields: [
{
configured: true,
label: 'Api Key',
path: 'FiveManage.ApiKey',
scope: 'media',
sensitive: true,
type: 'string',
value: '',
},
{
label: 'Base Url',
path: 'FiveManage.BaseUrl',
scope: 'media',
sensitive: false,
type: 'string',
value: 'https://api.fivemanage.com/api/v3/file',
},
{
label: 'Upload Timeout Ms',
path: 'FiveManage.UploadTimeoutMs',
scope: 'media',
sensitive: false,
type: 'number',
value: 25000,
},
],
},
{
id: 'media:Import',
label: 'Import',
scope: 'media',
fields: [
{
label: 'Enabled',
path: 'Import.Enabled',
scope: 'media',
sensitive: false,
type: 'boolean',
value: true,
},
{
label: 'Websites',
path: 'Import.Websites',
scope: 'media',
sensitive: false,
type: 'json',
value: [
{
Adapter: 'fivemanage',
Enabled: true,
Id: 'fivemanage',
Label: 'FiveManage',
},
],
},
],
},
],
updatedAt: '2026-08-20 20:15:00',
updatedBy: 'Alex Morgan',
}
app.post('/api/:endpoint', async (request, response, next) => { app.post('/api/:endpoint', async (request, response, next) => {
const endpoint = request.params.endpoint const endpoint = request.params.endpoint
const loggedBody = { ...request.body } const loggedBody = { ...request.body }
if (typeof loggedBody.password === 'string') if (typeof loggedBody.password === 'string')
loggedBody.password = '<redacted>' loggedBody.password = '<redacted>'
if (
endpoint === 'admin:save-configurator' &&
Array.isArray(loggedBody.changes)
) {
loggedBody.changes = loggedBody.changes.map((change) => ({
...change,
value: /api.?key|pepper|secret|token|password/i.test(
String(change.path ?? ''),
)
? '<redacted>'
: change.value,
}))
}
if (endpoint === 'memos:devCapture') { if (endpoint === 'memos:devCapture') {
loggedBody.audioDataUrl = `<${String(request.body.audioDataUrl ?? '').length} characters>` loggedBody.audioDataUrl = `<${String(request.body.audioDataUrl ?? '').length} characters>`
} }
@@ -4759,6 +4952,31 @@ app.post('/api/:endpoint', async (request, response, next) => {
response.json({ success: true, data: adminMockBootstrap() }) response.json({ success: true, data: adminMockBootstrap() })
return return
} }
if (endpoint === 'admin:configurator') {
response.json({ success: true, data: adminMockConfigurator })
return
}
if (endpoint === 'admin:save-configurator') {
const changes = Array.isArray(request.body.changes)
? request.body.changes
: []
for (const change of changes) {
for (const section of adminMockConfigurator.sections) {
const field = section.fields.find(
(candidate) =>
candidate.scope === change.scope && candidate.path === change.path,
)
if (!field) continue
if (field.sensitive)
field.configured = String(change.value ?? '') !== ''
else field.value = change.value
}
}
adminMockConfigurator.revision += 1
adminMockConfigurator.updatedAt = new Date().toISOString()
response.json({ success: true, data: adminMockConfigurator })
return
}
if (endpoint === 'admin:player') { if (endpoint === 'admin:player') {
response.json({ response.json({
success: true, success: true,
+7 -1
View File
@@ -10,6 +10,12 @@
Keep option names unchanged. Restart sky_phone after editing this file. Keep option names unchanged. Restart sky_phone after editing this file.
]] ]]
-- When enabled, config.lua and media.lua only provide first-run defaults.
-- The active configuration is loaded from SQL and managed through /phonepanel.
Config.PhoneConfigurator = {
Enabled = false,
}
-- ============================================================================= -- =============================================================================
-- Core, framework and device -- Core, framework and device
-- ============================================================================= -- =============================================================================
@@ -63,7 +69,7 @@ Config.Security = {
Config.AdminPanel = { Config.AdminPanel = {
Enabled = true, Enabled = true,
Command = "phoneadmin", Command = "phonepanel",
AdminGroups = { "admin", "superadmin" }, AdminGroups = { "admin", "superadmin" },
MaximumPlayers = 128, MaximumPlayers = 128,
ReadRequestsPerMinute = 60, ReadRequestsPerMinute = 60,
+7 -6
View File
@@ -1,7 +1,7 @@
Locales["de"] = { Locales["de"] = {
CommandDescription = "Öffne dein Handy.", CommandDescription = "Öffne dein Handy.",
AdminCommand = { AdminCommand = {
CommandDescription = "Öffne das geschützte Handy-Admin-Panel.", CommandDescription = "Öffne Handy-Administration und -Konfiguration.",
Errors = { Errors = {
disabled = "Das Handy-Admin-Panel ist deaktiviert.", disabled = "Das Handy-Admin-Panel ist deaktiviert.",
not_authorized = "Du hast keinen Zugriff auf das Handy-Admin-Panel.", not_authorized = "Du hast keinen Zugriff auf das Handy-Admin-Panel.",
@@ -203,9 +203,10 @@ Locales["de"] = {
}, },
AdminPanel = { AdminPanel = {
name = "Phone Admin", subtitle = "Administration", navigation = "Admin-Navigation", refresh = "Admin-Daten aktualisieren", loading = "Geschützte Daten werden geladen...", name = "Phone Admin", subtitle = "Administration", navigation = "Admin-Navigation", refresh = "Admin-Daten aktualisieren", loading = "Geschützte Daten werden geladen...",
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit" }, tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit", configurator = "Phone Configurator" },
overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", auditFeature = "Sensible Admin-Aktionen prüfen" }, overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", auditFeature = "Sensible Admin-Aktionen prüfen", configuratorFeature = "config.lua und media.lua über SQL verwalten" },
appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } }, appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } },
configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", restartNotice = "Nichts wird automatisch gespeichert. Der grüne Haken schreibt alle vorgemerkten Werte. Startgebundene Einstellungen werden nach einem Neustart von sky_phone vollständig aktiv.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert.", table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } },
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." }, players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" }, search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" },
detail = { character = "Charakterprofil", data = "Spielerdatenübersicht", cash = "Bargeld", bank = "Bank", job = "Job", duty = "Dienst", onDuty = "Im Dienst", offDuty = "Außer Dienst", identity = "Identität", playerData = "Spielerdaten", identifier = "Charakter-Identifier", birthdate = "Geburtsdatum", grade = "Job-Rang", unknown = "Unbekannt" }, detail = { character = "Charakterprofil", data = "Spielerdatenübersicht", cash = "Bargeld", bank = "Bank", job = "Job", duty = "Dienst", onDuty = "Im Dienst", offDuty = "Außer Dienst", identity = "Identität", playerData = "Spielerdaten", identifier = "Charakter-Identifier", birthdate = "Geburtsdatum", grade = "Job-Rang", unknown = "Unbekannt" },
@@ -218,9 +219,9 @@ Locales["de"] = {
dialogs = { ["reset-passcodeTitle"] = "Gerätecode zurücksetzen?", ["reset-passcodeBody"] = "Der Spieler kann dieses Handy danach ohne die bisherige PIN entsperren.", ["change-numberTitle"] = "Telefonnummer ändern?", ["change-numberBody"] = "Die neue Nummer muss dem Serverformat entsprechen und eindeutig sein.", ["factory-resetTitle"] = "Werksreset für dieses Handy?", ["factory-resetBody"] = "Lokale Gerätedaten, App-Einstellungen, Sicherheit und der verknüpfte Account werden gelöscht. Das kann nicht rückgängig gemacht werden." }, dialogs = { ["reset-passcodeTitle"] = "Gerätecode zurücksetzen?", ["reset-passcodeBody"] = "Der Spieler kann dieses Handy danach ohne die bisherige PIN entsperren.", ["change-numberTitle"] = "Telefonnummer ändern?", ["change-numberBody"] = "Die neue Nummer muss dem Serverformat entsprechen und eindeutig sein.", ["factory-resetTitle"] = "Werksreset für dieses Handy?", ["factory-resetBody"] = "Lokale Gerätedaten, App-Einstellungen, Sicherheit und der verknüpfte Account werden gelöscht. Das kann nicht rückgängig gemacht werden." },
confirm = { ["reset-passcode"] = "Gerätecode zurücksetzen", ["change-number"] = "Nummer ändern", ["factory-reset"] = "Werksreset" }, confirm = { ["reset-passcode"] = "Gerätecode zurücksetzen", ["change-number"] = "Nummer ändern", ["factory-reset"] = "Werksreset" },
}, },
audit = { eyebrow = "Nachvollziehbarkeit", title = "Audit-Verlauf", body = "Sensible Anzeigen und App-Änderungen werden hier protokolliert.", empty = "Noch keine Admin-Aktionen", emptyBody = "Geschützte Aktionen erscheinen hier, nachdem sie ausgeführt wurden.", by = "{actor} · Ziel-ID {target}", actions = { grant_app = "App installiert", revoke_app = "App entfernt", reveal_account_password = "Passwort angezeigt", view_messages = "Nachrichten angesehen", view_calls = "Anrufe angesehen", reset_passcode = "Gerätecode zurückgesetzt", change_number = "Telefonnummer geändert", factory_reset = "Werksreset ausgeführt" } }, audit = { eyebrow = "Nachvollziehbarkeit", title = "Audit-Verlauf", body = "Sensible Anzeigen und App-Änderungen werden hier protokolliert.", empty = "Noch keine Admin-Aktionen", emptyBody = "Geschützte Aktionen erscheinen hier, nachdem sie ausgeführt wurden.", by = "{actor} · Ziel-ID {target}", actions = { grant_app = "App installiert", revoke_app = "App entfernt", reveal_account_password = "Passwort angezeigt", view_messages = "Nachrichten angesehen", view_calls = "Anrufe angesehen", reset_passcode = "Gerätecode zurückgesetzt", change_number = "Telefonnummer geändert", factory_reset = "Werksreset ausgeführt", save_configuration = "Konfiguration gespeichert" } },
editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Spielerverzeichnis", audit = "Audit-Protokoll", selectPlayer = "Wähle einen Spieler, um Identität, Geräte, Zugangsdaten und App-Zugriffe zu prüfen.", save = "Änderungen speichern", saveHint = "Offene Änderungen übernehmen", saved = "Änderungen gespeichert.", unsaved = "Ungespeicherte Änderungen", close = "Admin-Panel schließen", refresh = "Live-Daten aktualisieren", online = "LIVE", profile = "PROFIL", financial = "FINANZEN", device = "GERÄT", security = "SICHERHEIT", noAutoSave = "Manuelles Speichern", noAutoSaveBody = "Änderungen bleiben lokal, bis der grüne Haken gedrückt wird.", discardTitle = "Ungespeicherte Änderungen verwerfen?", discardBody = "Deine vorgemerkten App-Änderungen wurden noch nicht gespeichert.", keepEditing = "Weiter bearbeiten", discard = "Änderungen verwerfen", saveFailed = "Einige Änderungen konnten nicht gespeichert werden.", noSelection = "Kein Spieler ausgewählt" }, editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Spielerverzeichnis", audit = "Audit-Protokoll", selectPlayer = "Wähle einen Spieler, um Identität, Geräte, Zugangsdaten und App-Zugriffe zu prüfen.", save = "Änderungen speichern", saveHint = "Offene Änderungen übernehmen", saved = "Änderungen gespeichert.", unsaved = "Ungespeicherte Änderungen", close = "Admin-Panel schließen", refresh = "Live-Daten aktualisieren", online = "LIVE", profile = "PROFIL", financial = "FINANZEN", device = "GERÄT", security = "SICHERHEIT", noAutoSave = "Manuelles Speichern", noAutoSaveBody = "Änderungen bleiben lokal, bis der grüne Haken gedrückt wird.", discardTitle = "Ungespeicherte Änderungen verwerfen?", discardBody = "Deine vorgemerkten App- oder Konfigurationsänderungen wurden noch nicht gespeichert.", keepEditing = "Weiter bearbeiten", discard = "Änderungen verwerfen", saveFailed = "Einige Änderungen konnten nicht gespeichert werden.", noSelection = "Kein Spieler ausgewählt" },
errors = { not_authorized = "Du hast keinen Zugriff auf das Admin-Panel.", rate_limited = "Zu viele Admin-Anfragen. Bitte warte.", player_unavailable = "Dieser Spieler ist nicht mehr online.", device_not_owned = "Dieses Handy gehört nicht mehr zum ausgewählten Spieler.", invalid_app = "Diese App ist nicht auf dem Server registriert.", app_protected = "Diese System-App kann nicht entfernt werden.", revision_conflict = "Das Handy wurde zwischenzeitlich geändert. Aktualisiere und versuche es erneut.", account_not_found = "Mit diesem Handy ist kein iFruit-Account verknüpft.", invalid_phone_number = "Gib eine Telefonnummer im konfigurierten Serverformat ein.", phone_number_unchanged = "Diese SIM verwendet diese Telefonnummer bereits.", phone_number_taken = "Diese Telefonnummer ist bereits vergeben.", no_sim = "Dieses Handy besitzt keine änderbare SIM.", passcode_not_set = "Für dieses Handy ist kein Gerätecode eingerichtet.", device_not_found = "Dieses Handy existiert nicht mehr.", metadata_unsupported = "Die Inventar-Metadaten des Handys konnten nicht aktualisiert werden.", invalid_request = "Die Admin-Anfrage war ungültig.", request_failed = "Die Admin-Anfrage ist fehlgeschlagen.", default = "Das Admin-Panel ist vorübergehend nicht verfügbar." }, errors = { not_authorized = "Du hast keinen Zugriff auf das Admin-Panel.", rate_limited = "Zu viele Admin-Anfragen. Bitte warte.", player_unavailable = "Dieser Spieler ist nicht mehr online.", device_not_owned = "Dieses Handy gehört nicht mehr zum ausgewählten Spieler.", invalid_app = "Diese App ist nicht auf dem Server registriert.", app_protected = "Diese System-App kann nicht entfernt werden.", revision_conflict = "Die Daten wurden zwischenzeitlich geändert. Öffne den Bereich neu und versuche es erneut.", configurator_disabled = "Aktiviere zuerst den Phone Configurator in der config.lua.", invalid_field = "Dieses Konfigurationsfeld ist nicht mehr verfügbar.", invalid_value = "Ein Konfigurationswert ist ungültig.", account_not_found = "Mit diesem Handy ist kein iFruit-Account verknüpft.", invalid_phone_number = "Gib eine Telefonnummer im konfigurierten Serverformat ein.", phone_number_unchanged = "Diese SIM verwendet diese Telefonnummer bereits.", phone_number_taken = "Diese Telefonnummer ist bereits vergeben.", no_sim = "Dieses Handy besitzt keine änderbare SIM.", passcode_not_set = "Für dieses Handy ist kein Gerätecode eingerichtet.", device_not_found = "Dieses Handy existiert nicht mehr.", metadata_unsupported = "Die Inventar-Metadaten des Handys konnten nicht aktualisiert werden.", invalid_request = "Die Admin-Anfrage war ungültig.", request_failed = "Die Admin-Anfrage ist fehlgeschlagen.", default = "Das Admin-Panel ist vorübergehend nicht verfügbar." },
}, },
Apps = { Apps = {
health = { health = {
+7 -6
View File
@@ -1,7 +1,7 @@
Locales["en"] = { Locales["en"] = {
CommandDescription = "Open your phone.", CommandDescription = "Open your phone.",
AdminCommand = { AdminCommand = {
CommandDescription = "Open the protected phone admin panel.", CommandDescription = "Open phone administration and configuration.",
Errors = { Errors = {
disabled = "The phone admin panel is disabled.", disabled = "The phone admin panel is disabled.",
not_authorized = "You do not have access to the phone admin panel.", not_authorized = "You do not have access to the phone admin panel.",
@@ -203,9 +203,10 @@ Locales["en"] = {
}, },
AdminPanel = { AdminPanel = {
name = "Phone Admin", subtitle = "Administration", navigation = "Admin navigation", refresh = "Refresh admin data", loading = "Loading protected data...", name = "Phone Admin", subtitle = "Administration", navigation = "Admin navigation", refresh = "Refresh admin data", loading = "Loading protected data...",
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit" }, tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit", configurator = "Phone configurator" },
overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", messageFeature = "Review recent SMS activity", callFeature = "Review recent call activity", moderationFeature = "Reset access, number, or device data", auditFeature = "Review sensitive admin actions" }, overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", messageFeature = "Review recent SMS activity", callFeature = "Review recent call activity", moderationFeature = "Reset access, number, or device data", auditFeature = "Review sensitive admin actions", configuratorFeature = "Manage config.lua and media.lua through SQL" },
appearance = { eyebrow = "Appearance", title = "Accent color", body = "Change the accent across the complete admin workspace.", colors = { emerald = "Emerald", blue = "Blue", violet = "Violet", orange = "Orange", red = "Red" } }, appearance = { eyebrow = "Appearance", title = "Accent color", body = "Change the accent across the complete admin workspace.", colors = { emerald = "Emerald", blue = "Blue", violet = "Violet", orange = "Orange", red = "Red" } },
configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", restartNotice = "Nothing is written automatically. Press the green check to persist all staged values. Startup-bound settings take full effect after restarting sky_phone.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved.", table = { list = "List", table = "Key table", vector = "Vector", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } },
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." }, players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" }, search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" },
detail = { character = "Character profile", data = "Player data overview", cash = "Cash", bank = "Bank", job = "Job", duty = "Duty", onDuty = "On duty", offDuty = "Off duty", identity = "Identity", playerData = "Player data", identifier = "Character identifier", birthdate = "Birthdate", grade = "Job grade", unknown = "Unknown" }, detail = { character = "Character profile", data = "Player data overview", cash = "Cash", bank = "Bank", job = "Job", duty = "Duty", onDuty = "On duty", offDuty = "Off duty", identity = "Identity", playerData = "Player data", identifier = "Character identifier", birthdate = "Birthdate", grade = "Job grade", unknown = "Unknown" },
@@ -218,9 +219,9 @@ Locales["en"] = {
dialogs = { ["reset-passcodeTitle"] = "Reset device passcode?", ["reset-passcodeBody"] = "The player can unlock this phone without the previous PIN afterward.", ["change-numberTitle"] = "Change phone number?", ["change-numberBody"] = "The new number must match the configured server number format and be unique.", ["factory-resetTitle"] = "Factory reset this phone?", ["factory-resetBody"] = "This clears local device data, app settings, security, and the linked account. This cannot be undone." }, dialogs = { ["reset-passcodeTitle"] = "Reset device passcode?", ["reset-passcodeBody"] = "The player can unlock this phone without the previous PIN afterward.", ["change-numberTitle"] = "Change phone number?", ["change-numberBody"] = "The new number must match the configured server number format and be unique.", ["factory-resetTitle"] = "Factory reset this phone?", ["factory-resetBody"] = "This clears local device data, app settings, security, and the linked account. This cannot be undone." },
confirm = { ["reset-passcode"] = "Reset passcode", ["change-number"] = "Change number", ["factory-reset"] = "Factory reset" }, confirm = { ["reset-passcode"] = "Reset passcode", ["change-number"] = "Change number", ["factory-reset"] = "Factory reset" },
}, },
audit = { eyebrow = "Accountability", title = "Audit trail", body = "Sensitive reveals and remote app changes are recorded here.", empty = "No admin actions yet", emptyBody = "Protected actions will appear here after they are performed.", by = "{actor} · target ID {target}", actions = { grant_app = "App installed", revoke_app = "App removed", reveal_account_password = "Password revealed", view_messages = "Messages viewed", view_calls = "Calls viewed", reset_passcode = "Passcode reset", change_number = "Phone number changed", factory_reset = "Phone factory reset" } }, audit = { eyebrow = "Accountability", title = "Audit trail", body = "Sensitive reveals and remote app changes are recorded here.", empty = "No admin actions yet", emptyBody = "Protected actions will appear here after they are performed.", by = "{actor} · target ID {target}", actions = { grant_app = "App installed", revoke_app = "App removed", reveal_account_password = "Password revealed", view_messages = "Messages viewed", view_calls = "Calls viewed", reset_passcode = "Passcode reset", change_number = "Phone number changed", factory_reset = "Phone factory reset", save_configuration = "Configuration saved" } },
editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Player directory", audit = "Audit log", selectPlayer = "Select a player to inspect identity, devices, credentials, and app access.", save = "Save changes", saveHint = "Apply pending changes", saved = "Changes saved.", unsaved = "Unsaved changes", close = "Close admin panel", refresh = "Refresh live data", online = "LIVE", profile = "PROFILE", financial = "FINANCIAL", device = "DEVICE", security = "SECURITY", noAutoSave = "Manual save", noAutoSaveBody = "Changes stay local until the green check is pressed.", discardTitle = "Discard unsaved changes?", discardBody = "Your staged app changes have not been saved.", keepEditing = "Keep editing", discard = "Discard changes", saveFailed = "Some changes could not be saved.", noSelection = "No player selected" }, editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Player directory", audit = "Audit log", selectPlayer = "Select a player to inspect identity, devices, credentials, and app access.", save = "Save changes", saveHint = "Apply pending changes", saved = "Changes saved.", unsaved = "Unsaved changes", close = "Close admin panel", refresh = "Refresh live data", online = "LIVE", profile = "PROFILE", financial = "FINANCIAL", device = "DEVICE", security = "SECURITY", noAutoSave = "Manual save", noAutoSaveBody = "Changes stay local until the green check is pressed.", discardTitle = "Discard unsaved changes?", discardBody = "Your staged app or configuration changes have not been saved.", keepEditing = "Keep editing", discard = "Discard changes", saveFailed = "Some changes could not be saved.", noSelection = "No player selected" },
errors = { not_authorized = "You do not have access to the admin panel.", rate_limited = "Too many admin requests. Please wait.", player_unavailable = "That player is no longer online.", device_not_owned = "That phone no longer belongs to the selected player.", invalid_app = "That app is not registered on the server.", app_protected = "This system app cannot be removed.", revision_conflict = "The phone changed in the meantime. Refresh and try again.", account_not_found = "No iFruit account is linked to this phone.", invalid_phone_number = "Enter a phone number in the configured server format.", phone_number_unchanged = "This SIM already uses that phone number.", phone_number_taken = "That phone number is already assigned.", no_sim = "This phone has no SIM that can be changed.", passcode_not_set = "This phone has no passcode configured.", device_not_found = "This phone no longer exists.", metadata_unsupported = "The phone inventory metadata could not be updated.", invalid_request = "The admin request was invalid.", request_failed = "The admin request failed.", default = "The admin panel is temporarily unavailable." }, errors = { not_authorized = "You do not have access to the admin panel.", rate_limited = "Too many admin requests. Please wait.", player_unavailable = "That player is no longer online.", device_not_owned = "That phone no longer belongs to the selected player.", invalid_app = "That app is not registered on the server.", app_protected = "This system app cannot be removed.", revision_conflict = "The data changed in the meantime. Reopen the section and try again.", configurator_disabled = "Enable the phone configurator in config.lua first.", invalid_field = "That configuration field is no longer available.", invalid_value = "A configuration value is invalid.", account_not_found = "No iFruit account is linked to this phone.", invalid_phone_number = "Enter a phone number in the configured server format.", phone_number_unchanged = "This SIM already uses that phone number.", phone_number_taken = "That phone number is already assigned.", no_sim = "This phone has no SIM that can be changed.", passcode_not_set = "This phone has no passcode configured.", device_not_found = "This phone no longer exists.", metadata_unsupported = "The phone inventory metadata could not be updated.", invalid_request = "The admin request was invalid.", request_failed = "The admin request failed.", default = "The admin panel is temporarily unavailable." },
}, },
Apps = { Apps = {
health = { health = {
+3 -2
View File
@@ -1,8 +1,9 @@
--[[ --[[
Sky Phone media configuration Sky Phone media configuration
This file is loaded on the server only. Keep API keys private and restart This file is loaded on the server only. Keep API keys private. When the
sky_phone after changing providers, upload limits, or import sources. phone configurator is enabled in config.lua, these values are first-run
defaults and the active media configuration is loaded from SQL.
]] ]]
Config.Media = { Config.Media = {
+4 -1
View File
@@ -32,8 +32,9 @@ client_scripts {
'config/config.lua', 'config/config.lua',
'config/locales/en.lua', 'config/locales/en.lua',
'config/locales/de.lua', 'config/locales/de.lua',
'source/bridge/client/framework.lua',
'source/bridge/client/callbacks.lua', 'source/bridge/client/callbacks.lua',
'source/client/phone_configurator.lua',
'source/bridge/client/framework.lua',
'source/bridge/client/housing.lua', 'source/bridge/client/housing.lua',
'source/bridge/client/housing/*.lua', 'source/bridge/client/housing/*.lua',
'source/bridge/client/calls.lua', 'source/bridge/client/calls.lua',
@@ -79,7 +80,9 @@ server_scripts {
'source/server/update_check.lua', 'source/server/update_check.lua',
'source/bridge/server/database.lua', 'source/bridge/server/database.lua',
'source/bridge/server/migrations.lua', 'source/bridge/server/migrations.lua',
'source/server/phone_configurator_schema.lua',
'source/bridge/server/callbacks.lua', 'source/bridge/server/callbacks.lua',
'source/server/phone_configurator.lua',
'source/bridge/server/framework.lua', 'source/bridge/server/framework.lua',
'source/bridge/server/frameworks/*.lua', 'source/bridge/server/frameworks/*.lua',
'source/bridge/server/housing.lua', 'source/bridge/server/housing.lua',
@@ -2,7 +2,7 @@ local callback_groups = {
account = [[login register logout devices remove-device]], account = [[login register logout devices remove-device]],
admin = [[ admin = [[
bootstrap player save-apps reveal-password activity bootstrap player save-apps reveal-password activity
reset-passcode change-number factory-reset reset-passcode change-number factory-reset configurator save-configurator
]], ]],
banking = [[overview transfer]], banking = [[overview transfer]],
billing = [[overview list detail markRead pay dispute]], billing = [[overview list detail markRead pay dispute]],
@@ -0,0 +1,53 @@
local function deserialize_value(value)
if type(value) ~= "table" then
return value
end
if value.__skyType == "vector2" then
return vector2(tonumber(value.x) or 0.0, tonumber(value.y) or 0.0)
end
if value.__skyType == "vector3" then
return vector3(tonumber(value.x) or 0.0, tonumber(value.y) or 0.0, tonumber(value.z) or 0.0)
end
if value.__skyType == "vector4" then
return vector4(
tonumber(value.x) or 0.0,
tonumber(value.y) or 0.0,
tonumber(value.z) or 0.0,
tonumber(value.w) or 0.0
)
end
local decoded = {}
for key, child in pairs(value) do
decoded[key] = deserialize_value(child)
end
return decoded
end
local function apply_runtime_config(payload)
if type(payload) ~= "table" or payload.enabled ~= true then
return
end
if type(payload.config) ~= "table" then
error("[sky_phone] Phone configurator received an invalid client configuration payload.")
end
local runtime_config = deserialize_value(payload.config)
for key, value in pairs(runtime_config) do
Config[key] = value
end
end
RegisterNetEvent("sky_phone:configurator:sync", function(payload)
apply_runtime_config(payload)
end)
local response = Bridge.Callbacks.Trigger("sky_phone:configurator:runtime", {})
if not response or not response.success or type(response.data) ~= "table" then
if Config.PhoneConfigurator.Enabled then
error("[sky_phone] Phone configurator failed to load the client runtime configuration.")
end
return
end
apply_runtime_config(response.data)
+46
View File
@@ -861,4 +861,50 @@ Bridge.Callbacks.Register("sky_phone:admin:factory-reset", function(source, data
SkyPhone.RefreshDevice(data.imei) SkyPhone.RefreshDevice(data.imei)
return { success = true, data = load_player_detail(target_source) } return { success = true, data = load_player_detail(target_source) }
end) end)
Bridge.Callbacks.Register("sky_phone:admin:configurator", function(source)
local authorized, error_response = require_admin(
source,
"configurator_read",
Config.AdminPanel.ReadRequestsPerMinute
)
if not authorized then
return error_response
end
return { success = true, data = SkyPhoneConfigurator.GetAdminData() }
end)
Bridge.Callbacks.Register("sky_phone:admin:save-configurator", function(source, data)
local authorized, error_response = require_admin(
source,
"configurator_save",
Config.AdminPanel.ActionRequestsPerMinute
)
if not authorized then
return error_response
end
if type(data) ~= "table" or type(data.changes) ~= "table" then
return { success = false, error = "invalid_request" }
end
local actor_identifier = Bridge.Framework.GetIdentifier(source)
if not actor_identifier then
return { success = false, error = "player_unavailable" }
end
local actor_name = player_name(source)
local response = SkyPhoneConfigurator.Save(
data.revision,
data.changes,
actor_identifier,
actor_name
)
if response.success then
write_audit(source, source, actor_identifier, nil, "save_configuration", {
changeCount = #data.changes,
revision = response.data.revision,
})
end
return response
end)
end) end)
+2
View File
@@ -2949,6 +2949,8 @@ local schema = {
}, },
} }
schema[#schema + 1] = SkyPhoneConfiguratorSchema
Bridge.Database.Migrate("sky_phone", schema) Bridge.Database.Migrate("sky_phone", schema)
Bridge.Database.Query([[ Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_fliptok_video_media` (`video_id`, `media_id`, `sort_order`) INSERT IGNORE INTO `sky_phone_fliptok_video_media` (`video_id`, `media_id`, `sort_order`)
@@ -0,0 +1,573 @@
SkyPhoneConfigurator = SkyPhoneConfigurator or {}
local TABLE_NAME = "sky_phone_configurator"
local CONFIG_ROW_ID = 1
local MAX_CHANGES = 1000
local MAX_PAYLOAD_BYTES = 8 * 1024 * 1024
local REDACTED_VALUE = "***REDACTED***"
local configurator_enabled = Config.PhoneConfigurator.Enabled == true
local default_config
local default_media
local stored_config
local stored_media
local revision = 1
local updated_at
local updated_by_name
local CLIENT_CONFIG_KEYS = {
AdminPanel = true,
Animations = true,
Banking = true,
Billing = true,
Bridge = true,
Calendar = true,
Calls = true,
Command = true,
CrewLink = true,
Crypto = true,
CustomApps = true,
DarkChat = true,
EasyShare = true,
Feather = true,
FlipTok = true,
Garage = true,
Health = true,
Housing = true,
LocalPages = true,
Mail = true,
MapMarkers = true,
Marketplace = true,
Memos = true,
Messages = true,
Payphones = true,
Phone = true,
Picstagram = true,
Radio = true,
Security = true,
Sim = true,
SkyRide = true,
Speaker = true,
TestData = true,
}
local function copy_value(value, active)
local value_type = type(value)
if value_type ~= "table" then
return value
end
active = active or {}
if active[value] then
error("[sky_phone] Phone configurator cannot copy a cyclic table.")
end
active[value] = true
local copy = {}
for key, child in pairs(value) do
copy[key] = copy_value(child, active)
end
active[value] = nil
return copy
end
local function serialize_value(value, active)
local value_type = type(value)
if value_type == "nil" or value_type == "boolean" or value_type == "string" then
return value
end
if value_type == "number" then
if value ~= value or value == math.huge or value == -math.huge then
error("[sky_phone] Phone configurator cannot serialize a non-finite number.")
end
return value
end
if value_type == "vector2" then
return { __skyType = "vector2", x = value.x, y = value.y }
end
if value_type == "vector3" then
return { __skyType = "vector3", x = value.x, y = value.y, z = value.z }
end
if value_type == "vector4" then
return { __skyType = "vector4", x = value.x, y = value.y, z = value.z, w = value.w }
end
if value_type ~= "table" then
error(("[sky_phone] Phone configurator cannot serialize value type '%s'."):format(value_type))
end
active = active or {}
if active[value] then
error("[sky_phone] Phone configurator cannot serialize a cyclic table.")
end
active[value] = true
local serialized = {}
for key, child in pairs(value) do
if type(key) ~= "string" and type(key) ~= "number" then
error("[sky_phone] Phone configurator only supports string and numeric table keys.")
end
serialized[key] = serialize_value(child, active)
end
active[value] = nil
return serialized
end
local function deserialize_value(value)
if type(value) ~= "table" then
return value
end
if value.__skyType == "vector2" then
return vector2(tonumber(value.x) or 0.0, tonumber(value.y) or 0.0)
end
if value.__skyType == "vector3" then
return vector3(tonumber(value.x) or 0.0, tonumber(value.y) or 0.0, tonumber(value.z) or 0.0)
end
if value.__skyType == "vector4" then
return vector4(
tonumber(value.x) or 0.0,
tonumber(value.y) or 0.0,
tonumber(value.z) or 0.0,
tonumber(value.w) or 0.0
)
end
local decoded = {}
for key, child in pairs(value) do
decoded[key] = deserialize_value(child)
end
return decoded
end
local function is_sequence(value)
if type(value) ~= "table" then
return false
end
local count = 0
local maximum = 0
for key in pairs(value) do
if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then
return false
end
count = count + 1
maximum = math.max(maximum, key)
end
return count > 0 and count == maximum
end
local function merge_values(defaults, saved)
if type(defaults) ~= "table" or type(saved) ~= "table" then
return copy_value(saved)
end
if defaults.__skyType or saved.__skyType or is_sequence(defaults) or is_sequence(saved) then
return copy_value(saved)
end
local merged = copy_value(defaults)
for key, child in pairs(saved) do
if merged[key] ~= nil then
merged[key] = merge_values(merged[key], child)
else
merged[key] = copy_value(child)
end
end
return merged
end
local function decode_payload(encoded, scope)
if type(encoded) ~= "string" or encoded == "" then
error(("[sky_phone] Phone configurator has an empty %s SQL payload."):format(scope))
end
local success, decoded = pcall(json.decode, encoded)
if not success or type(decoded) ~= "table" then
error(("[sky_phone] Phone configurator failed to decode the %s SQL payload."):format(scope))
end
return decoded
end
local function encode_payload(payload, scope)
local success, encoded = pcall(json.encode, payload)
if not success or type(encoded) ~= "string" then
error(("[sky_phone] Phone configurator failed to encode the %s SQL payload."):format(scope))
end
if #encoded > MAX_PAYLOAD_BYTES then
error(("[sky_phone] Phone configurator %s payload exceeds %s bytes."):format(scope, MAX_PAYLOAD_BYTES))
end
return encoded
end
local function affected_rows(result)
if type(result) == "number" then
return result
end
return type(result) == "table" and tonumber(result.affectedRows) or 0
end
local function apply_runtime_configuration()
if not configurator_enabled then
return
end
local runtime_config = deserialize_value(stored_config)
for key, value in pairs(runtime_config) do
Config[key] = value
end
Config.Media = deserialize_value(stored_media)
end
local function humanize(value)
local text = tostring(value or "")
:gsub("[_%-]+", " ")
:gsub("(%l)(%u)", "%1 %2")
:gsub("(%a)(%d)", "%1 %2")
return text:gsub("^%l", string.upper)
end
local function sensitive_path(path)
local leaf = tostring(path):match("([^.]+)$") or path
local normalized = leaf:lower():gsub("[^%w]", "")
if normalized:find("apikey", 1, true)
or normalized:find("secret", 1, true)
or normalized:find("pepper", 1, true)
or normalized == "password"
or normalized == "token"
or normalized == "authorization"
or normalized == "credential"
or normalized == "connectionstring"
then
return true
end
return false
end
local function mask_admin_value(value, path)
if type(value) ~= "table" then
return type(value) == "string" and sensitive_path(path) and REDACTED_VALUE or value
end
local masked = {}
for key, child in pairs(value) do
masked[key] = mask_admin_value(child, path .. "." .. tostring(key))
end
return masked
end
local function restore_redacted_values(value, current, path)
if type(value) ~= "table" then
if value == REDACTED_VALUE and sensitive_path(path) then
return current
end
return value
end
local restored = {}
for key, child in pairs(value) do
local current_child = type(current) == "table" and current[key] or nil
restored[key] = restore_redacted_values(child, current_child, path .. "." .. tostring(key))
end
return restored
end
local function add_field(fields, field_index, scope, path, value)
local value_type = type(value)
local field_type = value_type
if value_type == "table" then
field_type = "json"
elseif value_type ~= "boolean" and value_type ~= "number" and value_type ~= "string" then
return
end
local sensitive = type(value) == "string" and sensitive_path(path)
local field = {
configured = sensitive and value ~= "" or nil,
label = humanize(path:match("([^.]+)$") or path),
path = path,
scope = scope,
sensitive = sensitive,
type = field_type,
value = sensitive and "" or mask_admin_value(value, path),
}
fields[#fields + 1] = field
field_index[scope .. ":" .. path] = field
end
local function flatten_fields(fields, field_index, scope, path, value)
if type(value) ~= "table" or value.__skyType or is_sequence(value) or next(value) == nil then
add_field(fields, field_index, scope, path, value)
return
end
local keys = {}
for key in pairs(value) do
keys[#keys + 1] = key
end
table.sort(keys, function(left, right)
return tostring(left) < tostring(right)
end)
for _, key in ipairs(keys) do
local child_path = path == "" and tostring(key) or (path .. "." .. tostring(key))
flatten_fields(fields, field_index, scope, child_path, value[key])
end
end
local function build_sections(scope, payload, sections, field_index)
local general_fields = {}
local keys = {}
for key in pairs(payload) do
keys[#keys + 1] = key
end
table.sort(keys, function(left, right)
return tostring(left) < tostring(right)
end)
for _, key in ipairs(keys) do
local value = payload[key]
if type(value) == "table" and not value.__skyType and not is_sequence(value) and next(value) ~= nil then
local fields = {}
flatten_fields(fields, field_index, scope, tostring(key), value)
table.sort(fields, function(left, right)
return left.path < right.path
end)
sections[#sections + 1] = {
fields = fields,
id = scope .. ":" .. tostring(key),
label = humanize(key),
scope = scope,
}
else
add_field(general_fields, field_index, scope, tostring(key), value)
end
end
if #general_fields > 0 then
table.insert(sections, scope == "config" and 1 or (#sections + 1), {
fields = general_fields,
id = scope .. ":general",
label = "General",
scope = scope,
})
end
end
local function build_admin_data()
local sections = {}
local field_index = {}
build_sections("config", stored_config, sections, field_index)
build_sections("media", stored_media, sections, field_index)
return {
enabled = configurator_enabled,
revision = revision,
sections = sections,
updatedAt = updated_at,
updatedBy = updated_by_name,
}, field_index
end
local function split_path(path)
local parts = {}
for part in path:gmatch("[^.]+") do
parts[#parts + 1] = part
end
return parts
end
local function set_path(root, path, value)
local parts = split_path(path)
if #parts == 0 then
return false
end
local parent = root
for index = 1, #parts - 1 do
parent = parent[parts[index]]
if type(parent) ~= "table" then
return false
end
end
parent[parts[#parts]] = value
return true
end
local function get_path(root, path)
local value = root
for _, part in ipairs(split_path(path)) do
if type(value) ~= "table" then
return nil
end
value = value[part]
end
return value
end
local function normalize_change_value(field, value)
if field.type == "boolean" then
return type(value) == "boolean" and value or nil
end
if field.type == "number" then
if type(value) ~= "number" or value ~= value or value == math.huge or value == -math.huge then
return nil
end
return value
end
if field.type == "string" then
if type(value) ~= "string" or #value > 65535 or value == REDACTED_VALUE then
return nil
end
return value
end
if field.type == "json" and type(value) == "table" then
return serialize_value(value)
end
return nil
end
local function client_payload()
local payload = {}
for key in pairs(CLIENT_CONFIG_KEYS) do
if stored_config[key] ~= nil then
payload[key] = copy_value(stored_config[key])
end
end
return payload
end
default_config = {}
for key, value in pairs(Config) do
if key ~= "Media" and key ~= "PhoneConfigurator" then
default_config[key] = serialize_value(value)
end
end
default_media = serialize_value(Config.Media)
Bridge.Database.Migrate("sky_phone_configurator", { SkyPhoneConfiguratorSchema })
Bridge.Database.Query(([[
INSERT IGNORE INTO `%s` (`id`, `config_payload`, `media_payload`, `revision`)
VALUES (?, ?, ?, 1)
]]):format(TABLE_NAME), {
CONFIG_ROW_ID,
encode_payload(default_config, "config"),
encode_payload(default_media, "media"),
})
local rows = Bridge.Database.Query(([[
SELECT `config_payload`, `media_payload`, `revision`, `updated_at`, `updated_by_name`
FROM `%s`
WHERE `id` = ?
LIMIT 1
]]):format(TABLE_NAME), { CONFIG_ROW_ID })
local row = rows[1]
if not row then
error("[sky_phone] Phone configurator could not load its SQL row after initialization.")
end
stored_config = merge_values(default_config, decode_payload(row.config_payload, "config"))
stored_media = merge_values(default_media, decode_payload(row.media_payload, "media"))
revision = tonumber(row.revision) or 1
updated_at = row.updated_at
updated_by_name = row.updated_by_name
apply_runtime_configuration()
function SkyPhoneConfigurator.GetAdminData()
local data = build_admin_data()
return data
end
function SkyPhoneConfigurator.Save(expected_revision, changes, actor_identifier, actor_name)
if not configurator_enabled then
return { success = false, error = "configurator_disabled" }
end
if tonumber(expected_revision) ~= revision then
return { success = false, error = "revision_conflict", data = SkyPhoneConfigurator.GetAdminData() }
end
if type(changes) ~= "table" or #changes < 1 or #changes > MAX_CHANGES then
return { success = false, error = "invalid_request" }
end
local _, field_index = build_admin_data()
local next_config = copy_value(stored_config)
local next_media = copy_value(stored_media)
local seen = {}
for _, change in ipairs(changes) do
if type(change) ~= "table" or type(change.scope) ~= "string" or type(change.path) ~= "string" then
return { success = false, error = "invalid_request" }
end
local field_key = change.scope .. ":" .. change.path
local field = field_index[field_key]
if not field or seen[field_key] then
return { success = false, error = "invalid_field" }
end
seen[field_key] = true
local normalized = normalize_change_value(field, change.value)
if normalized == nil then
return { success = false, error = "invalid_value" }
end
local target = change.scope == "config" and next_config or next_media
if change.scope ~= "config" and change.scope ~= "media" then
return { success = false, error = "invalid_field" }
end
if field.type == "json" then
normalized = restore_redacted_values(normalized, get_path(target, change.path), change.path)
end
if not set_path(target, change.path, normalized) then
return { success = false, error = "invalid_field" }
end
end
local config_encoded = encode_payload(next_config, "config")
local media_encoded = encode_payload(next_media, "media")
local result = Bridge.Database.Query(([[
UPDATE `%s`
SET `config_payload` = ?, `media_payload` = ?, `revision` = `revision` + 1,
`updated_by_identifier` = ?, `updated_by_name` = ?
WHERE `id` = ? AND `revision` = ?
]]):format(TABLE_NAME), {
config_encoded,
media_encoded,
tostring(actor_identifier or ""):sub(1, 80),
tostring(actor_name or ""):sub(1, 120),
CONFIG_ROW_ID,
revision,
})
if affected_rows(result) ~= 1 then
local latest = Bridge.Database.Query(("SELECT `revision` FROM `%s` WHERE `id` = ? LIMIT 1"):format(TABLE_NAME), {
CONFIG_ROW_ID,
})
revision = latest[1] and tonumber(latest[1].revision) or revision
return { success = false, error = "revision_conflict", data = SkyPhoneConfigurator.GetAdminData() }
end
stored_config = next_config
stored_media = next_media
revision = revision + 1
updated_at = os.date("!%Y-%m-%d %H:%M:%S")
updated_by_name = tostring(actor_name or ""):sub(1, 120)
apply_runtime_configuration()
TriggerClientEvent("sky_phone:configurator:sync", -1, {
config = client_payload(),
enabled = true,
revision = revision,
})
return { success = true, data = SkyPhoneConfigurator.GetAdminData() }
end
Bridge.Callbacks.Register("sky_phone:configurator:runtime", function()
return {
success = true,
data = {
config = configurator_enabled and client_payload() or {},
enabled = configurator_enabled,
revision = revision,
},
}
end)
Bridge.Debug(
"info",
"[sky_phone] Phone configurator initialized enabled=%s revision=%s.",
tostring(configurator_enabled),
tostring(revision)
)
@@ -0,0 +1,23 @@
SkyPhoneConfiguratorSchema = {
name = "sky_phone_configurator",
columns = {
{ name = "id", type = "TINYINT UNSIGNED NOT NULL" },
{ name = "config_payload", type = "LONGTEXT NOT NULL" },
{ name = "media_payload", type = "LONGTEXT NOT NULL" },
{ name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" },
{
name = "updated_by_identifier",
type = "VARCHAR(80) NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "updated_by_name", type = "VARCHAR(120) NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{
name = "updated_at",
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
},
},
primaryKey = "id",
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
}
+12
View File
@@ -200,6 +200,18 @@ CREATE TABLE IF NOT EXISTS `sky_phone_admin_audit` (
KEY `idx_sky_phone_admin_audit_target` (`target_identifier`, `created_at`) KEY `idx_sky_phone_admin_audit_target` (`target_identifier`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_configurator` (
`id` TINYINT UNSIGNED NOT NULL,
`config_payload` LONGTEXT NOT NULL,
`media_payload` LONGTEXT NOT NULL,
`revision` INT UNSIGNED NOT NULL DEFAULT 1,
`updated_by_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NULL,
`updated_by_name` VARCHAR(120) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_notes` ( CREATE TABLE IF NOT EXISTS `sky_phone_notes` (
`id` VARCHAR(64) NOT NULL, `id` VARCHAR(64) NOT NULL,
`account_id` BIGINT UNSIGNED NULL, `account_id` BIGINT UNSIGNED NULL,