mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
ADD - add SQL phone configurator
This commit is contained in:
@@ -211,6 +211,25 @@ The files contain clearly separated sections for:
|
||||
|
||||
Restart `sky_phone` after changing Lua configuration.
|
||||
|
||||
### In-game phone configurator
|
||||
|
||||
Set the switch at the beginning of `config/config.lua` to use SQL-backed configuration:
|
||||
|
||||
```lua
|
||||
Config.PhoneConfigurator = {
|
||||
Enabled = true,
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, `config.lua` and the server-only `media.lua` are first-run defaults. Sky Phone creates
|
||||
the `sky_phone_configurator` table automatically, loads its saved values before framework and phone
|
||||
modules initialize, and exposes the editor through `/phonepanel`. Nothing autosaves: stage changes
|
||||
in the Phone Configurator tool and press the green check. Restart `sky_phone` after changing
|
||||
startup-bound settings such as framework, inventory, command, item, or provider configuration.
|
||||
|
||||
Media API keys and server peppers are never returned in plaintext to the NUI. Existing secrets are
|
||||
shown only as configured and are replaced only when an administrator enters a new value.
|
||||
|
||||
### Language
|
||||
|
||||
Available locales:
|
||||
|
||||
@@ -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),
|
||||
'utf8',
|
||||
)
|
||||
const manifest = readFileSync(
|
||||
new URL('../../../sky_phone/fxmanifest.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configuratorServer = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/server/phone_configurator.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const configuratorClient = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/client/phone_configurator.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const configuratorValueEditor = readFileSync(
|
||||
new URL('./AdminConfigValueEditor.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('standalone admin panel contracts', () => {
|
||||
it('renders as a dedicated full-screen editor outside the phone shell', () => {
|
||||
@@ -91,6 +113,7 @@ describe('standalone admin panel contracts', () => {
|
||||
'calls',
|
||||
'moderation',
|
||||
'audit',
|
||||
'configurator',
|
||||
]) {
|
||||
expect(source).toContain(`selectTab('${tab}')`)
|
||||
expect(source).toContain(`t('tabs.${tab}')`)
|
||||
@@ -127,6 +150,8 @@ describe('standalone admin panel contracts', () => {
|
||||
'admin:reset-passcode',
|
||||
'admin:change-number',
|
||||
'admin:factory-reset',
|
||||
'admin:configurator',
|
||||
'admin:save-configurator',
|
||||
]) {
|
||||
expect(store).toContain(endpoint)
|
||||
}
|
||||
@@ -169,7 +194,7 @@ describe('standalone admin panel contracts', () => {
|
||||
})
|
||||
|
||||
it('opens directly from the configurable command with dedicated focus', () => {
|
||||
expect(config).toContain('Command = "phoneadmin"')
|
||||
expect(config).toContain('Command = "phonepanel"')
|
||||
expect(server).toContain(
|
||||
'RegisterCommand(Config.AdminPanel.Command, function(command_source)',
|
||||
)
|
||||
@@ -203,4 +228,47 @@ describe('standalone admin panel contracts', () => {
|
||||
'CREATE TABLE IF NOT EXISTS `sky_phone_admin_audit`',
|
||||
)
|
||||
})
|
||||
|
||||
it('loads the SQL phone configurator before framework-owned configuration is read', () => {
|
||||
expect(config).toMatch(
|
||||
/Config\.PhoneConfigurator\s*=\s*\{[\s\S]*?Enabled\s*=\s*false[\s\S]*?Config\.Bridge\s*=/,
|
||||
)
|
||||
expect(schema).toContain(
|
||||
'CREATE TABLE IF NOT EXISTS `sky_phone_configurator`',
|
||||
)
|
||||
expect(
|
||||
manifest.indexOf("'source/server/phone_configurator.lua'"),
|
||||
).toBeLessThan(manifest.indexOf("'source/bridge/server/framework.lua'"))
|
||||
expect(
|
||||
manifest.indexOf("'source/client/phone_configurator.lua'"),
|
||||
).toBeLessThan(manifest.indexOf("'source/bridge/client/framework.lua'"))
|
||||
expect(configuratorServer).toContain('AND `revision` = ?')
|
||||
expect(configuratorServer).toContain('configurator_enabled')
|
||||
expect(configuratorServer).toMatch(
|
||||
/for key, value in pairs\(Config\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/,
|
||||
)
|
||||
expect(configuratorServer).toContain(
|
||||
'default_media = serialize_value(Config.Media)',
|
||||
)
|
||||
expect(configuratorServer).toContain(
|
||||
'build_sections("config", stored_config',
|
||||
)
|
||||
expect(configuratorServer).toContain('build_sections("media", stored_media')
|
||||
expect(configuratorServer).toContain('sensitive_path')
|
||||
expect(configuratorServer).toContain('restore_redacted_values')
|
||||
expect(configuratorServer).not.toContain('Config.Media = client_payload')
|
||||
expect(configuratorClient).toContain(
|
||||
'Bridge.Callbacks.Trigger("sky_phone:configurator:runtime"',
|
||||
)
|
||||
expect(source).toContain('class="admin-panel-rail__configurator"')
|
||||
expect(source).toContain(
|
||||
'.admin-panel-rail .admin-panel-rail__configurator',
|
||||
)
|
||||
expect(source).toContain('<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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Save,
|
||||
ScrollText,
|
||||
Search,
|
||||
Settings2,
|
||||
ShieldAlert,
|
||||
Smartphone,
|
||||
Trash2,
|
||||
@@ -43,13 +44,22 @@ import {
|
||||
} from '@/config/apps'
|
||||
import { useAdminStore } from '@/stores/admin'
|
||||
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 { SkyButton } from '@/ui'
|
||||
import { copyText } from '@/utils/clipboard'
|
||||
import { parseDatabaseDate } from '@/utils/date'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
import AdminConfigValueEditor, {
|
||||
type AdminConfigEditorLabels,
|
||||
} from './AdminConfigValueEditor.vue'
|
||||
|
||||
type AdminTab =
|
||||
| 'overview'
|
||||
| 'players'
|
||||
@@ -60,6 +70,7 @@ type AdminTab =
|
||||
| 'calls'
|
||||
| 'moderation'
|
||||
| 'audit'
|
||||
| 'configurator'
|
||||
type DeviceAction = 'reset-passcode' | 'change-number' | 'factory-reset'
|
||||
type PendingAction = { kind: 'close' } | { kind: 'player'; source: number }
|
||||
|
||||
@@ -69,8 +80,11 @@ const phone = usePhoneStore()
|
||||
const tab = ref<AdminTab>('overview')
|
||||
const playerQuery = ref('')
|
||||
const appQuery = ref('')
|
||||
const configuratorQuery = ref('')
|
||||
const selectedConfiguratorSection = ref('')
|
||||
const selectedImei = ref('')
|
||||
const drafts = ref<Record<string, Record<string, boolean>>>({})
|
||||
const configuratorDrafts = ref<Record<string, unknown>>({})
|
||||
const saving = ref(false)
|
||||
const revealDialogImei = ref('')
|
||||
const deviceAction = ref<DeviceAction | null>(null)
|
||||
@@ -138,12 +152,18 @@ const manageableApps = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const pendingCount = computed(() =>
|
||||
const appPendingCount = computed(() =>
|
||||
Object.values(drafts.value).reduce(
|
||||
(total, changes) => total + Object.keys(changes).length,
|
||||
0,
|
||||
),
|
||||
)
|
||||
const configuratorPendingCount = computed(
|
||||
() => Object.keys(configuratorDrafts.value).length,
|
||||
)
|
||||
const pendingCount = computed(
|
||||
() => appPendingCount.value + configuratorPendingCount.value,
|
||||
)
|
||||
const hasChanges = computed(() => pendingCount.value > 0)
|
||||
const selectedDeviceChanges = computed(() =>
|
||||
selectedDevice.value
|
||||
@@ -165,6 +185,37 @@ const selectedCalls = computed(() =>
|
||||
? (admin.deviceActivity[selectedDevice.value.imei]?.calls ?? [])
|
||||
: [],
|
||||
)
|
||||
const filteredConfiguratorSections = computed(() => {
|
||||
const sections = admin.configurator?.sections ?? []
|
||||
const needle = configuratorQuery.value.trim().toLocaleLowerCase(phone.lang)
|
||||
if (!needle) return sections
|
||||
return sections.filter(
|
||||
(section) =>
|
||||
section.label.toLocaleLowerCase(phone.lang).includes(needle) ||
|
||||
section.scope.includes(needle) ||
|
||||
section.fields.some(
|
||||
(field) =>
|
||||
field.label.toLocaleLowerCase(phone.lang).includes(needle) ||
|
||||
field.path.toLocaleLowerCase(phone.lang).includes(needle),
|
||||
),
|
||||
)
|
||||
})
|
||||
const filteredConfiguratorFieldCount = computed(() =>
|
||||
filteredConfiguratorSections.value.reduce(
|
||||
(total, section) => total + section.fields.length,
|
||||
0,
|
||||
),
|
||||
)
|
||||
const activeConfiguratorSection = computed(() => {
|
||||
const sections = filteredConfiguratorSections.value
|
||||
return (
|
||||
sections.find(
|
||||
(section) => section.id === selectedConfiguratorSection.value,
|
||||
) ??
|
||||
sections[0] ??
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => admin.selectedPlayer,
|
||||
@@ -175,6 +226,19 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => admin.configurator?.sections,
|
||||
(sections) => {
|
||||
if (
|
||||
!sections?.some(
|
||||
(section) => section.id === selectedConfiguratorSection.value,
|
||||
)
|
||||
) {
|
||||
selectedConfiguratorSection.value = sections?.[0]?.id ?? ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
[tab, selectedImei, () => admin.selectedPlayer?.source],
|
||||
([currentTab, imei, source]) => {
|
||||
@@ -195,6 +259,28 @@ function t(key: string, params?: Record<string, string>): string {
|
||||
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(
|
||||
message: string,
|
||||
tone: 'error' | 'success' = 'success',
|
||||
@@ -307,6 +393,11 @@ function queueAction(action: PendingAction): void {
|
||||
|
||||
function selectTab(nextTab: AdminTab): void {
|
||||
tab.value = nextTab
|
||||
if (nextTab === 'configurator' && !admin.configurator) {
|
||||
void admin.loadConfigurator().then((loaded) => {
|
||||
if (!loaded) showToast(errorText(), 'error')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function selectAccent(color: (typeof accentOptions)[number]['color']): void {
|
||||
@@ -327,11 +418,96 @@ async function runAction(action: PendingAction): Promise<void> {
|
||||
async function discardAndContinue(): Promise<void> {
|
||||
const action = pendingAction.value
|
||||
drafts.value = {}
|
||||
configuratorDrafts.value = {}
|
||||
pendingAction.value = null
|
||||
discardDialog.value = false
|
||||
if (action) await runAction(action)
|
||||
}
|
||||
|
||||
function configuratorFieldKey(field: AdminConfiguratorField): string {
|
||||
return `${field.scope}:${field.path}`
|
||||
}
|
||||
|
||||
function configuratorFieldValue(field: AdminConfiguratorField): unknown {
|
||||
const key = configuratorFieldKey(field)
|
||||
if (Object.prototype.hasOwnProperty.call(configuratorDrafts.value, key)) {
|
||||
return configuratorDrafts.value[key]
|
||||
}
|
||||
return field.value
|
||||
}
|
||||
|
||||
function updateConfiguratorField(
|
||||
field: AdminConfiguratorField,
|
||||
value: unknown,
|
||||
): void {
|
||||
const key = configuratorFieldKey(field)
|
||||
const matchesInitial =
|
||||
field.type === 'json'
|
||||
? JSON.stringify(value) === JSON.stringify(field.value)
|
||||
: field.type === 'number'
|
||||
? String(value) === String(field.value)
|
||||
: value === field.value
|
||||
if (!field.sensitive && matchesInitial) {
|
||||
delete configuratorDrafts.value[key]
|
||||
return
|
||||
}
|
||||
configuratorDrafts.value[key] = value
|
||||
}
|
||||
|
||||
function updateConfiguratorInput(
|
||||
field: AdminConfiguratorField,
|
||||
event: Event,
|
||||
): void {
|
||||
const target = event.target
|
||||
if (target instanceof HTMLInputElement) {
|
||||
updateConfiguratorField(field, target.value)
|
||||
}
|
||||
}
|
||||
|
||||
function updateConfiguratorToggle(
|
||||
field: AdminConfiguratorField,
|
||||
event: Event,
|
||||
): void {
|
||||
const target = event.target
|
||||
if (target instanceof HTMLInputElement) {
|
||||
updateConfiguratorField(field, target.checked)
|
||||
}
|
||||
}
|
||||
|
||||
function findConfiguratorField(key: string): AdminConfiguratorField | null {
|
||||
for (const section of admin.configurator?.sections ?? []) {
|
||||
const field = section.fields.find(
|
||||
(candidate) => configuratorFieldKey(candidate) === key,
|
||||
)
|
||||
if (field) return field
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildConfiguratorChanges(): AdminConfiguratorChange[] | null {
|
||||
const changes: AdminConfiguratorChange[] = []
|
||||
for (const [key, draft] of Object.entries(configuratorDrafts.value)) {
|
||||
const field = findConfiguratorField(key)
|
||||
if (!field) return null
|
||||
|
||||
let value: unknown = draft
|
||||
if (field.type === 'number') {
|
||||
value = Number(draft)
|
||||
if (!Number.isFinite(value)) return null
|
||||
} else if (field.type === 'json') {
|
||||
value = draft
|
||||
if (!value || typeof value !== 'object') return null
|
||||
} else if (field.type === 'string') {
|
||||
value = String(draft)
|
||||
} else if (field.type === 'boolean' && typeof value !== 'boolean') {
|
||||
return null
|
||||
}
|
||||
|
||||
changes.push({ path: field.path, scope: field.scope, value })
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
function cancelDiscard(): void {
|
||||
discardDialog.value = false
|
||||
pendingAction.value = null
|
||||
@@ -339,8 +515,35 @@ function cancelDiscard(): void {
|
||||
|
||||
async function saveChanges(): Promise<void> {
|
||||
const player = admin.selectedPlayer
|
||||
if (!player || !hasChanges.value || saving.value) return
|
||||
if (!hasChanges.value || saving.value) return
|
||||
saving.value = true
|
||||
|
||||
if (configuratorPendingCount.value) {
|
||||
const changes = buildConfiguratorChanges()
|
||||
if (!changes) {
|
||||
saving.value = false
|
||||
showToast(t('configurator.invalidValue'), 'error')
|
||||
return
|
||||
}
|
||||
const response = await admin.saveConfigurator(changes)
|
||||
if (!response.success) {
|
||||
saving.value = false
|
||||
showToast(errorText(response.error), 'error')
|
||||
return
|
||||
}
|
||||
configuratorDrafts.value = {}
|
||||
}
|
||||
|
||||
if (!appPendingCount.value) {
|
||||
saving.value = false
|
||||
showToast(t('configurator.saved'))
|
||||
return
|
||||
}
|
||||
if (!player) {
|
||||
saving.value = false
|
||||
showToast(t('editor.saveFailed'), 'error')
|
||||
return
|
||||
}
|
||||
const pendingDevices = Object.entries(drafts.value)
|
||||
for (const [imei, deviceChanges] of pendingDevices) {
|
||||
const device = admin.selectedPlayer?.devices.find(
|
||||
@@ -500,7 +703,9 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
<ChevronRight :size="14" />
|
||||
<strong>{{
|
||||
admin.selectedPlayer?.name || t('editor.noSelection')
|
||||
tab === 'configurator'
|
||||
? t('configurator.context')
|
||||
: admin.selectedPlayer?.name || t('editor.noSelection')
|
||||
}}</strong>
|
||||
</div>
|
||||
|
||||
@@ -615,6 +820,16 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<ScrollText :size="19" />
|
||||
</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>
|
||||
|
||||
<aside class="admin-panel-directory">
|
||||
@@ -691,6 +906,62 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
<ChevronRight :size="14" />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -877,6 +1148,142 @@ onBeforeUnmount(() => {
|
||||
</article>
|
||||
</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
|
||||
v-else-if="tab === 'audit'"
|
||||
class="admin-panel-editor__scroll"
|
||||
@@ -1829,6 +2236,10 @@ button:disabled {
|
||||
background: #222522;
|
||||
}
|
||||
|
||||
.admin-panel-rail .admin-panel-rail__configurator {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.admin-panel-rail button.is-active {
|
||||
color: #f4f6f4;
|
||||
background: var(--admin-nav-active);
|
||||
@@ -1987,7 +2398,8 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-panel-player-list,
|
||||
.admin-panel-audit-mini-list {
|
||||
.admin-panel-audit-mini-list,
|
||||
.admin-panel-config-sections {
|
||||
height: calc(100% - 115px);
|
||||
overflow-y: auto;
|
||||
padding: 0 8px 16px;
|
||||
@@ -1995,6 +2407,79 @@ button:disabled {
|
||||
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 {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
@@ -2829,6 +3314,245 @@ button:disabled {
|
||||
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-empty-editor__icon {
|
||||
width: 44px;
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
AdminActivityResponse,
|
||||
AdminBootstrap,
|
||||
AdminCallActivity,
|
||||
AdminConfigurator,
|
||||
AdminConfiguratorChange,
|
||||
AdminCredential,
|
||||
AdminMessageActivity,
|
||||
AdminPlayerDetail,
|
||||
@@ -20,6 +22,8 @@ export const useAdminStore = defineStore('admin', {
|
||||
actionKey: '',
|
||||
activityKey: '',
|
||||
audit: [] as AdminAuditEntry[],
|
||||
configurator: null as AdminConfigurator | null,
|
||||
configuratorLoading: false,
|
||||
detailLoading: false,
|
||||
error: '',
|
||||
initialized: false,
|
||||
@@ -134,6 +138,42 @@ export const useAdminStore = defineStore('admin', {
|
||||
this.error = ''
|
||||
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(
|
||||
source: number,
|
||||
imei: string,
|
||||
|
||||
@@ -821,6 +821,7 @@ const adminPanelFallbackLocales = {
|
||||
calls: 'Calls',
|
||||
moderation: 'Moderation',
|
||||
audit: 'Audit',
|
||||
configurator: 'Phone configurator',
|
||||
},
|
||||
overview: {
|
||||
eyebrow: 'Server',
|
||||
@@ -843,6 +844,7 @@ const adminPanelFallbackLocales = {
|
||||
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',
|
||||
@@ -856,6 +858,48 @@ const adminPanelFallbackLocales = {
|
||||
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',
|
||||
@@ -1006,7 +1050,8 @@ const adminPanelFallbackLocales = {
|
||||
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.',
|
||||
discardBody:
|
||||
'Your staged app or configuration changes have not been saved.',
|
||||
keepEditing: 'Keep editing',
|
||||
discard: 'Discard changes',
|
||||
saveFailed: 'Some changes could not be saved.',
|
||||
@@ -1028,6 +1073,7 @@ const adminPanelFallbackLocales = {
|
||||
reset_passcode: 'Passcode reset',
|
||||
change_number: 'Phone number changed',
|
||||
factory_reset: 'Phone factory reset',
|
||||
save_configuration: 'Configuration saved',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
@@ -1038,7 +1084,10 @@ const adminPanelFallbackLocales = {
|
||||
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.',
|
||||
'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.',
|
||||
|
||||
@@ -111,3 +111,34 @@ export type AdminCallActivity = {
|
||||
export type AdminActivityResponse =
|
||||
| { entries: AdminMessageActivity[]; kind: 'messages' }
|
||||
| { 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
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
const endpoint = request.params.endpoint
|
||||
const loggedBody = { ...request.body }
|
||||
if (typeof loggedBody.password === 'string')
|
||||
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') {
|
||||
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() })
|
||||
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') {
|
||||
response.json({
|
||||
success: true,
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
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
|
||||
-- =============================================================================
|
||||
@@ -63,7 +69,7 @@ Config.Security = {
|
||||
|
||||
Config.AdminPanel = {
|
||||
Enabled = true,
|
||||
Command = "phoneadmin",
|
||||
Command = "phonepanel",
|
||||
AdminGroups = { "admin", "superadmin" },
|
||||
MaximumPlayers = 128,
|
||||
ReadRequestsPerMinute = 60,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Locales["de"] = {
|
||||
CommandDescription = "Öffne dein Handy.",
|
||||
AdminCommand = {
|
||||
CommandDescription = "Öffne das geschützte Handy-Admin-Panel.",
|
||||
CommandDescription = "Öffne Handy-Administration und -Konfiguration.",
|
||||
Errors = {
|
||||
disabled = "Das Handy-Admin-Panel ist deaktiviert.",
|
||||
not_authorized = "Du hast keinen Zugriff auf das Handy-Admin-Panel.",
|
||||
@@ -203,9 +203,10 @@ Locales["de"] = {
|
||||
},
|
||||
AdminPanel = {
|
||||
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" },
|
||||
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" },
|
||||
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" } } },
|
||||
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" },
|
||||
@@ -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." },
|
||||
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" } },
|
||||
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" },
|
||||
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." },
|
||||
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- 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 = "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 = {
|
||||
health = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Locales["en"] = {
|
||||
CommandDescription = "Open your phone.",
|
||||
AdminCommand = {
|
||||
CommandDescription = "Open the protected phone admin panel.",
|
||||
CommandDescription = "Open phone administration and configuration.",
|
||||
Errors = {
|
||||
disabled = "The phone admin panel is disabled.",
|
||||
not_authorized = "You do not have access to the phone admin panel.",
|
||||
@@ -203,9 +203,10 @@ Locales["en"] = {
|
||||
},
|
||||
AdminPanel = {
|
||||
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" },
|
||||
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" },
|
||||
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" } } },
|
||||
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" },
|
||||
@@ -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." },
|
||||
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" } },
|
||||
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" },
|
||||
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." },
|
||||
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 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 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 = {
|
||||
health = {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
--[[
|
||||
Sky Phone media configuration
|
||||
|
||||
This file is loaded on the server only. Keep API keys private and restart
|
||||
sky_phone after changing providers, upload limits, or import sources.
|
||||
This file is loaded on the server only. Keep API keys private. When the
|
||||
phone configurator is enabled in config.lua, these values are first-run
|
||||
defaults and the active media configuration is loaded from SQL.
|
||||
]]
|
||||
|
||||
Config.Media = {
|
||||
|
||||
@@ -32,8 +32,9 @@ client_scripts {
|
||||
'config/config.lua',
|
||||
'config/locales/en.lua',
|
||||
'config/locales/de.lua',
|
||||
'source/bridge/client/framework.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/calls.lua',
|
||||
@@ -79,7 +80,9 @@ server_scripts {
|
||||
'source/server/update_check.lua',
|
||||
'source/bridge/server/database.lua',
|
||||
'source/bridge/server/migrations.lua',
|
||||
'source/server/phone_configurator_schema.lua',
|
||||
'source/bridge/server/callbacks.lua',
|
||||
'source/server/phone_configurator.lua',
|
||||
'source/bridge/server/framework.lua',
|
||||
'source/bridge/server/frameworks/*.lua',
|
||||
'source/bridge/server/housing.lua',
|
||||
|
||||
@@ -2,7 +2,7 @@ local callback_groups = {
|
||||
account = [[login register logout devices remove-device]],
|
||||
admin = [[
|
||||
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]],
|
||||
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)
|
||||
@@ -861,4 +861,50 @@ Bridge.Callbacks.Register("sky_phone:admin:factory-reset", function(source, data
|
||||
SkyPhone.RefreshDevice(data.imei)
|
||||
return { success = true, data = load_player_detail(target_source) }
|
||||
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)
|
||||
|
||||
@@ -2949,6 +2949,8 @@ local schema = {
|
||||
},
|
||||
}
|
||||
|
||||
schema[#schema + 1] = SkyPhoneConfiguratorSchema
|
||||
|
||||
Bridge.Database.Migrate("sky_phone", schema)
|
||||
Bridge.Database.Query([[
|
||||
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",
|
||||
}
|
||||
@@ -200,6 +200,18 @@ CREATE TABLE IF NOT EXISTS `sky_phone_admin_audit` (
|
||||
KEY `idx_sky_phone_admin_audit_target` (`target_identifier`, `created_at`)
|
||||
) 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` (
|
||||
`id` VARCHAR(64) NOT NULL,
|
||||
`account_id` BIGINT UNSIGNED NULL,
|
||||
|
||||
Reference in New Issue
Block a user