FIX - expose complete phone configuration

This commit is contained in:
Leon.Schmidt
2026-08-21 02:01:39 +02:00
parent 15e32799d4
commit d8a7721917
13 changed files with 1620 additions and 57 deletions
+11 -1
View File
@@ -227,6 +227,13 @@ modules initialize, and exposes the editor through `/phonepanel`. Nothing autosa
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.
Every `Config.*` value from `config.lua`, including server-only sections, and every value from
`Config.Media` is discovered automatically. The bootstrap switch above intentionally remains
file-owned because it decides whether SQL configuration is loaded. Lists, nested objects, vectors,
and numeric-keyed Lua tables use structured editors instead of raw JSON. Shipped schema rows stay
editable but cannot be renamed, converted, or removed. Every list and table still accepts any number
of additional rows; administrator-added rows remain removable.
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.
@@ -553,7 +560,10 @@ Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_prop
### Companies
Company jobs, public profiles, service numbers, services, permissions, locations, and default availability are configured under `Config.Companies.Definitions`.
Company jobs, public profiles, service numbers, services, permissions, locations, and default
availability are configured under `Config.Companies.Definitions`. Definitions are not limited to the
shipped jobs: add any number of company IDs in the in-game configurator, choose `Table`, and fill the
freely configurable `Job` value in the generated full company template.
### Weazel News
@@ -2,11 +2,14 @@
import { Plus, Rows3, TableProperties, Trash2 } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import type { AdminConfiguratorStructure } from '@/types/admin'
export type AdminConfigEditorLabels = {
addField: string
addRow: string
configuredSecret: string
convertToList: string
convertToMap: string
convertToTable: string
emptyList: string
emptyTable: string
@@ -33,38 +36,97 @@ const props = withDefaults(
disabled?: boolean
labels: AdminConfigEditorLabels
modelValue: unknown
structure?: AdminConfiguratorStructure
}>(),
{ ariaLabel: '', depth: 0, disabled: false },
)
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
type ValueKind = 'boolean' | 'list' | 'number' | 'string' | 'table'
type MapKeyKind = 'number' | 'string'
type SerializedMapEntry = {
key: number | string
keyType: MapKeyKind
value: unknown
}
const newArrayKind = ref<ValueKind>('string')
const newObjectKey = ref('')
const newObjectKind = ref<ValueKind>('string')
const newMapKey = ref('')
const newMapKeyKind = ref<MapKeyKind>('string')
const newMapValueKind = ref<ValueKind>('string')
const isList = computed(() => Array.isArray(props.modelValue))
const isList = computed(
() =>
props.structure?.kind === 'list' ||
(props.structure?.kind !== 'map' &&
props.structure?.kind !== 'table' &&
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),
props.structure?.kind === 'map' ||
props.structure?.kind === 'table' ||
props.structure?.kind === 'vector' ||
(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>) : {},
isTable.value && !Array.isArray(props.modelValue)
? (props.modelValue as Record<string, unknown>)
: {},
)
const vectorType = computed(() => {
const value = tableValue.value.__skyType
return typeof value === 'string' && /^vector[234]$/.test(value) ? value : ''
})
const mapType = computed(
() => props.structure?.kind === 'map' || tableValue.value.__skyType === 'map',
)
const mapEntries = computed<SerializedMapEntry[]>(() => {
const entries = tableValue.value.entries
if (!mapType.value || !Array.isArray(entries)) return []
return entries.filter(
(entry): entry is SerializedMapEntry =>
entry !== null &&
typeof entry === 'object' &&
(entry.keyType === 'number' || entry.keyType === 'string'),
)
})
const listStructure = computed(() =>
props.structure?.kind === 'list' ? props.structure : null,
)
const tableStructure = computed(() =>
props.structure?.kind === 'table' ? props.structure : null,
)
const mapStructure = computed(() =>
props.structure?.kind === 'map' ? props.structure : null,
)
const tableEntries = computed(() =>
Object.entries(tableValue.value).filter(([key]) => key !== '__skyType'),
Object.entries(tableValue.value).filter(
([key]) => key !== '__skyType' && (!mapType.value || key !== 'entries'),
),
)
const isMaskedSecret = computed(() => props.modelValue === '***REDACTED***')
const parsedNewMapKey = computed(() => {
const key = newMapKey.value.trim()
if (!key) return null
if (newMapKeyKind.value === 'string') return key
const numeric = Number(key)
return Number.isFinite(numeric) ? numeric : null
})
const canAddMapEntry = computed(() => {
const key = parsedNewMapKey.value
if (key === null) return false
return !mapEntries.value.some(
(entry) => entry.keyType === newMapKeyKind.value && entry.key === key,
)
})
function blankValue(kind: ValueKind): unknown {
if (kind === 'boolean') return false
@@ -88,6 +150,19 @@ function blankLike(value: unknown): unknown {
return ''
}
function blankCollectionValue(kind: ValueKind, siblings: unknown[]): unknown {
const template = siblings.find((value) => {
if (kind === 'list') return Array.isArray(value)
if (kind === 'table') {
return (
value !== null && typeof value === 'object' && !Array.isArray(value)
)
}
return typeof value === kind
})
return template === undefined ? blankValue(kind) : blankLike(template)
}
function updateScalar(event: Event): void {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
@@ -106,6 +181,19 @@ function updateBoolean(event: Event): void {
}
}
function updateOptionalString(event: Event): void {
const target = event.target
if (target instanceof HTMLInputElement) {
emit('update:modelValue', target.value)
}
}
function toggleOptionalString(event: Event): void {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
emit('update:modelValue', target.checked ? '' : false)
}
function addListRow(): void {
const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : []
rows.push(rows.length ? blankLike(rows[0]) : blankValue(newArrayKind.value))
@@ -119,6 +207,7 @@ function updateListRow(index: number, value: unknown): void {
}
function removeListRow(index: number): void {
if (listStructure.value?.items[index]) return
const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : []
rows.splice(index, 1)
emit('update:modelValue', rows)
@@ -129,6 +218,11 @@ function updateTableField(key: string, value: unknown): void {
}
function removeTableField(key: string): void {
if (
tableStructure.value &&
Object.prototype.hasOwnProperty.call(tableStructure.value.fields, key)
)
return
const next = { ...tableValue.value }
delete next[key]
emit('update:modelValue', next)
@@ -136,14 +230,104 @@ function removeTableField(key: string): void {
function addTableField(): void {
const key = newObjectKey.value.trim()
if (!key || Object.prototype.hasOwnProperty.call(tableValue.value, key))
if (
!key ||
key === '__skyType' ||
Object.prototype.hasOwnProperty.call(tableValue.value, key)
)
return
emit('update:modelValue', {
...tableValue.value,
[key]: blankValue(newObjectKind.value),
[key]: blankCollectionValue(
newObjectKind.value,
Object.values(tableValue.value).filter((_, index) => index < 50),
),
})
newObjectKey.value = ''
}
function emitMap(entries: SerializedMapEntry[]): void {
emit('update:modelValue', { __skyType: 'map', entries })
}
function convertTableToMap(): void {
emitMap(
Object.entries(tableValue.value)
.filter(([key]) => key !== '__skyType')
.map(([key, value]) => ({ key, keyType: 'string', value })),
)
}
function updateMapKey(index: number, event: Event): void {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
const current = mapEntries.value[index]
if (!current) return
if (mapEntryStructure(current)) {
target.value = String(current.key)
return
}
const key =
current.keyType === 'number' ? Number(target.value) : target.value.trim()
if (
key === '' ||
(typeof key === 'number' && !Number.isFinite(key)) ||
mapEntries.value.some(
(entry, candidateIndex) =>
candidateIndex !== index &&
entry.keyType === current.keyType &&
entry.key === key,
)
) {
target.value = String(current.key)
return
}
const entries = mapEntries.value.map((entry, candidateIndex) =>
candidateIndex === index ? { ...entry, key } : entry,
)
emitMap(entries)
}
function updateMapValue(index: number, value: unknown): void {
emitMap(
mapEntries.value.map((entry, candidateIndex) =>
candidateIndex === index ? { ...entry, value } : entry,
),
)
}
function removeMapEntry(index: number): void {
const entry = mapEntries.value[index]
if (!entry || mapEntryStructure(entry)) return
emitMap(
mapEntries.value.filter((_, candidateIndex) => candidateIndex !== index),
)
}
function addMapEntry(): void {
if (!canAddMapEntry.value || parsedNewMapKey.value === null) return
emitMap([
...mapEntries.value,
{
key: parsedNewMapKey.value,
keyType: newMapKeyKind.value,
value: blankCollectionValue(
newMapValueKind.value,
mapEntries.value.slice(0, 50).map((entry) => entry.value),
),
},
])
newMapKey.value = ''
}
function mapEntryStructure(
entry: SerializedMapEntry,
): AdminConfiguratorStructure | undefined {
return mapStructure.value?.entries.find(
(candidate) =>
candidate.keyType === entry.keyType && candidate.key === entry.key,
)?.structure
}
</script>
<template>
@@ -166,7 +350,7 @@ function addTableField(): void {
<option value="table">{{ labels.types.table }}</option>
</select>
<button
v-if="!listValue.length"
v-if="!listValue.length && !structure"
type="button"
:disabled="disabled"
:title="labels.convertToTable"
@@ -189,6 +373,7 @@ function addTableField(): void {
<span class="config-structured-editor__index">{{ index + 1 }}</span>
<AdminConfigValueEditor
:model-value="row"
:structure="listStructure?.items[index]"
:aria-label="`${ariaLabel} ${index + 1}`"
:labels="labels"
:disabled="disabled"
@@ -196,6 +381,7 @@ function addTableField(): void {
@update:model-value="updateListRow(index, $event)"
/>
<button
v-if="!listStructure?.items[index]"
type="button"
class="config-structured-editor__remove"
:disabled="disabled"
@@ -224,7 +410,16 @@ function addTableField(): void {
}}
</span>
<button
v-if="!tableEntries.length && !vectorType"
v-if="!structure && !mapType && !vectorType"
type="button"
:disabled="disabled"
:title="labels.convertToMap"
@click="convertTableToMap"
>
<TableProperties :size="13" />{{ labels.convertToMap }}
</button>
<button
v-if="!structure && !mapType && !tableEntries.length && !vectorType"
type="button"
:disabled="disabled"
:title="labels.convertToList"
@@ -234,8 +429,50 @@ function addTableField(): void {
</button>
</header>
<div v-if="mapType" class="config-structured-editor__properties is-map">
<div
v-for="(entry, index) in mapEntries"
:key="`${entry.keyType}:${entry.key}`"
class="config-structured-editor__property is-map"
>
<span class="config-structured-editor__index">{{ index + 1 }}</span>
<span class="config-structured-editor__map-key">
<small>{{ labels.types[entry.keyType] }}</small>
<input
:type="entry.keyType === 'number' ? 'number' : 'text'"
:value="entry.key"
:aria-label="`${ariaLabel} ${labels.types[entry.keyType]} ${index + 1}`"
:disabled="disabled || Boolean(mapEntryStructure(entry))"
@change="updateMapKey(index, $event)"
/>
</span>
<AdminConfigValueEditor
:model-value="entry.value"
:structure="mapEntryStructure(entry)"
:aria-label="`${ariaLabel} ${entry.key}`"
:labels="labels"
:disabled="disabled"
:depth="depth + 1"
@update:model-value="updateMapValue(index, $event)"
/>
<button
v-if="!mapEntryStructure(entry)"
type="button"
class="config-structured-editor__remove"
:disabled="disabled"
:title="labels.remove"
@click="removeMapEntry(index)"
>
<Trash2 :size="13" />
</button>
</div>
<div v-if="!mapEntries.length" class="config-structured-editor__empty">
{{ labels.emptyTable }}
</div>
</div>
<div
v-if="tableEntries.length"
v-else-if="tableEntries.length"
class="config-structured-editor__properties"
>
<div
@@ -247,6 +484,7 @@ function addTableField(): void {
<strong>{{ key }}</strong>
<AdminConfigValueEditor
:model-value="value"
:structure="tableStructure?.fields[key]"
:aria-label="`${ariaLabel} ${key}`"
:labels="labels"
:disabled="disabled"
@@ -254,7 +492,13 @@ function addTableField(): void {
@update:model-value="updateTableField(key, $event)"
/>
<button
v-if="!vectorType"
v-if="
!vectorType &&
!Object.prototype.hasOwnProperty.call(
tableStructure?.fields ?? {},
key,
)
"
type="button"
class="config-structured-editor__remove"
:disabled="disabled"
@@ -270,7 +514,35 @@ function addTableField(): void {
</div>
<form
v-if="!vectorType"
v-if="mapType"
class="config-structured-editor__add-field is-map"
@submit.prevent="addMapEntry"
>
<select v-model="newMapKeyKind" :disabled="disabled">
<option value="string">{{ labels.types.string }}</option>
<option value="number">{{ labels.types.number }}</option>
</select>
<input
v-model="newMapKey"
:type="newMapKeyKind === 'number' ? 'number' : 'text'"
:disabled="disabled"
:placeholder="labels.keyPlaceholder"
:aria-label="labels.keyPlaceholder"
/>
<select v-model="newMapValueKind" :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 || !canAddMapEntry">
<Plus :size="13" />{{ labels.addField }}
</button>
</form>
<form
v-else-if="!vectorType"
class="config-structured-editor__add-field"
@submit.prevent="addTableField"
>
@@ -279,6 +551,7 @@ function addTableField(): void {
type="text"
:disabled="disabled"
:placeholder="labels.keyPlaceholder"
:aria-label="labels.keyPlaceholder"
/>
<select v-model="newObjectKind" :disabled="disabled">
<option value="string">{{ labels.types.string }}</option>
@@ -293,6 +566,31 @@ function addTableField(): void {
</form>
</div>
<span
v-else-if="structure?.kind === 'optionalString'"
class="config-value-optional"
>
<input
class="config-value-input"
type="text"
:aria-label="ariaLabel"
:value="modelValue === false ? '' : String(modelValue ?? '')"
:disabled="disabled || modelValue === false"
autocomplete="off"
@input="updateOptionalString"
/>
<label class="config-value-toggle">
<input
type="checkbox"
:aria-label="ariaLabel"
:checked="modelValue !== false"
:disabled="disabled"
@change="toggleOptionalString"
/>
<i></i>
</label>
</span>
<label
v-else-if="typeof modelValue === 'boolean'"
class="config-value-toggle"
@@ -426,6 +724,30 @@ function addTableField(): void {
grid-template-columns: 24px minmax(80px, 0.35fr) minmax(150px, 1fr) 27px;
}
.config-structured-editor__property.is-map {
grid-template-columns: 24px minmax(105px, 0.42fr) minmax(150px, 1fr) 27px;
}
.config-structured-editor__map-key {
min-width: 0;
display: grid;
gap: 3px;
}
.config-structured-editor__map-key small {
color: var(--admin-dim);
font-size: 7px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.config-structured-editor__map-key input {
width: 100%;
min-width: 0;
height: 25px;
padding: 0 7px;
}
.config-structured-editor__property > strong {
overflow: hidden;
color: #c9cec9;
@@ -448,6 +770,14 @@ function addTableField(): void {
color: #b66a6a;
}
.config-value-optional {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
}
.config-structured-editor__empty {
padding: 13px 10px;
color: var(--admin-dim);
@@ -463,6 +793,10 @@ function addTableField(): void {
background: #151715;
}
.config-structured-editor__add-field.is-map {
grid-template-columns: 76px minmax(90px, 1fr) 82px auto;
}
.config-structured-editor__add-field input {
min-width: 0;
height: 25px;
@@ -256,6 +256,13 @@ describe('standalone admin panel contracts', () => {
expect(configuratorServer).toContain('build_sections("media", stored_media')
expect(configuratorServer).toContain('sensitive_path')
expect(configuratorServer).toContain('restore_redacted_values')
expect(configuratorServer).toContain(
'{ __skyType = "map", entries = entries }',
)
expect(configuratorServer).toContain('validate_structured_value')
expect(configuratorServer).toContain('validate_locked_structure')
expect(configuratorServer).toContain('build_structure(default_value')
expect(configuratorServer).toContain('field.type == "stringOrFalse"')
expect(configuratorServer).not.toContain('Config.Media = client_payload')
expect(configuratorClient).toContain(
'Bridge.Callbacks.Trigger("sky_phone:configurator:runtime"',
@@ -269,6 +276,12 @@ describe('standalone admin panel contracts', () => {
expect(configuratorValueEditor).toContain('function addTableField()')
expect(configuratorValueEditor).toContain('function removeListRow(')
expect(configuratorValueEditor).toContain('function removeTableField(')
expect(configuratorValueEditor).toContain('function addMapEntry()')
expect(configuratorValueEditor).toContain('function updateMapKey(')
expect(configuratorValueEditor).toContain('mapEntryStructure(current)')
expect(configuratorValueEditor).toContain('listStructure?.items[index]')
expect(configuratorValueEditor).toContain('tableStructure?.fields[key]')
expect(configuratorValueEditor).toContain('function blankCollectionValue(')
expect(configuratorValueEditor).not.toContain('<textarea')
})
})
+149 -8
View File
@@ -48,6 +48,7 @@ import type {
AdminAuditEntry,
AdminConfiguratorChange,
AdminConfiguratorField,
AdminConfiguratorStructure,
AdminDevice,
} from '@/types/admin'
import type { LaunchablePhoneAppDefinition } from '@/types/apps'
@@ -175,6 +176,81 @@ const revealedCredential = computed(() =>
? admin.revealedCredentials[selectedDevice.value.imei]
: undefined,
)
function configuratorStructureCount(
structure: AdminConfiguratorStructure | undefined,
): number {
if (
!structure ||
structure.kind === 'value' ||
structure.kind === 'optionalString'
)
return 1
if (structure.kind === 'vector') return Number(structure.vectorType.slice(-1))
if (structure.kind === 'list') {
return Math.max(
1,
structure.items.reduce(
(total, item) => total + configuratorStructureCount(item),
0,
),
)
}
if (structure.kind === 'map') {
return Math.max(
1,
structure.entries.reduce(
(total, entry) => total + configuratorStructureCount(entry.structure),
0,
),
)
}
return Math.max(
1,
Object.values(structure.fields).reduce(
(total, field) => total + configuratorStructureCount(field),
0,
),
)
}
function configuratorSectionCount(fields: AdminConfiguratorField[]): number {
return fields.reduce(
(total, field) => total + configuratorStructureCount(field.structure),
0,
)
}
function configuratorStructureContains(
structure: AdminConfiguratorStructure | undefined,
needle: string,
): boolean {
if (
!structure ||
structure.kind === 'value' ||
structure.kind === 'optionalString'
)
return false
if (structure.kind === 'vector') return structure.vectorType.includes(needle)
if (structure.kind === 'list') {
return structure.items.some((item) =>
configuratorStructureContains(item, needle),
)
}
if (structure.kind === 'map') {
return structure.entries.some(
(entry) =>
String(entry.key).toLocaleLowerCase(phone.lang).includes(needle) ||
configuratorStructureContains(entry.structure, needle),
)
}
return Object.entries(structure.fields).some(
([key, field]) =>
key.toLocaleLowerCase(phone.lang).includes(needle) ||
configuratorStructureContains(field, needle),
)
}
const selectedMessages = computed(() =>
selectedDevice.value
? (admin.deviceActivity[selectedDevice.value.imei]?.messages ?? [])
@@ -196,13 +272,14 @@ const filteredConfiguratorSections = computed(() => {
section.fields.some(
(field) =>
field.label.toLocaleLowerCase(phone.lang).includes(needle) ||
field.path.toLocaleLowerCase(phone.lang).includes(needle),
field.path.toLocaleLowerCase(phone.lang).includes(needle) ||
configuratorStructureContains(field.structure, needle),
),
)
})
const filteredConfiguratorFieldCount = computed(() =>
filteredConfiguratorSections.value.reduce(
(total, section) => total + section.fields.length,
(total, section) => total + configuratorSectionCount(section.fields),
0,
),
)
@@ -264,6 +341,7 @@ const configuratorEditorLabels = computed<AdminConfigEditorLabels>(() => ({
addRow: t('configurator.table.addRow'),
configuredSecret: t('configurator.secretConfigured'),
convertToList: t('configurator.table.convertToList'),
convertToMap: t('configurator.table.convertToMap'),
convertToTable: t('configurator.table.convertToTable'),
emptyList: t('configurator.table.emptyList'),
emptyTable: t('configurator.table.emptyTable'),
@@ -474,6 +552,19 @@ function updateConfiguratorToggle(
}
}
function updateOptionalStringToggle(
field: AdminConfiguratorField,
event: Event,
): void {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
const current = configuratorFieldValue(field)
updateConfiguratorField(
field,
target.checked ? (current === false ? '' : String(current ?? '')) : false,
)
}
function findConfiguratorField(key: string): AdminConfiguratorField | null {
for (const section of admin.configurator?.sections ?? []) {
const field = section.fields.find(
@@ -499,6 +590,8 @@ function buildConfiguratorChanges(): AdminConfiguratorChange[] | null {
if (!value || typeof value !== 'object') return null
} else if (field.type === 'string') {
value = String(draft)
} else if (field.type === 'stringOrFalse') {
if (value !== false && typeof value !== 'string') return null
} else if (field.type === 'boolean' && typeof value !== 'boolean') {
return null
}
@@ -953,7 +1046,7 @@ onBeforeUnmount(() => {
: t('configurator.configScope')
}}</small>
</span>
<em>{{ section.fields.length }}</em>
<em>{{ configuratorSectionCount(section.fields) }}</em>
</button>
<div
v-if="!filteredConfiguratorSections.length"
@@ -1211,7 +1304,11 @@ onBeforeUnmount(() => {
</div>
<strong>{{
t('configurator.fieldCount', {
count: String(activeConfiguratorSection.fields.length),
count: String(
configuratorSectionCount(
activeConfiguratorSection.fields,
),
),
})
}}</strong>
</header>
@@ -1250,6 +1347,7 @@ onBeforeUnmount(() => {
<AdminConfigValueEditor
v-else-if="field.type === 'json'"
:model-value="configuratorFieldValue(field)"
:structure="field.structure"
:aria-label="`${field.label} ${field.path}`"
:labels="configuratorEditorLabels"
:disabled="!admin.configurator.enabled"
@@ -1258,6 +1356,37 @@ onBeforeUnmount(() => {
"
/>
<span
v-else-if="field.type === 'stringOrFalse'"
class="admin-panel-config-optional"
>
<input
type="text"
:aria-label="`${field.label} ${field.path}`"
:value="
configuratorFieldValue(field) === false
? ''
: String(configuratorFieldValue(field) ?? '')
"
:disabled="
!admin.configurator.enabled ||
configuratorFieldValue(field) === false
"
autocomplete="off"
@input="updateConfiguratorInput(field, $event)"
/>
<span class="admin-panel-config-toggle">
<input
type="checkbox"
:aria-label="`${field.label} ${field.path}`"
:checked="configuratorFieldValue(field) !== false"
:disabled="!admin.configurator.enabled"
@change="updateOptionalStringToggle(field, $event)"
/>
<i></i>
</span>
</span>
<input
v-else
:aria-label="`${field.label} ${field.path}`"
@@ -3479,7 +3608,8 @@ button:disabled {
font-size: 8px;
}
.admin-panel-config-field > input {
.admin-panel-config-field > input,
.admin-panel-config-optional > input {
width: 100%;
min-width: 0;
border: 0;
@@ -3491,19 +3621,30 @@ button:disabled {
font-size: 10px;
}
.admin-panel-config-field > input {
.admin-panel-config-field > input,
.admin-panel-config-optional > input {
height: 31px;
padding: 0 9px;
}
.admin-panel-config-field > input:focus {
.admin-panel-config-field > input:focus,
.admin-panel-config-optional > input:focus {
outline-color: color-mix(in srgb, var(--admin-green) 45%, transparent);
}
.admin-panel-config-field > input:disabled {
.admin-panel-config-field > input:disabled,
.admin-panel-config-optional > input:disabled {
opacity: 0.45;
}
.admin-panel-config-optional {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 32px;
align-items: center;
gap: 8px;
}
.admin-panel-config-toggle {
position: relative;
justify-self: end;
+1
View File
@@ -890,6 +890,7 @@ const adminPanelFallbackLocales = {
emptyTable: 'No fields yet. Add the first key below.',
keyPlaceholder: 'New key',
convertToList: 'Use list',
convertToMap: 'Use typed key table',
convertToTable: 'Use key table',
types: {
string: 'Text',
+31 -1
View File
@@ -116,12 +116,42 @@ export type AdminConfiguratorField = {
configured?: boolean
label: string
path: string
structure?: AdminConfiguratorStructure
scope: 'config' | 'media'
sensitive: boolean
type: 'boolean' | 'json' | 'number' | 'string'
type: 'boolean' | 'json' | 'number' | 'string' | 'stringOrFalse'
value: unknown
}
export type AdminConfiguratorStructure =
| {
kind: 'list'
items: AdminConfiguratorStructure[]
}
| {
fields: Record<string, AdminConfiguratorStructure>
kind: 'table'
}
| {
entries: Array<{
key: number | string
keyType: 'number' | 'string'
structure: AdminConfiguratorStructure
}>
kind: 'map'
}
| {
kind: 'value'
valueType: 'boolean' | 'number' | 'string'
}
| {
kind: 'optionalString'
}
| {
kind: 'vector'
vectorType: 'vector2' | 'vector3' | 'vector4'
}
export type AdminConfiguratorSection = {
fields: AdminConfiguratorField[]
id: string
@@ -0,0 +1,531 @@
const { readFileSync } = require('node:fs')
const { resolve } = require('node:path')
class LuaTable {
constructor() {
this.entries = []
this.nextArrayIndex = 1
}
get(key) {
return this.entries.find((entry) => entry.key === key)?.value
}
set(key, value) {
const entry = this.entries.find((candidate) => candidate.key === key)
if (entry) entry.value = value
else this.entries.push({ key, value })
}
}
function tokenize(source) {
const tokens = []
let index = 0
while (index < source.length) {
const character = source[index]
if (/\s/.test(character)) {
index += 1
continue
}
if (source.startsWith('--[[', index)) {
const end = source.indexOf(']]', index + 4)
index = end < 0 ? source.length : end + 2
continue
}
if (source.startsWith('--', index)) {
const end = source.indexOf('\n', index + 2)
index = end < 0 ? source.length : end + 1
continue
}
if (character === '"' || character === "'") {
const quote = character
let value = ''
index += 1
while (index < source.length && source[index] !== quote) {
if (source[index] !== '\\') {
value += source[index]
index += 1
continue
}
index += 1
const escaped = source[index]
const escapeValues = {
a: '\u0007',
b: '\b',
f: '\f',
n: '\n',
r: '\r',
t: '\t',
v: '\u000b',
}
value += escapeValues[escaped] ?? escaped
index += 1
}
if (source[index] !== quote)
throw new Error('Unterminated Lua string in configurator fixture.')
index += 1
tokens.push({ type: 'string', value })
continue
}
if (/[A-Za-z_]/.test(character)) {
const match = source.slice(index).match(/^[A-Za-z_][A-Za-z0-9_]*/)
tokens.push({ type: 'name', value: match[0] })
index += match[0].length
continue
}
if (
/\d/.test(character) ||
(character === '.' && /\d/.test(source[index + 1]))
) {
const match = source
.slice(index)
.match(/^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/)
tokens.push({ type: 'number', value: Number(match[0]) })
index += match[0].length
continue
}
tokens.push({ type: 'symbol', value: character })
index += 1
}
return tokens
}
class LuaConfigParser {
constructor(source) {
this.tokens = tokenize(source)
this.index = 0
this.config = new LuaTable()
}
current(offset = 0) {
return this.tokens[this.index + offset]
}
matches(value, offset = 0) {
return this.current(offset)?.value === value
}
take(value) {
const token = this.current()
if (!token || (value !== undefined && token.value !== value)) {
throw new Error(
`Expected '${value}', received '${token?.value ?? 'end of file'}' in configurator fixture.`,
)
}
this.index += 1
return token
}
parse() {
while (this.current()) {
if (this.matches('Config')) {
const start = this.index
const path = this.parsePath()
if (path.length && this.matches('=')) {
this.take('=')
this.setPath(path, this.parseExpression())
continue
}
this.index = start + 1
continue
}
this.index += 1
}
return this.config
}
parsePath() {
this.take('Config')
const path = []
while (this.matches('.') && this.current(1)?.type === 'name') {
this.take('.')
path.push(this.take().value)
}
return path
}
getPath(path) {
let value = this.config
for (const key of path) {
if (!(value instanceof LuaTable)) return undefined
value = value.get(key)
}
return value
}
setPath(path, value) {
let parent = this.config
for (let index = 0; index < path.length - 1; index += 1) {
const key = path[index]
let child = parent.get(key)
if (!(child instanceof LuaTable)) {
child = new LuaTable()
parent.set(key, child)
}
parent = child
}
parent.set(path.at(-1), value)
}
parseExpression() {
return this.parseOr()
}
parseOr() {
let value = this.parseAdditive()
while (this.matches('or')) {
this.take('or')
const fallback = this.parseAdditive()
value =
value !== false && value !== null && value !== undefined
? value
: fallback
}
return value
}
parseAdditive() {
let value = this.parseMultiplicative()
while (this.matches('+') || this.matches('-')) {
const operator = this.take().value
const right = this.parseMultiplicative()
value =
operator === '+'
? Number(value) + Number(right)
: Number(value) - Number(right)
}
return value
}
parseMultiplicative() {
let value = this.parseUnary()
while (this.matches('*') || this.matches('/')) {
const operator = this.take().value
const right = this.parseUnary()
value =
operator === '*'
? Number(value) * Number(right)
: Number(value) / Number(right)
}
return value
}
parseUnary() {
if (this.matches('-')) {
this.take('-')
return -Number(this.parseUnary())
}
return this.parsePrimary()
}
parsePrimary() {
const token = this.current()
if (!token)
throw new Error(
'Unexpected end of Lua configuration in configurator fixture.',
)
if (token.type === 'number' || token.type === 'string') {
this.index += 1
return token.value
}
if (this.matches('true') || this.matches('false') || this.matches('nil')) {
this.index += 1
return token.value === 'true'
? true
: token.value === 'false'
? false
: null
}
if (this.matches('{')) return this.parseTable()
if (this.matches('(')) {
this.take('(')
const value = this.parseExpression()
this.take(')')
return value
}
if (this.matches('Config')) return this.getPath(this.parsePath())
if (token.type === 'name' && this.current(1)?.value === '(') {
const name = this.take().value
this.take('(')
const argumentsList = []
while (!this.matches(')')) {
argumentsList.push(this.parseExpression())
if (!this.matches(',')) break
this.take(',')
}
this.take(')')
if (/^vector[234]$/.test(name)) {
const axes = ['x', 'y', 'z', 'w']
return Object.fromEntries([
['__skyType', name],
...argumentsList.map((value, index) => [axes[index], value]),
])
}
throw new Error(`Unsupported Lua call '${name}' in configurator fixture.`)
}
throw new Error(
`Unsupported Lua token '${token.value}' in configurator fixture.`,
)
}
parseTable() {
const table = new LuaTable()
this.take('{')
while (!this.matches('}')) {
if (this.matches('[')) {
this.take('[')
const key = this.parseExpression()
this.take(']')
this.take('=')
table.set(key, this.parseExpression())
} else if (
this.current()?.type === 'name' &&
this.current(1)?.value === '='
) {
const key = this.take().value
this.take('=')
table.set(key, this.parseExpression())
} else {
table.set(table.nextArrayIndex, this.parseExpression())
table.nextArrayIndex += 1
}
if (this.matches(',') || this.matches(';')) this.index += 1
else if (!this.matches('}')) {
throw new Error(
`Expected a Lua table separator, received '${this.current()?.value ?? 'end of file'}'.`,
)
}
}
this.take('}')
return table
}
}
function serializeLuaValue(value) {
if (!(value instanceof LuaTable)) {
if (Array.isArray(value)) return value.map(serializeLuaValue)
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
serializeLuaValue(child),
]),
)
}
return value
}
const numericKeys = value.entries
.filter((entry) => typeof entry.key === 'number')
.map((entry) => entry.key)
.sort((left, right) => left - right)
const isSequence =
numericKeys.length === value.entries.length &&
numericKeys.every((key, index) => key === index + 1)
if (isSequence) {
return [...value.entries]
.sort((left, right) => left.key - right.key)
.map((entry) => serializeLuaValue(entry.value))
}
if (value.entries.every((entry) => typeof entry.key === 'string')) {
return Object.fromEntries(
value.entries.map((entry) => [entry.key, serializeLuaValue(entry.value)]),
)
}
return {
__skyType: 'map',
entries: value.entries.map((entry) => ({
key: entry.key,
keyType: typeof entry.key,
value: serializeLuaValue(entry.value),
})),
}
}
function humanize(value) {
return String(value ?? '')
.replace(/[_-]+/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/([A-Za-z])(\d)/g, '$1 $2')
.replace(/^./, (character) => character.toUpperCase())
}
function sensitivePath(path) {
const leaf = path.split('.').at(-1) ?? path
const normalized = leaf.toLowerCase().replace(/[^a-z0-9]/g, '')
return (
normalized.includes('apikey') ||
normalized.includes('secret') ||
normalized.includes('pepper') ||
[
'password',
'token',
'authorization',
'credential',
'connectionstring',
].includes(normalized)
)
}
function maskValue(value, path) {
if (Array.isArray(value))
return value.map((child, index) => maskValue(child, `${path}.${index + 1}`))
if (value?.__skyType === 'map' && Array.isArray(value.entries)) {
return {
__skyType: 'map',
entries: value.entries.map((entry) => ({
key: entry.key,
keyType: entry.keyType,
value: maskValue(entry.value, `${path}.${entry.key}`),
})),
}
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
maskValue(child, `${path}.${key}`),
]),
)
}
return typeof value === 'string' && sensitivePath(path)
? '***REDACTED***'
: value
}
function buildStructure(value, scope, path) {
if (scope === 'config' && path === 'Phone.Keybind') {
return { kind: 'optionalString' }
}
if (
scope === 'config' &&
path === 'Garage.VehicleImages.ModelNames' &&
Array.isArray(value) &&
value.length === 0
) {
return { kind: 'map', entries: [] }
}
if (Array.isArray(value)) {
return {
kind: 'list',
items: value.map((child, index) =>
buildStructure(child, scope, `${path}.${index + 1}`),
),
}
}
if (value?.__skyType === 'map' && Array.isArray(value.entries)) {
return {
kind: 'map',
entries: value.entries.map((entry) => ({
key: entry.key,
keyType: entry.keyType,
structure: buildStructure(entry.value, scope, `${path}.${entry.key}`),
})),
}
}
if (/^vector[234]$/.test(value?.__skyType ?? '')) {
return { kind: 'vector', vectorType: value.__skyType }
}
if (value !== null && typeof value === 'object') {
return {
kind: 'table',
fields: Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
buildStructure(child, scope, `${path}.${key}`),
]),
),
}
}
return { kind: 'value', valueType: typeof value }
}
function addField(fields, scope, path, value) {
const valueType = Array.isArray(value) ? 'json' : typeof value
const sensitive = typeof value === 'string' && sensitivePath(path)
fields.push({
configured: sensitive ? value !== '' : undefined,
label: humanize(path.split('.').at(-1)),
path,
scope,
sensitive,
structure:
value !== null && typeof value === 'object'
? buildStructure(value, scope, path)
: undefined,
type:
scope === 'config' && path === 'Phone.Keybind'
? 'stringOrFalse'
: value !== null && typeof value === 'object'
? 'json'
: valueType,
value: sensitive ? '' : maskValue(value, path),
})
}
function buildSections(scope, payload) {
const sections = []
const generalFields = []
for (const key of Object.keys(payload).sort()) {
const value = payload[key]
if (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
!value.__skyType &&
Object.keys(value).length > 0
) {
const fields = []
addField(fields, scope, key, value)
sections.push({
fields,
id: `${scope}:${key}`,
label: humanize(key),
scope,
})
} else {
addField(generalFields, scope, key, value)
}
}
if (generalFields.length) {
const general = {
fields: generalFields,
id: `${scope}:general`,
label: 'General',
scope,
}
if (scope === 'config') sections.unshift(general)
else sections.push(general)
}
return sections
}
function loadConfiguratorSections() {
const resourceRoot = resolve(__dirname, '../..')
const config = serializeLuaValue(
new LuaConfigParser(
readFileSync(
resolve(resourceRoot, 'sky_phone/config/config.lua'),
'utf8',
),
).parse(),
)
const mediaRoot = serializeLuaValue(
new LuaConfigParser(
readFileSync(resolve(resourceRoot, 'sky_phone/config/media.lua'), 'utf8'),
).parse(),
)
const media = mediaRoot.Media
delete config.PhoneConfigurator
delete config.Media
return [...buildSections('config', config), ...buildSections('media', media)]
}
module.exports = { loadConfiguratorSections }
@@ -0,0 +1,117 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
type ConfiguratorField = {
path: string
structure?: ConfiguratorStructure
type: string
value: unknown
}
type ConfiguratorStructure = {
entries?: Array<{ structure: ConfiguratorStructure }>
fields?: Record<string, ConfiguratorStructure>
items?: ConfiguratorStructure[]
kind: string
}
type ConfiguratorSection = {
fields: ConfiguratorField[]
id: string
scope: 'config' | 'media'
}
const require = createRequire(import.meta.url)
const { loadConfiguratorSections } = require('./configurator-fixture.cjs') as {
loadConfiguratorSections: () => ConfiguratorSection[]
}
const configSource = readFileSync(
new URL('../../sky_phone/config/config.lua', import.meta.url),
'utf8',
)
function countStructure(structure: ConfiguratorStructure | undefined): number {
if (!structure) return 1
if (structure.fields) {
return Object.values(structure.fields).reduce(
(total, field) => total + countStructure(field),
0,
)
}
if (structure.items) {
return structure.items.reduce(
(total, item) => total + countStructure(item),
0,
)
}
if (structure.entries) {
return structure.entries.reduce(
(total, entry) => total + countStructure(entry.structure),
0,
)
}
return 1
}
describe('admin configurator fixture', () => {
it('exposes every config.lua root through the full live preview', () => {
const sections = loadConfiguratorSections()
const fields = sections.flatMap((section) => section.fields)
const roots = [
...configSource.matchAll(/^\s{0,4}Config\.([A-Za-z0-9_]+)\s*=/gm),
]
.map((match) => match[1])
.filter((root) => root !== 'Media' && root !== 'PhoneConfigurator')
expect(sections).toHaveLength(45)
expect(
fields.reduce(
(total, field) => total + countStructure(field.structure),
0,
),
).toBeGreaterThan(700)
for (const root of new Set(roots)) {
expect(
sections.some(
(section) =>
section.id === `config:${root}` ||
section.fields.some(
(field) =>
field.path === root || field.path.startsWith(`${root}.`),
),
),
`Missing Config.${root}`,
).toBe(true)
}
})
it('preserves numeric Lua map keys and the false keybind option', () => {
const fields = loadConfiguratorSections().flatMap(
(section) => section.fields,
)
const darkChat = fields.find((field) => field.path === 'DarkChat')
const phone = fields.find((field) => field.path === 'Phone')
const companies = fields.find((field) => field.path === 'Companies')
const garage = fields.find((field) => field.path === 'Garage')
const timers = (darkChat?.value as Record<string, unknown> | undefined)
?.AllowedDisappearTimers
expect(timers).toMatchObject({
__skyType: 'map',
entries: expect.arrayContaining([
{ key: -1, keyType: 'number', value: true },
{ key: 0, keyType: 'number', value: true },
{ key: 604800, keyType: 'number', value: true },
]),
})
expect(darkChat?.structure?.fields?.AllowedDisappearTimers.kind).toBe('map')
expect(phone?.structure?.fields?.Keybind.kind).toBe('optionalString')
expect(companies?.structure?.fields?.Definitions.kind).toBe('table')
expect(
garage?.structure?.fields?.VehicleImages.fields?.ModelNames.kind,
).toBe('map')
})
})
+8 -1
View File
@@ -3,6 +3,8 @@ const { randomUUID } = require('node:crypto')
const cors = require('cors')
const express = require('express')
const { loadConfiguratorSections } = require('./configurator-fixture.cjs')
const app = express()
const port = Number(process.argv[2]) || 3001
@@ -4742,7 +4744,7 @@ function adminMockBootstrap() {
}
}
const adminMockConfigurator = {
const adminMockConfiguratorBase = {
enabled: true,
revision: 4,
sections: [
@@ -4922,6 +4924,11 @@ const adminMockConfigurator = {
updatedBy: 'Alex Morgan',
}
const adminMockConfigurator = {
...adminMockConfiguratorBase,
sections: loadConfiguratorSections(),
}
app.post('/api/:endpoint', async (request, response, next) => {
const endpoint = request.params.endpoint
const loggedBody = { ...request.body }
+1 -1
View File
@@ -206,7 +206,7 @@ Locales["de"] = {
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", 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" } },
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" } } },
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", convertToMap = "Als typisierte Schlüsseltabelle 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." },
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" },
+1 -1
View File
@@ -206,7 +206,7 @@ Locales["en"] = {
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", 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" } },
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" } } },
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", convertToMap = "Use typed key table", 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." },
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" },
@@ -17,6 +17,14 @@ local function deserialize_value(value)
tonumber(value.w) or 0.0
)
end
if value.__skyType == "map" then
local decoded = {}
for _, entry in ipairs(value.entries or {}) do
local key = entry.keyType == "number" and tonumber(entry.key) or entry.key
decoded[key] = deserialize_value(entry.value)
end
return decoded
end
local decoded = {}
for key, child in pairs(value) do
+402 -31
View File
@@ -4,6 +4,8 @@ local TABLE_NAME = "sky_phone_configurator"
local CONFIG_ROW_ID = 1
local MAX_CHANGES = 1000
local MAX_PAYLOAD_BYTES = 8 * 1024 * 1024
local MAX_STRUCTURED_DEPTH = 20
local MAX_STRUCTURED_ENTRIES = 20000
local REDACTED_VALUE = "***REDACTED***"
local configurator_enabled = Config.PhoneConfigurator.Enabled == true
local default_config
@@ -13,6 +15,7 @@ local stored_media
local revision = 1
local updated_at
local updated_by_name
local is_sequence
local CLIENT_CONFIG_KEYS = {
AdminPanel = true,
@@ -100,6 +103,40 @@ local function serialize_value(value, active)
end
active[value] = true
local has_numeric_map_key = false
if not is_sequence(value) then
for key in pairs(value) do
if type(key) == "number" then
has_numeric_map_key = true
break
end
end
end
if has_numeric_map_key then
local entries = {}
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
entries[#entries + 1] = {
key = key,
keyType = type(key),
value = serialize_value(child, active),
}
end
table.sort(entries, function(left, right)
if left.keyType ~= right.keyType then
return left.keyType < right.keyType
end
if left.keyType == "number" then
return left.key < right.key
end
return tostring(left.key) < tostring(right.key)
end)
active[value] = nil
return { __skyType = "map", entries = entries }
end
local serialized = {}
for key, child in pairs(value) do
if type(key) ~= "string" and type(key) ~= "number" then
@@ -130,6 +167,14 @@ local function deserialize_value(value)
tonumber(value.w) or 0.0
)
end
if value.__skyType == "map" then
local decoded = {}
for _, entry in ipairs(value.entries or {}) do
local key = entry.keyType == "number" and tonumber(entry.key) or entry.key
decoded[key] = deserialize_value(entry.value)
end
return decoded
end
local decoded = {}
for key, child in pairs(value) do
@@ -138,7 +183,7 @@ local function deserialize_value(value)
return decoded
end
local function is_sequence(value)
is_sequence = function(value)
if type(value) ~= "table" then
return false
end
@@ -155,11 +200,85 @@ local function is_sequence(value)
return count > 0 and count == maximum
end
local function upgrade_legacy_map(defaults, saved)
local default_types = {}
local numeric_only = true
for _, entry in ipairs(defaults.entries or {}) do
default_types[tostring(entry.key)] = entry.keyType
if entry.keyType ~= "number" then
numeric_only = false
end
end
local entries = {}
for key, child in pairs(saved) do
local key_type = default_types[tostring(key)] or (numeric_only and "number" or type(key))
local normalized_key = key_type == "number" and tonumber(key) or tostring(key)
if normalized_key == nil then
error("[sky_phone] Phone configurator could not migrate a numeric configuration key.")
end
entries[#entries + 1] = {
key = normalized_key,
keyType = key_type,
value = copy_value(child),
}
end
table.sort(entries, function(left, right)
if left.keyType ~= right.keyType then
return left.keyType < right.keyType
end
if left.keyType == "number" then
return left.key < right.key
end
return tostring(left.key) < tostring(right.key)
end)
return { __skyType = "map", entries = entries }
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
if defaults.__skyType == "map" and not saved.__skyType then
saved = upgrade_legacy_map(defaults, saved)
end
if defaults.__skyType == "map" and saved.__skyType == "map" then
local saved_entries = {}
for _, entry in ipairs(saved.entries or {}) do
saved_entries[entry.keyType .. ":" .. tostring(entry.key)] = entry
end
local entries = {}
local included = {}
for _, entry in ipairs(defaults.entries or {}) do
local identity = entry.keyType .. ":" .. tostring(entry.key)
local saved_entry = saved_entries[identity]
entries[#entries + 1] = {
key = entry.key,
keyType = entry.keyType,
value = saved_entry and merge_values(entry.value, saved_entry.value) or copy_value(entry.value),
}
included[identity] = true
end
for _, entry in ipairs(saved.entries or {}) do
local identity = entry.keyType .. ":" .. tostring(entry.key)
if not included[identity] then
entries[#entries + 1] = copy_value(entry)
end
end
return { __skyType = "map", entries = entries }
end
if defaults.__skyType or saved.__skyType then
return copy_value(saved)
end
if is_sequence(defaults) then
local merged = copy_value(saved)
for index, child in ipairs(defaults) do
merged[index] = saved[index] ~= nil and merge_values(child, saved[index]) or copy_value(child)
end
return merged
end
if is_sequence(saved) then
return copy_value(saved)
end
@@ -245,6 +364,17 @@ 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
if value.__skyType == "map" then
local entries = {}
for index, entry in ipairs(value.entries or {}) do
entries[index] = {
key = entry.key,
keyType = entry.keyType,
value = mask_admin_value(entry.value, path .. "." .. tostring(entry.key)),
}
end
return { __skyType = "map", entries = entries }
end
local masked = {}
for key, child in pairs(value) do
@@ -260,6 +390,29 @@ local function restore_redacted_values(value, current, path)
end
return value
end
if value.__skyType == "map" then
local current_entries = {}
if type(current) == "table" and current.__skyType == "map" then
for _, entry in ipairs(current.entries or {}) do
current_entries[entry.keyType .. ":" .. tostring(entry.key)] = entry.value
end
end
local entries = {}
for index, entry in ipairs(value.entries or {}) do
local identity = entry.keyType .. ":" .. tostring(entry.key)
entries[index] = {
key = entry.key,
keyType = entry.keyType,
value = restore_redacted_values(
entry.value,
current_entries[identity],
path .. "." .. tostring(entry.key)
),
}
end
return { __skyType = "map", entries = entries }
end
local restored = {}
for key, child in pairs(value) do
@@ -269,7 +422,86 @@ local function restore_redacted_values(value, current, path)
return restored
end
local function add_field(fields, field_index, scope, path, value)
local function empty_structure_kind(scope, path)
if scope ~= "config" then
return nil
end
if path == "Garage.VehicleImages.ModelNames" then
return "map"
end
if path == "FlipTok.MusicTracks" or path:match("^Companies%.Definitions%.[^.]+%.Services$") then
return "list"
end
return nil
end
local function build_structure(value, scope, path)
local value_type = type(value)
if scope == "config" and path == "Phone.Keybind" then
return { kind = "optionalString" }
end
if value_type ~= "table" then
return {
kind = "value",
valueType = value_type,
}
end
if value.__skyType == "vector2" or value.__skyType == "vector3" or value.__skyType == "vector4" then
return {
kind = "vector",
vectorType = value.__skyType,
}
end
if value.__skyType == "map" then
local entries = {}
for index, entry in ipairs(value.entries or {}) do
entries[index] = {
key = entry.key,
keyType = entry.keyType,
structure = build_structure(entry.value, scope, path .. "." .. tostring(entry.key)),
}
end
return {
entries = entries,
kind = "map",
}
end
local empty_kind = next(value) == nil and empty_structure_kind(scope, path) or nil
if empty_kind == "map" then
return {
entries = {},
kind = "map",
}
end
if empty_kind == "list" then
return {
items = {},
kind = "list",
}
end
if is_sequence(value) then
local items = {}
for index, child in ipairs(value) do
items[index] = build_structure(child, scope, path .. "." .. tostring(index))
end
return {
items = items,
kind = "list",
}
end
local fields = {}
for key, child in pairs(value) do
fields[key] = build_structure(child, scope, path .. "." .. tostring(key))
end
return {
fields = fields,
kind = "table",
}
end
local function add_field(fields, field_index, scope, path, value, default_value)
local value_type = type(value)
local field_type = value_type
if value_type == "table" then
@@ -277,6 +509,9 @@ local function add_field(fields, field_index, scope, path, value)
elseif value_type ~= "boolean" and value_type ~= "number" and value_type ~= "string" then
return
end
if scope == "config" and path == "Phone.Keybind" then
field_type = "stringOrFalse"
end
local sensitive = type(value) == "string" and sensitive_path(path)
local field = {
@@ -285,6 +520,7 @@ local function add_field(fields, field_index, scope, path, value)
path = path,
scope = scope,
sensitive = sensitive,
structure = field_type == "json" and build_structure(default_value, scope, path) or nil,
type = field_type,
value = sensitive and "" or mask_admin_value(value, path),
}
@@ -292,26 +528,7 @@ local function add_field(fields, field_index, scope, path, value)
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 function build_sections(scope, payload, defaults, sections, field_index)
local general_fields = {}
local keys = {}
for key in pairs(payload) do
@@ -325,10 +542,7 @@ local function build_sections(scope, payload, sections, field_index)
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)
add_field(fields, field_index, scope, tostring(key), value, defaults[key])
sections[#sections + 1] = {
fields = fields,
id = scope .. ":" .. tostring(key),
@@ -336,7 +550,7 @@ local function build_sections(scope, payload, sections, field_index)
scope = scope,
}
else
add_field(general_fields, field_index, scope, tostring(key), value)
add_field(general_fields, field_index, scope, tostring(key), value, defaults[key])
end
end
@@ -353,8 +567,8 @@ 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)
build_sections("config", stored_config, default_config, sections, field_index)
build_sections("media", stored_media, default_media, sections, field_index)
return {
enabled = configurator_enabled,
revision = revision,
@@ -400,6 +614,147 @@ local function get_path(root, path)
return value
end
local function validate_structured_value(value, depth, state)
local value_type = type(value)
if value_type == "string" then
return #value <= 65535
end
if value_type == "number" then
return value == value and value ~= math.huge and value ~= -math.huge
end
if value_type == "boolean" or value_type == "nil" then
return true
end
if value_type ~= "table" or depth > MAX_STRUCTURED_DEPTH then
return false
end
state.entries = state.entries + 1
if state.entries > MAX_STRUCTURED_ENTRIES then
return false
end
local sky_type = value.__skyType
if sky_type then
if sky_type == "vector2" or sky_type == "vector3" or sky_type == "vector4" then
local axes = sky_type == "vector2" and { "x", "y" }
or sky_type == "vector3" and { "x", "y", "z" }
or { "x", "y", "z", "w" }
for _, axis in ipairs(axes) do
local coordinate = value[axis]
if type(coordinate) ~= "number"
or coordinate ~= coordinate
or coordinate == math.huge
or coordinate == -math.huge
then
return false
end
end
return true
end
if sky_type ~= "map" or type(value.entries) ~= "table" then
return false
end
if next(value.entries) and not is_sequence(value.entries) then
return false
end
local seen = {}
for _, entry in ipairs(value.entries) do
if type(entry) ~= "table" or (entry.keyType ~= "string" and entry.keyType ~= "number") then
return false
end
local key = entry.key
if entry.keyType == "string" then
if type(key) ~= "string" or key == "" or #key > 255 then
return false
end
elseif type(key) ~= "number"
or key ~= key
or key == math.huge
or key == -math.huge
then
return false
end
local identity = entry.keyType .. ":" .. tostring(key)
if seen[identity] then
return false
end
seen[identity] = true
if not validate_structured_value(entry.value, depth + 1, state) then
return false
end
end
return true
end
for key, child in pairs(value) do
if type(key) ~= "string" and type(key) ~= "number" then
return false
end
if not validate_structured_value(child, depth + 1, state) then
return false
end
end
return true
end
local function validate_locked_structure(structure, value)
if type(structure) ~= "table" or type(structure.kind) ~= "string" then
return false
end
if structure.kind == "value" then
return type(value) == structure.valueType
end
if structure.kind == "optionalString" then
return value == false or type(value) == "string"
end
if structure.kind == "vector" then
return type(value) == "table" and value.__skyType == structure.vectorType
end
if structure.kind == "list" then
if type(value) ~= "table" or value.__skyType or (next(value) and not is_sequence(value)) then
return false
end
for index, item_structure in ipairs(structure.items or {}) do
if value[index] == nil or not validate_locked_structure(item_structure, value[index]) then
return false
end
end
return true
end
if structure.kind == "table" then
if type(value) ~= "table" or value.__skyType or is_sequence(value) then
return false
end
for key, field_structure in pairs(structure.fields or {}) do
if value[key] == nil or not validate_locked_structure(field_structure, value[key]) then
return false
end
end
return true
end
if structure.kind ~= "map" or type(value) ~= "table" then
return false
end
if value.__skyType ~= "map" then
return next(value) == nil and #(structure.entries or {}) == 0
end
local entries = {}
for _, entry in ipairs(value.entries or {}) do
entries[entry.keyType .. ":" .. tostring(entry.key)] = entry.value
end
for _, entry in ipairs(structure.entries or {}) do
local child = entries[entry.keyType .. ":" .. tostring(entry.key)]
if child == nil or not validate_locked_structure(entry.structure, child) then
return false
end
end
return true
end
local function normalize_change_value(field, value)
if field.type == "boolean" then
return type(value) == "boolean" and value or nil
@@ -416,8 +771,24 @@ local function normalize_change_value(field, value)
end
return value
end
if field.type == "stringOrFalse" then
if value == false then
return false
end
if type(value) ~= "string" or #value > 65535 then
return nil
end
return value
end
if field.type == "json" and type(value) == "table" then
return serialize_value(value)
if not validate_structured_value(value, 0, { entries = 0 }) then
return nil
end
local serialized = serialize_value(value)
if not validate_locked_structure(field.structure, serialized) then
return nil
end
return serialized
end
return nil
end