From 9dd330da9304c877ea1a351fedc871d54b4820ff Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 20 Aug 2026 21:36:16 +0200 Subject: [PATCH] ENH - expand admin device controls --- .../components/AdminPanel.contract.test.ts | 61 +- frontend/src/components/AdminPanel.vue | 948 +++++++++++++++--- frontend/src/stores/admin.ts | 94 ++ frontend/src/stores/phone.ts | 112 ++- frontend/src/types/admin.ts | 25 + frontend/testserver/index.cjs | 107 +- sky_phone/config/config.lua | 1 + sky_phone/config/locales/de.lua | 19 +- sky_phone/config/locales/en.lua | 19 +- sky_phone/source/client/nui_server_bridge.lua | 5 +- sky_phone/source/server/admin.lua | 225 +++++ sky_phone/source/server/phone_persistence.lua | 120 ++- sky_phone/source/server/sim.lua | 56 ++ 13 files changed, 1537 insertions(+), 255 deletions(-) diff --git a/frontend/src/components/AdminPanel.contract.test.ts b/frontend/src/components/AdminPanel.contract.test.ts index 6d2af77..36cdef7 100644 --- a/frontend/src/components/AdminPanel.contract.test.ts +++ b/frontend/src/components/AdminPanel.contract.test.ts @@ -20,6 +20,17 @@ const phoneServer = readFileSync( new URL('../../../sky_phone/source/server/phone.lua', import.meta.url), 'utf8', ) +const persistenceServer = readFileSync( + new URL( + '../../../sky_phone/source/server/phone_persistence.lua', + import.meta.url, + ), + 'utf8', +) +const simServer = readFileSync( + new URL('../../../sky_phone/source/server/sim.lua', import.meta.url), + 'utf8', +) const phoneClient = readFileSync( new URL('../../../sky_phone/source/client/main.lua', import.meta.url), 'utf8', @@ -61,8 +72,8 @@ describe('standalone admin panel contracts', () => { it('uses a compact transparent shell with dedicated admin workspaces', () => { expect(source).toContain('background: transparent') - expect(source).toContain('width: min(84vw, 1420px)') - expect(source).toContain('height: min(82vh, 820px)') + expect(source).toContain('width: min(76vw, 1220px)') + expect(source).toContain('height: min(74vh, 700px)') expect(source).not.toContain('backdrop-filter: blur(2px)') for (const tab of [ @@ -70,7 +81,10 @@ describe('standalone admin panel contracts', () => { 'players', 'devices', 'apps', - 'security', + 'accounts', + 'messages', + 'calls', + 'moderation', 'audit', ]) { expect(source).toContain(`selectTab('${tab}')`) @@ -78,6 +92,14 @@ describe('standalone admin panel contracts', () => { } }) + it('removes manual reload and applies a persistent global accent choice', () => { + expect(source).not.toContain(' { expect(source).toContain('const drafts = ref<') expect(source).toContain('@click="saveChanges"') @@ -90,19 +112,46 @@ describe('standalone admin panel contracts', () => { }) it('connects every operation through the standard NUI callback bridge', () => { - expect(bridge).toContain( - 'admin = [[bootstrap player save-apps reveal-password]]', - ) + expect(bridge).toContain('admin = [[') for (const endpoint of [ 'admin:bootstrap', 'admin:player', 'admin:save-apps', 'admin:reveal-password', + 'admin:activity', + 'admin:reset-passcode', + 'admin:change-number', + 'admin:factory-reset', ]) { expect(store).toContain(endpoint) } }) + it('protects activity views and device moderation with ownership and audit checks', () => { + for (const endpoint of [ + 'activity', + 'reset-passcode', + 'change-number', + 'factory-reset', + ]) { + expect(server).toContain( + `Bridge.Callbacks.Register("sky_phone:admin:${endpoint}"`, + ) + } + expect(server).toContain('data.kind ~= "messages"') + expect(server).toContain('data.kind ~= "calls"') + expect(server).toContain('"view_messages"') + expect(server).toContain('"view_calls"') + expect(server).toContain('"reset_passcode"') + expect(server).toContain('"change_number"') + expect(server).toContain('"factory_reset"') + expect(simServer).toContain('function SkyPhoneSim.ChangeNumber(') + expect(simServer).toContain('UPDATE IGNORE `sky_phone_sims`') + expect(persistenceServer).toContain( + 'function SkyPhonePersistence.FactoryReset(imei)', + ) + }) + it('authorizes every server request without requiring a phone session', () => { expect(server).not.toContain('SkyPhone.RequireSession(source)') expect(server).toContain( diff --git a/frontend/src/components/AdminPanel.vue b/frontend/src/components/AdminPanel.vue index c934651..f4a31fb 100644 --- a/frontend/src/components/AdminPanel.vue +++ b/frontend/src/components/AdminPanel.vue @@ -14,12 +14,17 @@ import { LayoutDashboard, LoaderCircle, LockKeyhole, - RefreshCw, + MessageSquare, + Palette, + PhoneCall, + PhoneForwarded, Save, ScrollText, Search, + ShieldAlert, ShieldCheck, Smartphone, + Trash2, TriangleAlert, UsersRound, WalletCards, @@ -51,12 +56,13 @@ type AdminTab = | 'players' | 'devices' | 'apps' - | 'security' + | 'accounts' + | 'messages' + | 'calls' + | 'moderation' | 'audit' -type PendingAction = - | { kind: 'close' } - | { kind: 'player'; source: number } - | { kind: 'refresh' } +type DeviceAction = 'reset-passcode' | 'change-number' | 'factory-reset' +type PendingAction = { kind: 'close' } | { kind: 'player'; source: number } const emit = defineEmits<{ close: [] }>() const admin = useAdminStore() @@ -68,12 +74,23 @@ const selectedImei = ref('') const drafts = ref>>({}) const saving = ref(false) const revealDialogImei = ref('') +const deviceAction = ref(null) +const deviceActionInput = ref('') const discardDialog = ref(false) const pendingAction = ref(null) const toast = ref('') const toastTone = ref<'error' | 'success'>('success') let toastTimer: number | undefined +const accentOptions = [ + { color: '#74d66f', key: 'emerald' }, + { color: '#4f9cff', key: 'blue' }, + { color: '#a875ff', key: 'violet' }, + { color: '#f0a24b', key: 'orange' }, + { color: '#ef6969', key: 'red' }, +] as const +const accentColor = ref<(typeof accentOptions)[number]['color']>('#74d66f') + const filteredPlayers = computed(() => { const needle = playerQuery.value.trim().toLocaleLowerCase(phone.lang) if (!needle) return admin.players @@ -139,6 +156,16 @@ const revealedCredential = computed(() => ? admin.revealedCredentials[selectedDevice.value.imei] : undefined, ) +const selectedMessages = computed(() => + selectedDevice.value + ? (admin.deviceActivity[selectedDevice.value.imei]?.messages ?? []) + : [], +) +const selectedCalls = computed(() => + selectedDevice.value + ? (admin.deviceActivity[selectedDevice.value.imei]?.calls ?? []) + : [], +) watch( () => admin.selectedPlayer, @@ -149,6 +176,22 @@ watch( }, ) +watch( + [tab, selectedImei, () => admin.selectedPlayer?.source], + ([currentTab, imei, source]) => { + if ( + !source || + !imei || + (currentTab !== 'messages' && currentTab !== 'calls') + ) { + return + } + void admin.loadActivity(source, imei, currentTab).then((loaded) => { + if (!loaded) showToast(errorText(), 'error') + }) + }, +) + function t(key: string, params?: Record): string { return phone.t('AdminPanel.' + key, params) } @@ -180,6 +223,12 @@ function formatDate(value: string): string { }).format(date) } +function formatDuration(seconds: number): string { + const minutes = Math.floor(seconds / 60) + const remainder = Math.max(0, seconds % 60) + return `${minutes}:${String(remainder).padStart(2, '0')}` +} + function initials(name: string): string { return name .split(/\s+/) @@ -261,6 +310,11 @@ function selectTab(nextTab: AdminTab): void { tab.value = nextTab } +function selectAccent(color: (typeof accentOptions)[number]['color']): void { + accentColor.value = color + window.localStorage.setItem('sky-phone-admin-accent', color) +} + async function runAction(action: PendingAction): Promise { if (action.kind === 'close') { const response = await nuiCall('admin:close') @@ -268,10 +322,6 @@ async function runAction(action: PendingAction): Promise { else showToast(errorText(response.error), 'error') return } - if (action.kind === 'refresh') { - await refreshData() - return - } await loadPlayer(action.source) } @@ -340,6 +390,48 @@ async function copyPassword(): Promise { } } +function openDeviceAction(action: DeviceAction): void { + if (!selectedDevice.value) return + deviceActionInput.value = + action === 'change-number' ? (selectedDevice.value.number ?? '') : '' + deviceAction.value = action +} + +function cancelDeviceAction(): void { + deviceAction.value = null + deviceActionInput.value = '' +} + +async function confirmDeviceAction(): Promise { + const player = admin.selectedPlayer + const device = selectedDevice.value + const action = deviceAction.value + if (!player || !device || !action || admin.actionKey) return + + let response + if (action === 'reset-passcode') { + response = await admin.resetPasscode(player.source, device.imei) + } else if (action === 'change-number') { + response = await admin.changeNumber( + player.source, + device.imei, + deviceActionInput.value, + ) + } else { + response = await admin.factoryReset(player.source, device.imei) + } + if (!response.success) { + showToast(errorText(response.error), 'error') + return + } + + if (action === 'factory-reset') delete drafts.value[device.imei] + deviceAction.value = null + deviceActionInput.value = '' + showToast(t(`moderation.${action}Success`)) + await admin.load() +} + function auditDescription(entry: AdminAuditEntry): string { const appId = typeof entry.details.appId === 'string' ? entry.details.appId : '' @@ -358,6 +450,11 @@ function onKeydown(event: KeyboardEvent): void { revealDialogImei.value = '' return } + if (deviceAction.value) { + deviceAction.value = null + deviceActionInput.value = '' + return + } if (discardDialog.value) { discardDialog.value = false pendingAction.value = null @@ -368,6 +465,11 @@ function onKeydown(event: KeyboardEvent): void { onMounted(() => { document.addEventListener('keydown', onKeydown) + const storedAccent = window.localStorage.getItem('sky-phone-admin-accent') + const storedOption = accentOptions.find( + (option) => option.color === storedAccent, + ) + if (storedOption) accentColor.value = storedOption.color void refreshData() }) @@ -378,7 +480,12 @@ onBeforeUnmount(() => {