mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:01:31 +00:00
ENH - rebuild admin panel as standalone editor
This commit is contained in:
+28
-5
@@ -11,6 +11,7 @@ import {
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { SkyProvider } from '@/ui'
|
||||
import AdminPanel from '@/components/AdminPanel.vue'
|
||||
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
|
||||
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
|
||||
import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'
|
||||
@@ -114,8 +115,13 @@ type AppMessage = {
|
||||
| CustomAppCatalogEventData
|
||||
| CustomAppEventData
|
||||
| NavigationEventData
|
||||
| AdminPanelOpenPayload
|
||||
}
|
||||
|
||||
type AdminPanelOpenPayload = Required<
|
||||
Pick<PhoneOpenPayload, 'fallbackLocales' | 'lang' | 'locales'>
|
||||
>
|
||||
|
||||
type CustomAppCatalogEventData = {
|
||||
apps?: unknown
|
||||
}
|
||||
@@ -355,6 +361,9 @@ const appTransitionName = computed(() =>
|
||||
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
|
||||
)
|
||||
const isLocked = ref(false)
|
||||
const adminPanelOpen = ref(
|
||||
isDevelopment && developmentParameters.has('adminPanel'),
|
||||
)
|
||||
const springboardEditing = ref(false)
|
||||
const isUnlocking = ref(false)
|
||||
const passcodeBusy = ref(false)
|
||||
@@ -518,10 +527,7 @@ function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
clock.hydrate(payload.device?.data.alarms?.payload)
|
||||
games.hydrate(payload.device?.data.games?.payload)
|
||||
media.hydrate(payload.device?.data.media?.payload)
|
||||
appStore.hydrate(
|
||||
payload.device?.data.apps?.payload,
|
||||
phone.permissions.adminPanel,
|
||||
)
|
||||
appStore.hydrate(payload.device?.data.apps?.payload)
|
||||
widgets.hydrate(payload.device?.data.widgets?.payload)
|
||||
}
|
||||
|
||||
@@ -723,7 +729,15 @@ function openDevelopmentPayphonePreview(): void {
|
||||
function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
if (!isTrustedRootMessageSource(event.source, window)) return
|
||||
|
||||
if (event.data?.type === 'custom-apps:catalog') {
|
||||
if (event.data?.type === 'admin:open') {
|
||||
const data = event.data.data as AdminPanelOpenPayload | undefined
|
||||
if (data?.lang && data.locales && data.fallbackLocales) {
|
||||
phone.setLocale(data.lang, data.locales, data.fallbackLocales)
|
||||
}
|
||||
adminPanelOpen.value = true
|
||||
} else if (event.data?.type === 'admin:close') {
|
||||
adminPanelOpen.value = false
|
||||
} else if (event.data?.type === 'custom-apps:catalog') {
|
||||
appCatalog.replaceCatalog(event.data.data)
|
||||
const catalogPayload = event.data.data as
|
||||
| { apps?: unknown; debug?: unknown }
|
||||
@@ -1736,6 +1750,15 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SkyProvider
|
||||
v-if="adminPanelOpen"
|
||||
dark
|
||||
:safe-areas="false"
|
||||
accent="#74d66f"
|
||||
accent-soft="rgba(116, 214, 111, 0.14)"
|
||||
>
|
||||
<AdminPanel @close="adminPanelOpen = false" />
|
||||
</SkyProvider>
|
||||
<PhoneMediaCapture />
|
||||
<PhoneMemoRecorder />
|
||||
<RadioHud />
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="12" y1="8" x2="116" y2="120" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#9b8cff"/>
|
||||
<stop offset="0.52" stop-color="#5540d7"/>
|
||||
<stop offset="1" stop-color="#17123f"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0" cy="0" r="1" gradientTransform="translate(102 20) rotate(132) scale(58)">
|
||||
<stop stop-color="#67e8ff" stop-opacity=".95"/>
|
||||
<stop offset="1" stop-color="#67e8ff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="shadow" x="20" y="16" width="88" height="102" filterUnits="userSpaceOnUse">
|
||||
<feDropShadow dx="0" dy="7" stdDeviation="6" flood-color="#110b39" flood-opacity=".48"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="30" fill="url(#bg)"/>
|
||||
<rect width="128" height="128" rx="30" fill="url(#glow)"/>
|
||||
<path d="M64 25 96 38v22c0 21-12 36-32 44-20-8-32-23-32-44V38l32-13Z" fill="#0f1532" fill-opacity=".78" stroke="#fff" stroke-width="5" stroke-linejoin="round" filter="url(#shadow)"/>
|
||||
<path d="m48 64 10 10 22-25" fill="none" stroke="#72ecff" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="102" cy="29" r="5" fill="#fff" fill-opacity=".9"/>
|
||||
<circle cx="24" cy="92" r="4" fill="#8ff5ff" fill-opacity=".7"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1288,19 +1288,6 @@ button {
|
||||
.app-icon--darkchat {
|
||||
background: #050507;
|
||||
}
|
||||
.app-icon--admin {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 72% 18%,
|
||||
rgba(66, 220, 255, 0.8),
|
||||
transparent 22%
|
||||
),
|
||||
linear-gradient(145deg, #8b7bff, #3423a6 70%);
|
||||
color: #fff;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.22),
|
||||
0 8px 22px rgba(80, 57, 210, 0.35);
|
||||
}
|
||||
.darkchat-page {
|
||||
--dc-purple: #8b5cf6;
|
||||
--dc-purple-dark: #5b21b6;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./AdminPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const app = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
|
||||
const apps = readFileSync(new URL('../config/apps.ts', import.meta.url), 'utf8')
|
||||
const store = readFileSync(
|
||||
new URL('../stores/admin.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const server = readFileSync(
|
||||
new URL('../../../sky_phone/source/server/admin.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const phoneServer = readFileSync(
|
||||
new URL('../../../sky_phone/source/server/phone.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const phoneClient = readFileSync(
|
||||
new URL('../../../sky_phone/source/client/main.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const focusClient = readFileSync(
|
||||
new URL('../../../sky_phone/source/client/focus.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const bridge = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/client/nui_server_bridge.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const config = readFileSync(
|
||||
new URL('../../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const schema = readFileSync(
|
||||
new URL('../../../sky_phone/sql/install.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('standalone admin panel contracts', () => {
|
||||
it('renders as a dedicated full-screen editor outside the phone shell', () => {
|
||||
expect(apps).not.toContain("id: 'admin'")
|
||||
expect(app).toContain("event.data?.type === 'admin:open'")
|
||||
expect(app).toContain('v-if="adminPanelOpen"')
|
||||
expect(app).toContain('<AdminPanel')
|
||||
expect(source).toContain("import { SkyButton } from '@/ui'")
|
||||
expect(source).toContain('class="admin-panel-overlay"')
|
||||
expect(source).toContain('class="admin-panel-rail"')
|
||||
expect(source).toContain('class="admin-panel-directory"')
|
||||
expect(source).toContain('class="admin-panel-editor"')
|
||||
expect(source).toContain('pointer-events: auto')
|
||||
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
|
||||
})
|
||||
|
||||
it('stages app changes locally and saves them only from the toolbar action', () => {
|
||||
expect(source).toContain('const drafts = ref<')
|
||||
expect(source).toContain('@click="saveChanges"')
|
||||
expect(source).toContain("t('editor.noAutoSave')")
|
||||
expect(store).toContain("'admin:save-apps'")
|
||||
expect(store).not.toContain("'admin:set-app'")
|
||||
expect(server).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:admin:save-apps"',
|
||||
)
|
||||
})
|
||||
|
||||
it('connects every operation through the standard NUI callback bridge', () => {
|
||||
expect(bridge).toContain(
|
||||
'admin = [[bootstrap player save-apps reveal-password]]',
|
||||
)
|
||||
for (const endpoint of [
|
||||
'admin:bootstrap',
|
||||
'admin:player',
|
||||
'admin:save-apps',
|
||||
'admin:reveal-password',
|
||||
]) {
|
||||
expect(store).toContain(endpoint)
|
||||
}
|
||||
})
|
||||
|
||||
it('authorizes every server request without requiring a phone session', () => {
|
||||
expect(server).not.toContain('SkyPhone.RequireSession(source)')
|
||||
expect(server).toContain(
|
||||
'Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)',
|
||||
)
|
||||
expect(server).toContain('Config.AdminPanel.ReadRequestsPerMinute')
|
||||
expect(server).toContain('Config.AdminPanel.ActionRequestsPerMinute')
|
||||
expect(server).toContain('Config.AdminPanel.CredentialRevealsPerMinute')
|
||||
expect(config).toContain('Config.AdminPanel = {')
|
||||
})
|
||||
|
||||
it('opens directly from the configurable command with dedicated focus', () => {
|
||||
expect(config).toContain('Command = "phoneadmin"')
|
||||
expect(server).toContain(
|
||||
'RegisterCommand(Config.AdminPanel.Command, function(command_source)',
|
||||
)
|
||||
expect(server).toContain(
|
||||
'TriggerClientEvent("sky_phone:admin:launch", player_source)',
|
||||
)
|
||||
expect(phoneServer).not.toContain(
|
||||
'RegisterCommand(Config.AdminPanel.Command',
|
||||
)
|
||||
expect(phoneClient).toContain('RegisterNetEvent("sky_phone:admin:launch"')
|
||||
expect(phoneClient).toContain('SkyPhoneFocus.SetAdminPanel(true)')
|
||||
expect(focusClient).toContain('function SkyPhoneFocus.SetAdminPanel(open)')
|
||||
})
|
||||
|
||||
it('validates ownership, app policy, and revision before a batch mutation', () => {
|
||||
expect(server).toContain('find_owned_device(target_source, data.imei)')
|
||||
expect(server).toContain('app_metadata(change.appId)')
|
||||
expect(server).toContain('error = "device_not_owned"')
|
||||
expect(server).toContain('error = "app_protected"')
|
||||
expect(server).toContain('AND `revision` = ?')
|
||||
expect(server).toContain('SkyPhone.RefreshDevice(data.imei)')
|
||||
})
|
||||
|
||||
it('gates plaintext account reveals behind confirmation and audit logging', () => {
|
||||
expect(source).toContain('revealDialogImei')
|
||||
expect(source).toContain("t('credentials.revealTitle')")
|
||||
expect(server).toContain('"reveal_account_password"')
|
||||
expect(server).toContain('write_audit(')
|
||||
expect(server).not.toContain('passcode_hash` AS')
|
||||
expect(schema).toContain(
|
||||
'CREATE TABLE IF NOT EXISTS `sky_phone_admin_audit`',
|
||||
)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,6 @@ import {
|
||||
import { defineAsyncComponent, markRaw, shallowReactive } from 'vue'
|
||||
|
||||
import appStoreIcon from '@/assets/img/app-icons/apps.webp'
|
||||
import adminIcon from '@/assets/img/app-icons/admin.svg'
|
||||
import calculatorIcon from '@/assets/img/app-icons/calculator.webp'
|
||||
import cameraIcon from '@/assets/img/app-icons/camera.webp'
|
||||
import clockIcon from '@/assets/img/app-icons/clock.webp'
|
||||
@@ -94,21 +93,6 @@ import type {
|
||||
} from '@/types/apps'
|
||||
|
||||
export const PHONE_APPS = shallowReactive<PhoneAppDefinition[]>([
|
||||
{
|
||||
adminOnly: true,
|
||||
category: 'utilities',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/AdminApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 31,
|
||||
icon: markRaw(ShieldCheck),
|
||||
iconClass: 'app-icon--admin',
|
||||
iconImage: adminIcon,
|
||||
id: 'admin',
|
||||
labelKey: 'Apps.admin.name',
|
||||
route: '/apps/admin',
|
||||
},
|
||||
{
|
||||
category: 'utilities',
|
||||
component: markRaw(
|
||||
|
||||
@@ -60,17 +60,17 @@ export const useAdminStore = defineStore('admin', {
|
||||
this.selectedPlayer = null
|
||||
this.revealedCredentials = {}
|
||||
},
|
||||
async setApp(
|
||||
async saveApps(
|
||||
source: number,
|
||||
imei: string,
|
||||
appId: string,
|
||||
installed: boolean,
|
||||
revision: number,
|
||||
changes: Array<{ appId: string; installed: boolean }>,
|
||||
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||
this.actionKey = `${imei}:${appId}`
|
||||
const response = await nuiCall<AdminPlayerDetail>('admin:set-app', {
|
||||
appId,
|
||||
this.actionKey = `${imei}:save`
|
||||
const response = await nuiCall<AdminPlayerDetail>('admin:save-apps', {
|
||||
changes,
|
||||
imei,
|
||||
installed,
|
||||
revision,
|
||||
source,
|
||||
})
|
||||
this.actionKey = ''
|
||||
|
||||
@@ -14,7 +14,6 @@ const mocks = vi.hoisted(() => ({
|
||||
phone: {
|
||||
device: { imei: 'phone-a' },
|
||||
isOpen: true,
|
||||
permissions: { adminPanel: false },
|
||||
saveDeviceNamespace: vi.fn(),
|
||||
},
|
||||
}))
|
||||
@@ -27,7 +26,6 @@ describe('app store', () => {
|
||||
setActivePinia(createPinia())
|
||||
mocks.phone.device.imei = 'phone-a'
|
||||
mocks.phone.isOpen = true
|
||||
mocks.phone.permissions.adminPanel = false
|
||||
mocks.phone.saveDeviceNamespace.mockReset()
|
||||
})
|
||||
|
||||
@@ -77,17 +75,11 @@ describe('app store', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes the protected admin app only with server-granted access', () => {
|
||||
it('drops the retired admin app from persisted phone layouts', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({ claimedApps: ['admin'] })
|
||||
expect(apps.isInstalled('admin')).toBe(false)
|
||||
expect(apps.homeLayout.grid).not.toContain('admin')
|
||||
|
||||
mocks.phone.permissions.adminPanel = true
|
||||
apps.hydrate(null, true)
|
||||
expect(apps.isInstalled('admin')).toBe(true)
|
||||
expect(apps.homeLayout.grid).toContain('admin')
|
||||
})
|
||||
|
||||
it('migrates current layouts so dock apps are not repeated in the grid', () => {
|
||||
|
||||
@@ -57,11 +57,9 @@ function getDefaultDockIds(): LaunchablePhoneAppId[] {
|
||||
.map((app) => app.id)
|
||||
}
|
||||
|
||||
function getDefaultInstalledIds(
|
||||
adminPanelAccess = false,
|
||||
): LaunchablePhoneAppId[] {
|
||||
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => {
|
||||
if (app.adminOnly) return adminPanelAccess
|
||||
if (app.adminOnly) return false
|
||||
return isExternalPhoneApp(app)
|
||||
? app.defaultInstalled
|
||||
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
|
||||
@@ -222,7 +220,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
installations.set(id, { deviceImei, timer, token })
|
||||
pendingInstallations.set(this, installations)
|
||||
},
|
||||
hydrate(payload: unknown, adminPanelAccess = false): void {
|
||||
hydrate(payload: unknown): void {
|
||||
this.cancelPendingInstalls()
|
||||
const data = payload as {
|
||||
claimedApps?: unknown
|
||||
@@ -258,10 +256,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
})
|
||||
: []
|
||||
const installedIds = [
|
||||
...new Set([
|
||||
...getDefaultInstalledIds(adminPanelAccess),
|
||||
...this.claimedApps,
|
||||
]),
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
const removedLegacyDefaults = hasUninstalledBuiltinApp(
|
||||
data?.homeLayout,
|
||||
@@ -317,7 +312,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
},
|
||||
isInstalled(appId: LaunchablePhoneAppId): boolean {
|
||||
const app = getPhoneApp(appId)
|
||||
if (app?.adminOnly) return usePhoneStore().permissions.adminPanel
|
||||
if (app?.adminOnly) return false
|
||||
if (this.uninstalledApps.includes(appId)) return false
|
||||
if (this.claimedApps.includes(appId)) return true
|
||||
if (!app) return false
|
||||
@@ -327,10 +322,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
},
|
||||
reconcileCatalog(): void {
|
||||
const installedIds = [
|
||||
...new Set([
|
||||
...getDefaultInstalledIds(usePhoneStore().permissions.adminPanel),
|
||||
...this.claimedApps,
|
||||
]),
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
|
||||
+227
-51
@@ -38,7 +38,6 @@ export type PhoneOpenPayload = {
|
||||
locales?: LocaleTree
|
||||
memos?: DeviceBootstrap['memos']
|
||||
notes?: DeviceBootstrap['notes']
|
||||
permissions?: DeviceBootstrap['permissions']
|
||||
player?: DeviceBootstrap['player']
|
||||
security?: DeviceSecurity
|
||||
token?: string
|
||||
@@ -806,7 +805,7 @@ const citywarnFallbackLocales = {
|
||||
},
|
||||
}
|
||||
|
||||
const adminFallbackLocales = {
|
||||
const adminPanelFallbackLocales = {
|
||||
name: 'Command Center',
|
||||
subtitle: 'Protected administration',
|
||||
navigation: 'Admin navigation',
|
||||
@@ -829,7 +828,11 @@ const adminFallbackLocales = {
|
||||
empty: 'No players found',
|
||||
emptyBody: 'Adjust the search or refresh the live player list.',
|
||||
},
|
||||
search: { players: 'Search name, ID, job, or number', 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',
|
||||
@@ -878,13 +881,39 @@ const adminFallbackLocales = {
|
||||
apps: {
|
||||
eyebrow: 'Remote management',
|
||||
title: 'App access',
|
||||
search: 'Search apps',
|
||||
description:
|
||||
'Stage app access for this device. Nothing changes until you save.',
|
||||
installed: 'Installed',
|
||||
available: 'Available',
|
||||
grant: 'Install',
|
||||
revoke: 'Remove',
|
||||
granted: 'App installed on the selected phone.',
|
||||
revoked: 'App removed from the selected phone.',
|
||||
protected: 'System app',
|
||||
changes: '{count} pending changes',
|
||||
},
|
||||
editor: {
|
||||
brand: 'SKY PHONE',
|
||||
workspace: 'ADMIN WORKSPACE',
|
||||
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',
|
||||
},
|
||||
audit: {
|
||||
eyebrow: 'Accountability',
|
||||
@@ -910,15 +939,14 @@ const adminFallbackLocales = {
|
||||
'The phone changed in the meantime. Refresh and try again.',
|
||||
account_not_found: 'No iFruit account is linked to this phone.',
|
||||
invalid_request: 'The admin request was invalid.',
|
||||
device_locked: 'Unlock your phone before using the admin panel.',
|
||||
request_failed: 'The admin request failed.',
|
||||
default: 'The admin panel is temporarily unavailable.',
|
||||
},
|
||||
}
|
||||
|
||||
const defaultLocales: LocaleTree = {
|
||||
AdminPanel: adminPanelFallbackLocales,
|
||||
Apps: {
|
||||
admin: adminFallbackLocales,
|
||||
citywarn: citywarnFallbackLocales,
|
||||
crypto: cryptoFallbackLocales,
|
||||
easyShare: {
|
||||
@@ -2544,46 +2572,190 @@ const defaultLocales: LocaleTree = {
|
||||
'neon-drop': 'Neon block-dropping puzzle',
|
||||
},
|
||||
previews: {
|
||||
citywarn: { first: 'Live alerts', second: 'Safety zones', third: 'Incident updates' },
|
||||
crypto: { first: 'Synthetic markets', second: 'Portfolio', third: 'Wallet transfers' },
|
||||
health: { first: 'Activity rings', second: 'Medical ID', third: 'Health records' },
|
||||
'weazel-news': { first: 'Top stories', second: 'Local reports', third: 'Breaking news' },
|
||||
companies: { first: 'Business directory', second: 'Job requests', third: 'Services' },
|
||||
music: { first: 'Now playing', second: 'Playlists', third: 'Music library' },
|
||||
picstagram: { first: 'Photo feed', second: 'Stories', third: 'Profiles' },
|
||||
feather: { first: 'Short posts', second: 'Following feed', third: 'Conversations' },
|
||||
fliptok: { first: 'Video feed', second: 'Creator tools', third: 'Trends' },
|
||||
flare: { first: 'Discover people', second: 'Matches', third: 'Live moments' },
|
||||
calendar: { first: 'Upcoming events', second: 'Day planner', third: 'Reminders' },
|
||||
radio: { first: 'Live channels', second: 'Team radio', third: 'Favorites' },
|
||||
'local-pages': { first: 'Local pages', second: 'Reviews', third: 'City discovery' },
|
||||
crewlink: { first: 'Crew roster', second: 'Shared locations', third: 'Coordination' },
|
||||
phone: { first: 'Recent calls', second: 'Contacts', third: 'Voicemail' },
|
||||
messages: { first: 'Conversations', second: 'Media sharing', third: 'Quick replies' },
|
||||
darkchat: { first: 'Private chats', second: 'Secure groups', third: 'Invitations' },
|
||||
garage: { first: 'Vehicle list', second: 'Parking locations', third: 'Valet' },
|
||||
house: { first: 'Property access', second: 'Residents', third: 'Management' },
|
||||
map: { first: 'Live navigation', second: 'Nearby places', third: 'Route guidance' },
|
||||
skyride: { first: 'Ride booking', second: 'Driver tracking', third: 'Trip history' },
|
||||
banking: { first: 'Account balance', second: 'Transfers', third: 'Transactions' },
|
||||
billing: { first: 'Open invoices', second: 'Payment requests', third: 'Payment history' },
|
||||
citywarn: {
|
||||
first: 'Live alerts',
|
||||
second: 'Safety zones',
|
||||
third: 'Incident updates',
|
||||
},
|
||||
crypto: {
|
||||
first: 'Synthetic markets',
|
||||
second: 'Portfolio',
|
||||
third: 'Wallet transfers',
|
||||
},
|
||||
health: {
|
||||
first: 'Activity rings',
|
||||
second: 'Medical ID',
|
||||
third: 'Health records',
|
||||
},
|
||||
'weazel-news': {
|
||||
first: 'Top stories',
|
||||
second: 'Local reports',
|
||||
third: 'Breaking news',
|
||||
},
|
||||
companies: {
|
||||
first: 'Business directory',
|
||||
second: 'Job requests',
|
||||
third: 'Services',
|
||||
},
|
||||
music: {
|
||||
first: 'Now playing',
|
||||
second: 'Playlists',
|
||||
third: 'Music library',
|
||||
},
|
||||
picstagram: {
|
||||
first: 'Photo feed',
|
||||
second: 'Stories',
|
||||
third: 'Profiles',
|
||||
},
|
||||
feather: {
|
||||
first: 'Short posts',
|
||||
second: 'Following feed',
|
||||
third: 'Conversations',
|
||||
},
|
||||
fliptok: {
|
||||
first: 'Video feed',
|
||||
second: 'Creator tools',
|
||||
third: 'Trends',
|
||||
},
|
||||
flare: {
|
||||
first: 'Discover people',
|
||||
second: 'Matches',
|
||||
third: 'Live moments',
|
||||
},
|
||||
calendar: {
|
||||
first: 'Upcoming events',
|
||||
second: 'Day planner',
|
||||
third: 'Reminders',
|
||||
},
|
||||
radio: {
|
||||
first: 'Live channels',
|
||||
second: 'Team radio',
|
||||
third: 'Favorites',
|
||||
},
|
||||
'local-pages': {
|
||||
first: 'Local pages',
|
||||
second: 'Reviews',
|
||||
third: 'City discovery',
|
||||
},
|
||||
crewlink: {
|
||||
first: 'Crew roster',
|
||||
second: 'Shared locations',
|
||||
third: 'Coordination',
|
||||
},
|
||||
phone: {
|
||||
first: 'Recent calls',
|
||||
second: 'Contacts',
|
||||
third: 'Voicemail',
|
||||
},
|
||||
messages: {
|
||||
first: 'Conversations',
|
||||
second: 'Media sharing',
|
||||
third: 'Quick replies',
|
||||
},
|
||||
darkchat: {
|
||||
first: 'Private chats',
|
||||
second: 'Secure groups',
|
||||
third: 'Invitations',
|
||||
},
|
||||
garage: {
|
||||
first: 'Vehicle list',
|
||||
second: 'Parking locations',
|
||||
third: 'Valet',
|
||||
},
|
||||
house: {
|
||||
first: 'Property access',
|
||||
second: 'Residents',
|
||||
third: 'Management',
|
||||
},
|
||||
map: {
|
||||
first: 'Live navigation',
|
||||
second: 'Nearby places',
|
||||
third: 'Route guidance',
|
||||
},
|
||||
skyride: {
|
||||
first: 'Ride booking',
|
||||
second: 'Driver tracking',
|
||||
third: 'Trip history',
|
||||
},
|
||||
banking: {
|
||||
first: 'Account balance',
|
||||
second: 'Transfers',
|
||||
third: 'Transactions',
|
||||
},
|
||||
billing: {
|
||||
first: 'Open invoices',
|
||||
second: 'Payment requests',
|
||||
third: 'Payment history',
|
||||
},
|
||||
mail: { first: 'Inbox', second: 'Attachments', third: 'Mailboxes' },
|
||||
notes: { first: 'Notes', second: 'Checklists', third: 'Pinned ideas' },
|
||||
memos: { first: 'Voice recordings', second: 'Playback', third: 'Favorites' },
|
||||
calculator: { first: 'Basic calculation', second: 'Scientific tools', third: 'History' },
|
||||
camera: { first: 'Photo mode', second: 'Video capture', third: 'Zoom controls' },
|
||||
memos: {
|
||||
first: 'Voice recordings',
|
||||
second: 'Playback',
|
||||
third: 'Favorites',
|
||||
},
|
||||
calculator: {
|
||||
first: 'Basic calculation',
|
||||
second: 'Scientific tools',
|
||||
third: 'History',
|
||||
},
|
||||
camera: {
|
||||
first: 'Photo mode',
|
||||
second: 'Video capture',
|
||||
third: 'Zoom controls',
|
||||
},
|
||||
clock: { first: 'World clock', second: 'Alarms', third: 'Timers' },
|
||||
weather: { first: 'Current weather', second: 'Hourly forecast', third: 'Seven-day outlook' },
|
||||
photos: { first: 'Media library', second: 'Albums', third: 'Shared media' },
|
||||
settings: { first: 'Device controls', second: 'Privacy', third: 'Personalization' },
|
||||
weather: {
|
||||
first: 'Current weather',
|
||||
second: 'Hourly forecast',
|
||||
third: 'Seven-day outlook',
|
||||
},
|
||||
photos: {
|
||||
first: 'Media library',
|
||||
second: 'Albums',
|
||||
third: 'Shared media',
|
||||
},
|
||||
settings: {
|
||||
first: 'Device controls',
|
||||
second: 'Privacy',
|
||||
third: 'Personalization',
|
||||
},
|
||||
snake: { first: 'High score', second: 'Speed', third: 'Classic grid' },
|
||||
memory: { first: 'Matched pairs', second: 'Best time', third: 'Card themes' },
|
||||
'number-merge': { first: 'Highest tile', second: 'Score', third: 'Strategy grid' },
|
||||
minesweeper: { first: 'Mine counter', second: 'Best time', third: 'Difficulty' },
|
||||
'tower-stack': { first: 'Tower height', second: 'Perfect drops', third: 'High score' },
|
||||
'sky-flappy': { first: 'Flight score', second: 'Best run', third: 'Obstacles' },
|
||||
citymarkt: { first: 'Listings', second: 'Categories', third: 'Saved offers' },
|
||||
'neon-drop': { first: 'Lines cleared', second: 'Level', third: 'Neon pieces' },
|
||||
memory: {
|
||||
first: 'Matched pairs',
|
||||
second: 'Best time',
|
||||
third: 'Card themes',
|
||||
},
|
||||
'number-merge': {
|
||||
first: 'Highest tile',
|
||||
second: 'Score',
|
||||
third: 'Strategy grid',
|
||||
},
|
||||
minesweeper: {
|
||||
first: 'Mine counter',
|
||||
second: 'Best time',
|
||||
third: 'Difficulty',
|
||||
},
|
||||
'tower-stack': {
|
||||
first: 'Tower height',
|
||||
second: 'Perfect drops',
|
||||
third: 'High score',
|
||||
},
|
||||
'sky-flappy': {
|
||||
first: 'Flight score',
|
||||
second: 'Best run',
|
||||
third: 'Obstacles',
|
||||
},
|
||||
citymarkt: {
|
||||
first: 'Listings',
|
||||
second: 'Categories',
|
||||
third: 'Saved offers',
|
||||
},
|
||||
'neon-drop': {
|
||||
first: 'Lines cleared',
|
||||
second: 'Level',
|
||||
third: 'Neon pieces',
|
||||
},
|
||||
},
|
||||
search: {
|
||||
recommended: 'Recommended',
|
||||
@@ -5276,9 +5448,6 @@ export const usePhoneStore = defineStore('phone', {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
} as DeviceBootstrap['player'],
|
||||
permissions: {
|
||||
adminPanel: false,
|
||||
} as DeviceBootstrap['permissions'],
|
||||
security: {
|
||||
enabled: false,
|
||||
length: null,
|
||||
@@ -5298,6 +5467,15 @@ export const usePhoneStore = defineStore('phone', {
|
||||
this.cameraLandscape = false
|
||||
this.isOpen = false
|
||||
},
|
||||
setLocale(
|
||||
lang: string,
|
||||
locales: LocaleTree,
|
||||
fallbackLocales: LocaleTree,
|
||||
): void {
|
||||
this.lang = lang
|
||||
this.locales = locales
|
||||
this.fallbackLocales = fallbackLocales
|
||||
},
|
||||
open(payload: PhoneOpenPayload = {}): void {
|
||||
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
|
||||
const nextToken = payload.token ?? this.deviceSessionToken
|
||||
@@ -5313,7 +5491,6 @@ export const usePhoneStore = defineStore('phone', {
|
||||
this.locales = payload.locales ?? this.fallbackLocales
|
||||
if (payload.device) this.hydrateDevice(payload.device)
|
||||
if (payload.player) this.player = payload.player
|
||||
this.permissions = payload.permissions ?? { adminPanel: false }
|
||||
this.security = payload.security ?? {
|
||||
enabled: false,
|
||||
length: null,
|
||||
@@ -5324,7 +5501,6 @@ export const usePhoneStore = defineStore('phone', {
|
||||
endDeviceSession(): void {
|
||||
this.close()
|
||||
this.player = { firstName: '', lastName: '' }
|
||||
this.permissions = { adminPanel: false }
|
||||
if (this.deviceSessionToken !== null) {
|
||||
this.deviceSessionToken = null
|
||||
this.persistenceGeneration += 1
|
||||
|
||||
@@ -58,6 +58,7 @@ export type AdminPlayerDetail = {
|
||||
money: {
|
||||
bank: number
|
||||
cash: number
|
||||
currency: string
|
||||
}
|
||||
name: string
|
||||
serverName: string
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export type BuiltinPhoneAppId =
|
||||
| 'admin'
|
||||
| 'phone'
|
||||
| 'messages'
|
||||
| 'darkchat'
|
||||
|
||||
@@ -50,9 +50,6 @@ export type DeviceBootstrap = {
|
||||
device: PhoneDevice
|
||||
memos: MemoDto[]
|
||||
notes: Note[]
|
||||
permissions: {
|
||||
adminPanel: boolean
|
||||
}
|
||||
player: PhonePlayerIdentity
|
||||
security: DeviceSecurity
|
||||
token: string
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { BuiltinPhoneAppId } from '@/types/apps'
|
||||
|
||||
type PreviewableBuiltinAppId = Exclude<
|
||||
BuiltinPhoneAppId,
|
||||
'admin' | 'app-store'
|
||||
>
|
||||
type PreviewableBuiltinAppId = Exclude<BuiltinPhoneAppId, 'app-store'>
|
||||
|
||||
const previewModules = import.meta.glob<string>(
|
||||
'../assets/img/app-previews/*.jpg',
|
||||
@@ -16,7 +13,10 @@ const previewModules = import.meta.glob<string>(
|
||||
|
||||
const APP_STORE_PREVIEW_IMAGES = Object.fromEntries(
|
||||
Object.entries(previewModules).map(([path, imageUrl]) => {
|
||||
const appId = path.split('/').at(-1)?.replace(/\.jpg$/, '')
|
||||
const appId = path
|
||||
.split('/')
|
||||
.at(-1)
|
||||
?.replace(/\.jpg$/, '')
|
||||
if (!appId) throw new Error(`Invalid App Store preview path: ${path}`)
|
||||
return [appId, imageUrl]
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,7 @@ export type AppStorePreviewVisual = {
|
||||
surface: string
|
||||
}
|
||||
|
||||
type PreviewableBuiltinAppId = Exclude<BuiltinPhoneAppId, 'admin' | 'app-store'>
|
||||
type PreviewableBuiltinAppId = Exclude<BuiltinPhoneAppId, 'app-store'>
|
||||
|
||||
const APP_STORE_PREVIEW_STYLES = {
|
||||
citywarn: { accent: '#ff4f5e', surface: '#250e17' },
|
||||
|
||||
@@ -98,7 +98,6 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
|
||||
LaunchablePhoneAppId,
|
||||
AppNotificationPreferences
|
||||
> = {
|
||||
admin: { enabled: true, sounds: true },
|
||||
phone: { enabled: true, sounds: true },
|
||||
messages: { enabled: true, sounds: true },
|
||||
darkchat: { enabled: true, sounds: true },
|
||||
|
||||
@@ -28,7 +28,7 @@ const launchStyle = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="app && (!app.adminOnly || phone.permissions.adminPanel)"
|
||||
v-if="app && !app.adminOnly"
|
||||
class="app-window"
|
||||
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
|
||||
:style="launchStyle"
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('./AdminApp.vue', import.meta.url), 'utf8')
|
||||
const store = readFileSync(
|
||||
new URL('../../stores/admin.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const server = readFileSync(
|
||||
new URL('../../../../sky_phone/source/server/admin.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const phoneServer = readFileSync(
|
||||
new URL('../../../../sky_phone/source/server/phone.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const phoneClient = readFileSync(
|
||||
new URL('../../../../sky_phone/source/client/main.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const bridge = readFileSync(
|
||||
new URL(
|
||||
'../../../../sky_phone/source/client/nui_server_bridge.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const config = readFileSync(
|
||||
new URL('../../../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const schema = readFileSync(
|
||||
new URL('../../../../sky_phone/sql/install.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('admin command center contracts', () => {
|
||||
it('uses the shared Sky UI with one scroll owner and no Konsta imports', () => {
|
||||
expect(source).not.toContain("from 'konsta/vue'")
|
||||
for (const component of [
|
||||
'SkyAppPage',
|
||||
'SkyNavbar',
|
||||
'SkyScrollArea',
|
||||
'SkySearchbar',
|
||||
'SkyDialog',
|
||||
'SkyCard',
|
||||
]) {
|
||||
expect(source).toContain(`<${component}`)
|
||||
}
|
||||
expect(source.match(/<SkyScrollArea/g)).toHaveLength(2)
|
||||
expect(source).toContain('<template v-if="admin.selectedPlayer">')
|
||||
expect(source).toContain('<template v-else>')
|
||||
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
|
||||
})
|
||||
|
||||
it('connects each admin operation through the standard NUI callback bridge', () => {
|
||||
expect(bridge).toContain(
|
||||
'admin = [[bootstrap player set-app reveal-password]]',
|
||||
)
|
||||
for (const endpoint of [
|
||||
'admin:bootstrap',
|
||||
'admin:player',
|
||||
'admin:set-app',
|
||||
'admin:reveal-password',
|
||||
]) {
|
||||
expect(store).toContain(endpoint)
|
||||
}
|
||||
})
|
||||
|
||||
it('authorizes every server request and applies separate rate limits', () => {
|
||||
expect(server).toContain('SkyPhone.RequireSession(source)')
|
||||
expect(server).toContain(
|
||||
'Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)',
|
||||
)
|
||||
expect(server).toContain('Config.AdminPanel.ReadRequestsPerMinute')
|
||||
expect(server).toContain('Config.AdminPanel.ActionRequestsPerMinute')
|
||||
expect(server).toContain('Config.AdminPanel.CredentialRevealsPerMinute')
|
||||
expect(config).toContain('Config.AdminPanel = {')
|
||||
})
|
||||
|
||||
it('opens directly from a server-authorized configurable command', () => {
|
||||
expect(config).toContain('Command = "phoneadmin"')
|
||||
expect(phoneServer).toContain(
|
||||
'RegisterCommand(Config.AdminPanel.Command, function(command_source)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'Bridge.Framework.HasAdminGroup(player_source, Config.AdminPanel.AdminGroups)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'TriggerClientEvent("sky_phone:admin:launch", player_source)',
|
||||
)
|
||||
expect(phoneServer).toContain('if not sessions[player_source] then')
|
||||
expect(phoneClient).toContain(
|
||||
'RegisterNetEvent("sky_phone:admin:launch"',
|
||||
)
|
||||
expect(phoneClient).toContain('requested_app_id = "admin"')
|
||||
})
|
||||
|
||||
it('validates ownership and app policy before remote device mutation', () => {
|
||||
expect(server).toContain('find_owned_device(target_source, data.imei)')
|
||||
expect(server).toContain('app_metadata(data.appId)')
|
||||
expect(server).toContain('error = "device_not_owned"')
|
||||
expect(server).toContain('error = "app_protected"')
|
||||
expect(server).toContain('AND `revision` = ?')
|
||||
expect(server).toContain('SkyPhone.RefreshDevice(data.imei)')
|
||||
})
|
||||
|
||||
it('gates plaintext account reveals behind confirmation and audit logging', () => {
|
||||
expect(source).toContain('revealDialogImei')
|
||||
expect(source).toContain("t('credentials.revealTitle')")
|
||||
expect(server).toContain('"reveal_account_password"')
|
||||
expect(server).toContain('write_audit(')
|
||||
expect(server).not.toContain('passcode_hash` AS')
|
||||
expect(schema).toContain('CREATE TABLE IF NOT EXISTS `sky_phone_admin_audit`')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -724,7 +724,7 @@ async function confirmFactoryReset(): Promise<void> {
|
||||
if (!success) accountToast.value = accountError()
|
||||
else {
|
||||
appAuth.hydrate(undefined, '')
|
||||
appStore.hydrate(undefined, phone.permissions.adminPanel)
|
||||
appStore.hydrate(undefined)
|
||||
phone.resetAfterFactoryReset()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4696,6 +4696,7 @@ function adminMockPlayerDetail(source = 1) {
|
||||
money: {
|
||||
bank: primary ? 182450 : 28450,
|
||||
cash: primary ? 2740 : 950,
|
||||
currency: '$',
|
||||
},
|
||||
name: primary ? 'Alex Morgan' : 'Jordan Blake',
|
||||
serverName: primary ? 'Skyline' : 'JordanB',
|
||||
@@ -4759,15 +4760,20 @@ app.post('/api/:endpoint', async (request, response, next) => {
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:set-app') {
|
||||
const appId = String(request.body.appId ?? '')
|
||||
const installed = request.body.installed === true
|
||||
adminMockApps.claimed = adminMockApps.claimed.filter((id) => id !== appId)
|
||||
adminMockApps.uninstalled = adminMockApps.uninstalled.filter(
|
||||
(id) => id !== appId,
|
||||
)
|
||||
if (installed) adminMockApps.claimed.push(appId)
|
||||
else adminMockApps.uninstalled.push(appId)
|
||||
if (endpoint === 'admin:save-apps') {
|
||||
const changes = Array.isArray(request.body.changes)
|
||||
? request.body.changes
|
||||
: []
|
||||
for (const change of changes) {
|
||||
const appId = String(change.appId ?? '')
|
||||
const installed = change.installed === true
|
||||
adminMockApps.claimed = adminMockApps.claimed.filter((id) => id !== appId)
|
||||
adminMockApps.uninstalled = adminMockApps.uninstalled.filter(
|
||||
(id) => id !== appId,
|
||||
)
|
||||
if (installed) adminMockApps.claimed.push(appId)
|
||||
else adminMockApps.uninstalled.push(appId)
|
||||
}
|
||||
adminMockApps.revision += 1
|
||||
response.json({
|
||||
success: true,
|
||||
@@ -4775,6 +4781,10 @@ app.post('/api/:endpoint', async (request, response, next) => {
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:close') {
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:reveal-password') {
|
||||
response.json({
|
||||
success: true,
|
||||
@@ -9982,9 +9992,6 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
firstName: 'Alex',
|
||||
lastName: 'Morgan',
|
||||
},
|
||||
permissions: {
|
||||
adminPanel: true,
|
||||
},
|
||||
security: mockSecurity,
|
||||
token: 'development',
|
||||
},
|
||||
|
||||
@@ -201,20 +201,21 @@ Locales["de"] = {
|
||||
contacts = { name = "Favoriten", description = "Rufe deine Lieblingskontakte an oder schreibe ihnen.", choose = "Lieblingskontakte" },
|
||||
},
|
||||
},
|
||||
AdminPanel = {
|
||||
name = "Kommandozentrale", subtitle = "Geschützte Administration", navigation = "Admin-Navigation", refresh = "Admin-Daten aktualisieren", loading = "Geschützte Daten werden geladen...",
|
||||
tabs = { players = "Spieler", audit = "Audit" },
|
||||
overview = { eyebrow = "Live-Betrieb", title = "Kommandozentrale", body = "Verwalte aktive Handys, App-Zugriffe und geschützte Accountdaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts" },
|
||||
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
|
||||
search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" },
|
||||
detail = { character = "Charakterprofil", data = "Spielerdatenübersicht", cash = "Bargeld", bank = "Bank", job = "Job", duty = "Dienst", onDuty = "Im Dienst", offDuty = "Außer Dienst", identity = "Identität", playerData = "Spielerdaten", identifier = "Charakter-Identifier", birthdate = "Geburtsdatum", grade = "Job-Rang", unknown = "Unbekannt" },
|
||||
devices = { eyebrow = "Gerätesteuerung", title = "Handys", choose = "Handy auswählen", empty = "Kein Handy gefunden", emptyBody = "Dieser Spieler besitzt aktuell kein verwaltbares Handy.", noNumber = "Keine Telefonnummer", noSim = "Keine SIM", imei = "IMEI", updated = "Letzte Aktivität" },
|
||||
credentials = { eyebrow = "Geschützte Daten", title = "Zugangsdaten", email = "iFruit-E-Mail", password = "iFruit-Passwort", reveal = "Anzeigen", copy = "Passwort kopieren", copied = "Passwort kopiert.", noAccount = "Mit diesem Handy ist kein iFruit-Account verknüpft.", passcode = "Gerätecode", passcodeHashed = "{length}-stellige PIN · sicher gehasht und nicht wiederherstellbar", passcodeDisabled = "Kein Gerätecode eingerichtet", revealTitle = "Geschütztes Passwort anzeigen?", revealBody = "Diese Aktion wird serverseitig autorisiert, rate-limitiert und im Admin-Audit protokolliert.", cancel = "Abbrechen", confirmReveal = "Passwort anzeigen" },
|
||||
apps = { eyebrow = "Fernverwaltung", title = "App-Zugriff", description = "Lege App-Zugriffe für dieses Gerät als Entwurf fest. Erst Speichern übernimmt die Änderungen.", installed = "Installiert", available = "Verfügbar", protected = "System-App", changes = "{count} offene Änderungen" },
|
||||
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" } },
|
||||
editor = { brand = "SKY PHONE", workspace = "ADMIN WORKSPACE", players = "Spielerverzeichnis", audit = "Audit-Protokoll", selectPlayer = "Wähle einen Spieler, um Identität, Geräte, Zugangsdaten und App-Zugriffe zu prüfen.", save = "Änderungen speichern", saveHint = "Offene Änderungen übernehmen", saved = "Änderungen gespeichert.", unsaved = "Ungespeicherte Änderungen", close = "Admin-Panel schließen", refresh = "Live-Daten aktualisieren", online = "LIVE", profile = "PROFIL", financial = "FINANZEN", device = "GERÄT", security = "SICHERHEIT", noAutoSave = "Manuelles Speichern", noAutoSaveBody = "Änderungen bleiben lokal, bis der grüne Haken gedrückt wird.", discardTitle = "Ungespeicherte Änderungen verwerfen?", discardBody = "Deine vorgemerkten App-Änderungen wurden noch nicht gespeichert.", keepEditing = "Weiter bearbeiten", discard = "Änderungen verwerfen", saveFailed = "Einige Änderungen konnten nicht gespeichert werden.", noSelection = "Kein Spieler ausgewählt" },
|
||||
errors = { not_authorized = "Du hast keinen Zugriff auf das Admin-Panel.", rate_limited = "Zu viele Admin-Anfragen. Bitte warte.", player_unavailable = "Dieser Spieler ist nicht mehr online.", device_not_owned = "Dieses Handy gehört nicht mehr zum ausgewählten Spieler.", invalid_app = "Diese App ist nicht auf dem Server registriert.", app_protected = "Diese System-App kann nicht entfernt werden.", revision_conflict = "Das Handy wurde zwischenzeitlich geändert. Aktualisiere und versuche es erneut.", account_not_found = "Mit diesem Handy ist kein iFruit-Account verknüpft.", invalid_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 = {
|
||||
admin = {
|
||||
name = "Kommandozentrale", subtitle = "Geschützte Administration", navigation = "Admin-Navigation", refresh = "Admin-Daten aktualisieren", loading = "Geschützte Daten werden geladen...",
|
||||
tabs = { players = "Spieler", audit = "Audit" },
|
||||
overview = { eyebrow = "Live-Betrieb", title = "Kommandozentrale", body = "Verwalte aktive Handys, App-Zugriffe und geschützte Accountdaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts" },
|
||||
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", 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" },
|
||||
devices = { eyebrow = "Gerätesteuerung", title = "Handys", choose = "Handy auswählen", empty = "Kein Handy gefunden", emptyBody = "Dieser Spieler besitzt aktuell kein verwaltbares Handy.", noNumber = "Keine Telefonnummer", noSim = "Keine SIM", imei = "IMEI", updated = "Letzte Aktivität" },
|
||||
credentials = { eyebrow = "Geschützte Daten", title = "Zugangsdaten", email = "iFruit-E-Mail", password = "iFruit-Passwort", reveal = "Anzeigen", copy = "Passwort kopieren", copied = "Passwort kopiert.", noAccount = "Mit diesem Handy ist kein iFruit-Account verknüpft.", passcode = "Gerätecode", passcodeHashed = "{length}-stellige PIN · sicher gehasht und nicht wiederherstellbar", passcodeDisabled = "Kein Gerätecode eingerichtet", revealTitle = "Geschütztes Passwort anzeigen?", revealBody = "Diese Aktion wird serverseitig autorisiert, rate-limitiert und im Admin-Audit protokolliert.", cancel = "Abbrechen", confirmReveal = "Passwort anzeigen" },
|
||||
apps = { eyebrow = "Fernverwaltung", title = "App-Zugriff", search = "Apps suchen", installed = "Installiert", available = "Verfügbar", grant = "Installieren", revoke = "Entfernen", granted = "App wurde auf dem ausgewählten Handy installiert.", revoked = "App wurde vom ausgewählten Handy entfernt." },
|
||||
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" } },
|
||||
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_request = "Die Admin-Anfrage war ungültig.", device_locked = "Entsperre dein Handy, bevor du das Admin-Panel nutzt.", request_failed = "Die Admin-Anfrage ist fehlgeschlagen.", default = "Das Admin-Panel ist vorübergehend nicht verfügbar." },
|
||||
},
|
||||
health = {
|
||||
name = "Gesundheit",
|
||||
navigation = "Gesundheitsnavigation",
|
||||
|
||||
@@ -201,20 +201,21 @@ Locales["en"] = {
|
||||
contacts = { name = "Favorites", description = "Call or message your favorite contacts.", choose = "Favorite Contacts" },
|
||||
},
|
||||
},
|
||||
AdminPanel = {
|
||||
name = "Command Center", subtitle = "Protected administration", navigation = "Admin navigation", refresh = "Refresh admin data", loading = "Loading protected data...",
|
||||
tabs = { players = "Players", audit = "Audit" },
|
||||
overview = { eyebrow = "Live operations", title = "Command Center", body = "Manage active phone devices, app access, and protected account data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts" },
|
||||
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
|
||||
search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" },
|
||||
detail = { character = "Character profile", data = "Player data overview", cash = "Cash", bank = "Bank", job = "Job", duty = "Duty", onDuty = "On duty", offDuty = "Off duty", identity = "Identity", playerData = "Player data", identifier = "Character identifier", birthdate = "Birthdate", grade = "Job grade", unknown = "Unknown" },
|
||||
devices = { eyebrow = "Device control", title = "Phones", choose = "Choose phone", empty = "No phone found", emptyBody = "This player currently has no phone device that can be managed.", noNumber = "No phone number", noSim = "No SIM", imei = "IMEI", updated = "Last activity" },
|
||||
credentials = { eyebrow = "Protected data", title = "Credentials", email = "iFruit email", password = "iFruit password", reveal = "Reveal", copy = "Copy password", copied = "Password copied.", noAccount = "No iFruit account is linked to this phone.", passcode = "Device passcode", passcodeHashed = "{length}-digit PIN · securely hashed and not recoverable", passcodeDisabled = "No passcode configured", revealTitle = "Reveal protected password?", revealBody = "This action is server-authorized, rate-limited, and written to the admin audit log.", cancel = "Cancel", confirmReveal = "Reveal password" },
|
||||
apps = { eyebrow = "Remote management", title = "App access", description = "Stage app access for this device. Nothing changes until you save.", installed = "Installed", available = "Available", protected = "System app", changes = "{count} pending changes" },
|
||||
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" } },
|
||||
editor = { brand = "SKY PHONE", workspace = "ADMIN WORKSPACE", players = "Player directory", audit = "Audit log", selectPlayer = "Select a player to inspect identity, devices, credentials, and app access.", save = "Save changes", saveHint = "Apply pending changes", saved = "Changes saved.", unsaved = "Unsaved changes", close = "Close admin panel", refresh = "Refresh live data", online = "LIVE", profile = "PROFILE", financial = "FINANCIAL", device = "DEVICE", security = "SECURITY", noAutoSave = "Manual save", noAutoSaveBody = "Changes stay local until the green check is pressed.", discardTitle = "Discard unsaved changes?", discardBody = "Your staged app changes have not been saved.", keepEditing = "Keep editing", discard = "Discard changes", saveFailed = "Some changes could not be saved.", noSelection = "No player selected" },
|
||||
errors = { not_authorized = "You do not have access to the admin panel.", rate_limited = "Too many admin requests. Please wait.", player_unavailable = "That player is no longer online.", device_not_owned = "That phone no longer belongs to the selected player.", invalid_app = "That app is not registered on the server.", app_protected = "This system app cannot be removed.", revision_conflict = "The phone changed in the meantime. Refresh and try again.", account_not_found = "No iFruit account is linked to this phone.", invalid_request = "The admin request was invalid.", request_failed = "The admin request failed.", default = "The admin panel is temporarily unavailable." },
|
||||
},
|
||||
Apps = {
|
||||
admin = {
|
||||
name = "Command Center", subtitle = "Protected administration", navigation = "Admin navigation", refresh = "Refresh admin data", loading = "Loading protected data...",
|
||||
tabs = { players = "Players", audit = "Audit" },
|
||||
overview = { eyebrow = "Live operations", title = "Command Center", body = "Manage active phone devices, app access, and protected account data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts" },
|
||||
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", 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" },
|
||||
devices = { eyebrow = "Device control", title = "Phones", choose = "Choose phone", empty = "No phone found", emptyBody = "This player currently has no phone device that can be managed.", noNumber = "No phone number", noSim = "No SIM", imei = "IMEI", updated = "Last activity" },
|
||||
credentials = { eyebrow = "Protected data", title = "Credentials", email = "iFruit email", password = "iFruit password", reveal = "Reveal", copy = "Copy password", copied = "Password copied.", noAccount = "No iFruit account is linked to this phone.", passcode = "Device passcode", passcodeHashed = "{length}-digit PIN · securely hashed and not recoverable", passcodeDisabled = "No passcode configured", revealTitle = "Reveal protected password?", revealBody = "This action is server-authorized, rate-limited, and written to the admin audit log.", cancel = "Cancel", confirmReveal = "Reveal password" },
|
||||
apps = { eyebrow = "Remote management", title = "App access", search = "Search apps", installed = "Installed", available = "Available", grant = "Install", revoke = "Remove", granted = "App installed on the selected phone.", revoked = "App removed from the selected phone." },
|
||||
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" } },
|
||||
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_request = "The admin request was invalid.", device_locked = "Unlock your phone before using the admin panel.", request_failed = "The admin request failed.", default = "The admin panel is temporarily unavailable." },
|
||||
},
|
||||
health = {
|
||||
name = "Health",
|
||||
navigation = "Health navigation",
|
||||
|
||||
@@ -4,6 +4,7 @@ local blocked_phone_controls = { 24, 140, 141, 142, 257, 263, 264 }
|
||||
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
|
||||
local focused_control_groups = { 0, 1, 2 }
|
||||
local state = {
|
||||
admin_panel_open = false,
|
||||
activity_suspended = false,
|
||||
allow_movement = Config.Phone.AllowMovement,
|
||||
call_focus = false,
|
||||
@@ -42,6 +43,15 @@ function SkyPhoneFocus.ApplyGameInputControls(block_look)
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.Resolve(state)
|
||||
if state.admin_panel_open then
|
||||
return {
|
||||
block_game = true,
|
||||
cursor = true,
|
||||
focused = true,
|
||||
game_input = false,
|
||||
keep_input = false,
|
||||
}
|
||||
end
|
||||
if state.activity_suspended then
|
||||
return { block_game = false, cursor = false, focused = false, game_input = false, keep_input = false }
|
||||
end
|
||||
@@ -112,6 +122,15 @@ function SkyPhoneFocus.SetPhone(open, cursor_disabled)
|
||||
SkyPhoneFocus.Reapply()
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.SetAdminPanel(open)
|
||||
state.admin_panel_open = open == true
|
||||
if state.admin_panel_open then
|
||||
state.notification_focus = false
|
||||
state.text_input_focused = false
|
||||
end
|
||||
SkyPhoneFocus.Reapply()
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.SetCall(active)
|
||||
state.call_focus = active == true
|
||||
SkyPhoneFocus.Reapply()
|
||||
@@ -142,6 +161,7 @@ function SkyPhoneFocus.SetExternalGameInput(owner_resource, allow_game_input)
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.Reset()
|
||||
state.admin_panel_open = false
|
||||
state.activity_suspended = false
|
||||
state.call_focus = false
|
||||
state.camera_active = false
|
||||
|
||||
@@ -8,7 +8,7 @@ local equipped_phone_number = nil
|
||||
local nui_generation = 0
|
||||
local live_activity_active = false
|
||||
local open_home_requested = false
|
||||
local requested_app_id = nil
|
||||
local admin_panel_open = false
|
||||
|
||||
local function get_equipped_phone_number()
|
||||
if not device_payload or not device_payload.device.sim then
|
||||
@@ -61,28 +61,30 @@ end
|
||||
SkyPhoneClient.GetState = get_phone_state
|
||||
SkyPhoneClient.GetEquippedPhoneNumber = get_authoritative_phone_number
|
||||
|
||||
local function open_requested_app()
|
||||
if not requested_app_id then
|
||||
return
|
||||
end
|
||||
|
||||
local app_id = requested_app_id
|
||||
requested_app_id = nil
|
||||
local opened, error_code = SkyPhoneNavigation.Open(app_id)
|
||||
if not opened then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Could not open requested app '%s': %s.",
|
||||
app_id,
|
||||
tostring(error_code)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true })
|
||||
|
||||
local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
||||
|
||||
local function send_admin_panel_open()
|
||||
SendNUIMessage({
|
||||
type = "admin:open",
|
||||
data = {
|
||||
lang = locale_name,
|
||||
locales = locale.Nui,
|
||||
fallbackLocales = Locales.en.Nui,
|
||||
},
|
||||
})
|
||||
end
|
||||
|
||||
local function close_admin_panel()
|
||||
if not admin_panel_open then
|
||||
return
|
||||
end
|
||||
admin_panel_open = false
|
||||
SkyPhoneFocus.SetAdminPanel(false)
|
||||
SendNUIMessage({ type = "admin:close" })
|
||||
end
|
||||
|
||||
local function send_open_message()
|
||||
if not device_payload then
|
||||
return
|
||||
@@ -114,7 +116,6 @@ local function close_phone(close_device_session)
|
||||
local was_open = is_open
|
||||
open_requested = false
|
||||
open_without_focus = false
|
||||
requested_app_id = nil
|
||||
TriggerEvent("sky_phone:animation:phone", false)
|
||||
is_open = false
|
||||
if was_open then
|
||||
@@ -244,6 +245,9 @@ RegisterNUICallback("ui:ready", function(data, cb)
|
||||
if open_requested and device_payload then
|
||||
send_open_message()
|
||||
end
|
||||
if admin_panel_open then
|
||||
send_admin_panel_open()
|
||||
end
|
||||
SkyPhoneCalls.ReplayNui()
|
||||
SkyPhoneSimPicker.ReplayNui()
|
||||
SkyPhoneFocus.Reapply()
|
||||
@@ -286,15 +290,16 @@ RegisterNUICallback("ui:opened", function(data, cb)
|
||||
end
|
||||
SkyPhoneFocus.SetPhone(true, open_without_focus)
|
||||
TriggerEvent("sky_phone:animation:phone", true)
|
||||
open_requested_app()
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:admin:launch", function()
|
||||
requested_app_id = "admin"
|
||||
if is_open then
|
||||
open_requested_app()
|
||||
if is_open or open_requested then
|
||||
close_phone()
|
||||
end
|
||||
admin_panel_open = true
|
||||
SkyPhoneFocus.SetAdminPanel(true)
|
||||
send_admin_panel_open()
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:admin:command-error", function(error_code)
|
||||
@@ -307,12 +312,23 @@ RegisterNetEvent("sky_phone:admin:command-error", function(error_code)
|
||||
)
|
||||
end)
|
||||
|
||||
RegisterNUICallback("admin:close", function(data, cb)
|
||||
if type(data) ~= "table" then
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
close_admin_panel()
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback("ui:input-focus", function(data, cb)
|
||||
if type(data) ~= "table" or type(data.active) ~= "boolean" then
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
SkyPhoneFocus.SetTextInputFocused(data.active and (is_open or open_requested))
|
||||
SkyPhoneFocus.SetTextInputFocused(
|
||||
data.active and (is_open or open_requested or admin_panel_open)
|
||||
)
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
@@ -374,11 +390,9 @@ RegisterNetEvent("sky_phone:device:invalidated", function()
|
||||
update_equipped_phone_number(nil)
|
||||
live_activity_active = false
|
||||
open_home_requested = false
|
||||
requested_app_id = nil
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:device:error", function(error_code)
|
||||
requested_app_id = nil
|
||||
if not is_open and not open_requested then
|
||||
open_without_focus = false
|
||||
end
|
||||
@@ -416,7 +430,7 @@ AddEventHandler("onResourceStop", function(resource_name)
|
||||
is_open = false
|
||||
open_requested = false
|
||||
open_without_focus = false
|
||||
requested_app_id = nil
|
||||
admin_panel_open = false
|
||||
|
||||
TriggerEvent("sky_phone:animation:reset")
|
||||
SkyPhoneCalls.Reset()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
local callback_groups = {
|
||||
account = [[login register logout devices remove-device]],
|
||||
admin = [[bootstrap player set-app reveal-password]],
|
||||
admin = [[bootstrap player save-apps reveal-password]],
|
||||
banking = [[overview transfer]],
|
||||
billing = [[overview list detail markRead pay dispute]],
|
||||
calendar = [[list create update delete]],
|
||||
|
||||
@@ -97,10 +97,6 @@ local function player_name(source)
|
||||
end
|
||||
|
||||
local function require_admin(source, operation, maximum)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return nil, error_response
|
||||
end
|
||||
if not Config.AdminPanel.Enabled
|
||||
or not Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)
|
||||
then
|
||||
@@ -110,9 +106,36 @@ local function require_admin(source, operation, maximum)
|
||||
if not SkyPhone.AllowOperation(source, "admin_" .. operation, maximum, 60) then
|
||||
return nil, { success = false, error = "rate_limited" }
|
||||
end
|
||||
return session
|
||||
return true
|
||||
end
|
||||
|
||||
if type(Config.AdminPanel.Command) ~= "string" or Config.AdminPanel.Command == "" then
|
||||
error("[sky_phone] Config.AdminPanel.Command must be a non-empty command name.")
|
||||
end
|
||||
|
||||
RegisterCommand(Config.AdminPanel.Command, function(command_source)
|
||||
local player_source = tonumber(command_source)
|
||||
if not player_source or player_source < 1 then
|
||||
Bridge.Debug("warn", "[sky_phone] The admin panel command can only be used by a player.")
|
||||
return
|
||||
end
|
||||
if not Config.AdminPanel.Enabled then
|
||||
TriggerClientEvent("sky_phone:admin:command-error", player_source, "disabled")
|
||||
return
|
||||
end
|
||||
if not Bridge.Framework.HasAdminGroup(player_source, Config.AdminPanel.AdminGroups) then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Rejected admin panel command from source %s.",
|
||||
tostring(player_source)
|
||||
)
|
||||
TriggerClientEvent("sky_phone:admin:command-error", player_source, "not_authorized")
|
||||
return
|
||||
end
|
||||
|
||||
TriggerClientEvent("sky_phone:admin:launch", player_source)
|
||||
end, false)
|
||||
|
||||
local function normalize_source(value)
|
||||
local player_source = tonumber(value)
|
||||
if not player_source or player_source < 1 or player_source ~= math.floor(player_source) then
|
||||
@@ -301,6 +324,7 @@ local function load_player_detail(source)
|
||||
money = {
|
||||
bank = tonumber(Bridge.Framework.GetMoney(source, "bank")) or 0,
|
||||
cash = tonumber(Bridge.Framework.GetMoney(source, "cash")) or 0,
|
||||
currency = Config.Banking.Currency,
|
||||
},
|
||||
devices = load_player_devices(source, identifier),
|
||||
}
|
||||
@@ -395,12 +419,12 @@ local function load_audit()
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:admin:bootstrap", function(source)
|
||||
local session, error_response = require_admin(
|
||||
local authorized, error_response = require_admin(
|
||||
source,
|
||||
"bootstrap",
|
||||
Config.AdminPanel.ReadRequestsPerMinute
|
||||
)
|
||||
if not session then
|
||||
if not authorized then
|
||||
return error_response
|
||||
end
|
||||
|
||||
@@ -425,12 +449,12 @@ Bridge.Callbacks.Register("sky_phone:admin:bootstrap", function(source)
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:admin:player", function(source, data)
|
||||
local session, error_response = require_admin(
|
||||
local authorized, error_response = require_admin(
|
||||
source,
|
||||
"player",
|
||||
Config.AdminPanel.ReadRequestsPerMinute
|
||||
)
|
||||
if not session then
|
||||
if not authorized then
|
||||
return error_response
|
||||
end
|
||||
local target_source = normalize_source(data and data.source)
|
||||
@@ -440,19 +464,23 @@ Bridge.Callbacks.Register("sky_phone:admin:player", function(source, data)
|
||||
return { success = true, data = load_player_detail(target_source) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:admin:set-app", function(source, data)
|
||||
local session, error_response = require_admin(
|
||||
Bridge.Callbacks.Register("sky_phone:admin:save-apps", function(source, data)
|
||||
local authorized, error_response = require_admin(
|
||||
source,
|
||||
"set_app",
|
||||
"save_apps",
|
||||
Config.AdminPanel.ActionRequestsPerMinute
|
||||
)
|
||||
if not session then
|
||||
if not authorized then
|
||||
return error_response
|
||||
end
|
||||
if type(data) ~= "table"
|
||||
or not SkyPhoneImei.IsValid(data.imei)
|
||||
or type(data.appId) ~= "string"
|
||||
or type(data.installed) ~= "boolean"
|
||||
or type(data.revision) ~= "number"
|
||||
or data.revision < 0
|
||||
or data.revision ~= math.floor(data.revision)
|
||||
or type(data.changes) ~= "table"
|
||||
or #data.changes < 1
|
||||
or #data.changes > 128
|
||||
then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
@@ -465,12 +493,32 @@ Bridge.Callbacks.Register("sky_phone:admin:set-app", function(source, data)
|
||||
if not target_device then
|
||||
return { success = false, error = "device_not_owned" }
|
||||
end
|
||||
local metadata = app_metadata(data.appId)
|
||||
if not metadata then
|
||||
return { success = false, error = "invalid_app" }
|
||||
end
|
||||
if not data.installed and not metadata.removable then
|
||||
return { success = false, error = "app_protected" }
|
||||
local normalized_changes = {}
|
||||
local seen_apps = {}
|
||||
for index = 1, #data.changes do
|
||||
local change = data.changes[index]
|
||||
if type(change) ~= "table"
|
||||
or type(change.appId) ~= "string"
|
||||
or #change.appId < 1
|
||||
or #change.appId > 64
|
||||
or type(change.installed) ~= "boolean"
|
||||
or seen_apps[change.appId]
|
||||
then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local metadata = app_metadata(change.appId)
|
||||
if not metadata then
|
||||
return { success = false, error = "invalid_app" }
|
||||
end
|
||||
if not change.installed and not metadata.removable then
|
||||
return { success = false, error = "app_protected" }
|
||||
end
|
||||
seen_apps[change.appId] = true
|
||||
normalized_changes[#normalized_changes + 1] = {
|
||||
appId = change.appId,
|
||||
installed = change.installed,
|
||||
metadata = metadata,
|
||||
}
|
||||
end
|
||||
|
||||
local rows = Bridge.Database.Query([[
|
||||
@@ -479,15 +527,23 @@ Bridge.Callbacks.Register("sky_phone:admin:set-app", function(source, data)
|
||||
WHERE `device_imei` = ? AND `namespace` = 'apps'
|
||||
LIMIT 1
|
||||
]], { data.imei })
|
||||
local current_revision = tonumber(rows[1] and rows[1].revision) or 0
|
||||
if current_revision ~= data.revision then
|
||||
return { success = false, error = "revision_conflict" }
|
||||
end
|
||||
|
||||
local payload, claimed_apps, uninstalled_apps = load_app_payload(rows[1] and rows[1].payload)
|
||||
if data.installed then
|
||||
uninstalled_apps = remove_app_id(uninstalled_apps, data.appId)
|
||||
if not metadata.defaultInstalled then
|
||||
claimed_apps = add_app_id(claimed_apps, data.appId)
|
||||
for index = 1, #normalized_changes do
|
||||
local change = normalized_changes[index]
|
||||
if change.installed then
|
||||
uninstalled_apps = remove_app_id(uninstalled_apps, change.appId)
|
||||
if not change.metadata.defaultInstalled then
|
||||
claimed_apps = add_app_id(claimed_apps, change.appId)
|
||||
end
|
||||
else
|
||||
claimed_apps = remove_app_id(claimed_apps, change.appId)
|
||||
uninstalled_apps = add_app_id(uninstalled_apps, change.appId)
|
||||
end
|
||||
else
|
||||
claimed_apps = remove_app_id(claimed_apps, data.appId)
|
||||
uninstalled_apps = add_app_id(uninstalled_apps, data.appId)
|
||||
end
|
||||
payload.claimedApps = claimed_apps
|
||||
payload.uninstalledApps = #uninstalled_apps > 0 and uninstalled_apps or nil
|
||||
@@ -498,12 +554,11 @@ Bridge.Callbacks.Register("sky_phone:admin:set-app", function(source, data)
|
||||
end
|
||||
|
||||
if rows[1] then
|
||||
local revision = tonumber(rows[1].revision) or 0
|
||||
local result = Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_device_data`
|
||||
SET `payload` = ?, `revision` = `revision` + 1
|
||||
WHERE `device_imei` = ? AND `namespace` = 'apps' AND `revision` = ?
|
||||
]], { encoded, data.imei, revision })
|
||||
]], { encoded, data.imei, data.revision })
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "revision_conflict" }
|
||||
end
|
||||
@@ -517,25 +572,28 @@ Bridge.Callbacks.Register("sky_phone:admin:set-app", function(source, data)
|
||||
end
|
||||
end
|
||||
|
||||
write_audit(
|
||||
source,
|
||||
target_source,
|
||||
target_identifier,
|
||||
data.imei,
|
||||
data.installed and "grant_app" or "revoke_app",
|
||||
{ appId = data.appId }
|
||||
)
|
||||
for index = 1, #normalized_changes do
|
||||
local change = normalized_changes[index]
|
||||
write_audit(
|
||||
source,
|
||||
target_source,
|
||||
target_identifier,
|
||||
data.imei,
|
||||
change.installed and "grant_app" or "revoke_app",
|
||||
{ appId = change.appId }
|
||||
)
|
||||
end
|
||||
SkyPhone.RefreshDevice(data.imei)
|
||||
return { success = true, data = load_player_detail(target_source) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:admin:reveal-password", function(source, data)
|
||||
local session, error_response = require_admin(
|
||||
local authorized, error_response = require_admin(
|
||||
source,
|
||||
"reveal_password",
|
||||
Config.AdminPanel.CredentialRevealsPerMinute
|
||||
)
|
||||
if not session then
|
||||
if not authorized then
|
||||
return error_response
|
||||
end
|
||||
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
|
||||
|
||||
@@ -2,10 +2,6 @@ local phone_open_handler
|
||||
local pending_phone_opens = {}
|
||||
local server_started = false
|
||||
|
||||
if type(Config.AdminPanel.Command) ~= "string" or Config.AdminPanel.Command == "" then
|
||||
error("[sky_phone] Config.AdminPanel.Command must be a non-empty command name.")
|
||||
end
|
||||
|
||||
local function flush_pending_phone_opens()
|
||||
if not server_started or not phone_open_handler then
|
||||
return
|
||||
@@ -575,11 +571,6 @@ local function bootstrap(source, security, security_loaded)
|
||||
firstName = trim(Bridge.Framework.GetFirstname(source)) or "",
|
||||
lastName = trim(Bridge.Framework.GetLastname(source)) or "",
|
||||
},
|
||||
permissions = {
|
||||
adminPanel = Config.AdminPanel.Enabled
|
||||
and Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)
|
||||
or false,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
@@ -919,32 +910,6 @@ end
|
||||
phone_open_handler = open_phone
|
||||
flush_pending_phone_opens()
|
||||
|
||||
RegisterCommand(Config.AdminPanel.Command, function(command_source)
|
||||
local player_source = tonumber(command_source)
|
||||
if not player_source or player_source < 1 then
|
||||
Bridge.Debug("warn", "[sky_phone] The admin panel command can only be used by a player.")
|
||||
return
|
||||
end
|
||||
if not Config.AdminPanel.Enabled then
|
||||
TriggerClientEvent("sky_phone:admin:command-error", player_source, "disabled")
|
||||
return
|
||||
end
|
||||
if not Bridge.Framework.HasAdminGroup(player_source, Config.AdminPanel.AdminGroups) then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Rejected admin panel command from source %s.",
|
||||
tostring(player_source)
|
||||
)
|
||||
TriggerClientEvent("sky_phone:admin:command-error", player_source, "not_authorized")
|
||||
return
|
||||
end
|
||||
|
||||
TriggerClientEvent("sky_phone:admin:launch", player_source)
|
||||
if not sessions[player_source] then
|
||||
open_phone(player_source, nil)
|
||||
end
|
||||
end, false)
|
||||
|
||||
function SkyPhone.OpenDeviceForCall(source, imei)
|
||||
local matches = find_device_slots(source, imei)
|
||||
if not matches[1] then
|
||||
|
||||
Reference in New Issue
Block a user