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