mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84cd233da8 | ||
|
|
5dba13ce4b | ||
|
|
33e6b1aa46 |
@@ -26,100 +26,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
## Inter 4.1
|
|
||||||
|
|
||||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
|
||||||
|
|
||||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
||||||
This license is copied below, and is also available with a FAQ at:
|
|
||||||
http://scripts.sil.org/OFL
|
|
||||||
|
|
||||||
-----------------------------------------------------------
|
|
||||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
||||||
-----------------------------------------------------------
|
|
||||||
|
|
||||||
PREAMBLE
|
|
||||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
||||||
development of collaborative font projects, to support the font creation
|
|
||||||
efforts of academic and linguistic communities, and to provide a free and
|
|
||||||
open framework in which fonts may be shared and improved in partnership
|
|
||||||
with others.
|
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
|
||||||
redistributed and/or sold with any software provided that any reserved
|
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
|
||||||
however, cannot be released under any other type of license. The
|
|
||||||
requirement for fonts to remain under this license does not apply
|
|
||||||
to any document created using the fonts or their derivatives.
|
|
||||||
|
|
||||||
DEFINITIONS
|
|
||||||
"Font Software" refers to the set of files released by the Copyright
|
|
||||||
Holder(s) under this license and clearly marked as such. This may
|
|
||||||
include source files, build scripts and documentation.
|
|
||||||
|
|
||||||
"Reserved Font Name" refers to any names specified as such after the
|
|
||||||
copyright statement(s).
|
|
||||||
|
|
||||||
"Original Version" refers to the collection of Font Software components as
|
|
||||||
distributed by the Copyright Holder(s).
|
|
||||||
|
|
||||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
||||||
or substituting -- in part or in whole -- any of the components of the
|
|
||||||
Original Version, by changing formats or by porting the Font Software to a
|
|
||||||
new environment.
|
|
||||||
|
|
||||||
"Author" refers to any designer, engineer, programmer, technical
|
|
||||||
writer or other person who contributed to the Font Software.
|
|
||||||
|
|
||||||
PERMISSION AND CONDITIONS
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
||||||
redistribute, and sell modified and unmodified copies of the Font
|
|
||||||
Software, subject to the following conditions:
|
|
||||||
|
|
||||||
1) Neither the Font Software nor any of its individual components,
|
|
||||||
in Original or Modified Versions, may be sold by itself.
|
|
||||||
|
|
||||||
2) Original or Modified Versions of the Font Software may be bundled,
|
|
||||||
redistributed and/or sold with any software, provided that each copy
|
|
||||||
contains the above copyright notice and this license. These can be
|
|
||||||
included either as stand-alone text files, human-readable headers or
|
|
||||||
in the appropriate machine-readable metadata fields within text or
|
|
||||||
binary files as long as those fields can be easily viewed by the user.
|
|
||||||
|
|
||||||
3) No Modified Version of the Font Software may use the Reserved Font
|
|
||||||
Name(s) unless explicit written permission is granted by the corresponding
|
|
||||||
Copyright Holder. This restriction only applies to the primary font name as
|
|
||||||
presented to the users.
|
|
||||||
|
|
||||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
||||||
Software shall not be used to promote, endorse or advertise any
|
|
||||||
Modified Version, except to acknowledge the contribution(s) of the
|
|
||||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
||||||
permission.
|
|
||||||
|
|
||||||
5) The Font Software, modified or unmodified, in part or in whole,
|
|
||||||
must be distributed entirely under this license, and must not be
|
|
||||||
distributed under any other license. The requirement for fonts to
|
|
||||||
remain under this license does not apply to any document created
|
|
||||||
using the Font Software.
|
|
||||||
|
|
||||||
TERMINATION
|
|
||||||
This license becomes null and void if any of the above conditions are not met.
|
|
||||||
|
|
||||||
DISCLAIMER
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
||||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
||||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
|
||||||
DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
|
|
||||||
## Framework7 documentation placeholder media
|
## Framework7 documentation placeholder media
|
||||||
|
|
||||||
The development-only Sky UI Kitchen Sink includes placeholder images mirrored
|
The development-only Sky UI Kitchen Sink includes placeholder images mirrored
|
||||||
|
|||||||
+3
-36
@@ -11,7 +11,6 @@ import {
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { SkyProvider } from '@/ui'
|
import { SkyProvider } from '@/ui'
|
||||||
import AdminPanel from '@/components/AdminPanel.vue'
|
|
||||||
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
|
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
|
||||||
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
|
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
|
||||||
import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'
|
import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'
|
||||||
@@ -115,13 +114,8 @@ type AppMessage = {
|
|||||||
| CustomAppCatalogEventData
|
| CustomAppCatalogEventData
|
||||||
| CustomAppEventData
|
| CustomAppEventData
|
||||||
| NavigationEventData
|
| NavigationEventData
|
||||||
| AdminPanelOpenPayload
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminPanelOpenPayload = Required<
|
|
||||||
Pick<PhoneOpenPayload, 'fallbackLocales' | 'lang' | 'locales'>
|
|
||||||
>
|
|
||||||
|
|
||||||
type CustomAppCatalogEventData = {
|
type CustomAppCatalogEventData = {
|
||||||
apps?: unknown
|
apps?: unknown
|
||||||
}
|
}
|
||||||
@@ -361,9 +355,6 @@ const appTransitionName = computed(() =>
|
|||||||
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
|
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
|
||||||
)
|
)
|
||||||
const isLocked = ref(false)
|
const isLocked = ref(false)
|
||||||
const adminPanelOpen = ref(
|
|
||||||
isDevelopment && developmentParameters.has('adminPanel'),
|
|
||||||
)
|
|
||||||
const springboardEditing = ref(false)
|
const springboardEditing = ref(false)
|
||||||
const isUnlocking = ref(false)
|
const isUnlocking = ref(false)
|
||||||
const passcodeBusy = ref(false)
|
const passcodeBusy = ref(false)
|
||||||
@@ -636,8 +627,6 @@ function loadUnlockedPhoneData(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function completePhoneSetup(): void {
|
function completePhoneSetup(): void {
|
||||||
const requestedRoute = pendingUnlockRoute.value
|
|
||||||
pendingUnlockRoute.value = null
|
|
||||||
setupPreviewDismissed.value = true
|
setupPreviewDismissed.value = true
|
||||||
setupAppearanceSelected.value = false
|
setupAppearanceSelected.value = false
|
||||||
isLocked.value = false
|
isLocked.value = false
|
||||||
@@ -645,7 +634,7 @@ function completePhoneSetup(): void {
|
|||||||
passcodeVisible.value = false
|
passcodeVisible.value = false
|
||||||
passcodeRequired.value = false
|
passcodeRequired.value = false
|
||||||
controlCenterOpened.value = false
|
controlCenterOpened.value = false
|
||||||
void router.replace(requestedRoute ?? '/')
|
void router.replace('/')
|
||||||
loadUnlockedPhoneData()
|
loadUnlockedPhoneData()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,15 +718,7 @@ function openDevelopmentPayphonePreview(): void {
|
|||||||
function onMessage(event: MessageEvent<AppMessage>): void {
|
function onMessage(event: MessageEvent<AppMessage>): void {
|
||||||
if (!isTrustedRootMessageSource(event.source, window)) return
|
if (!isTrustedRootMessageSource(event.source, window)) return
|
||||||
|
|
||||||
if (event.data?.type === 'admin:open') {
|
if (event.data?.type === 'custom-apps:catalog') {
|
||||||
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)
|
appCatalog.replaceCatalog(event.data.data)
|
||||||
const catalogPayload = event.data.data as
|
const catalogPayload = event.data.data as
|
||||||
| { apps?: unknown; debug?: unknown }
|
| { apps?: unknown; debug?: unknown }
|
||||||
@@ -782,12 +763,7 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
|||||||
isPhoneAppId(data.appId) &&
|
isPhoneAppId(data.appId) &&
|
||||||
appStore.isInstalled(data.appId)
|
appStore.isInstalled(data.appId)
|
||||||
) {
|
) {
|
||||||
const requestedRoute = `/apps/${data.appId}`
|
void router.push(`/apps/${data.appId}`)
|
||||||
if (setupRequired.value || isLocked.value) {
|
|
||||||
pendingUnlockRoute.value = requestedRoute
|
|
||||||
} else {
|
|
||||||
void router.push(requestedRoute)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.error('[Navigation] Ignored an unavailable app target.')
|
console.error('[Navigation] Ignored an unavailable app target.')
|
||||||
}
|
}
|
||||||
@@ -1750,15 +1726,6 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 />
|
<PhoneMediaCapture />
|
||||||
<PhoneMemoRecorder />
|
<PhoneMemoRecorder />
|
||||||
<RadioHud />
|
<RadioHud />
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -211,11 +211,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
:root {
|
:root {
|
||||||
font-family: var(--sky-font-family);
|
font-family:
|
||||||
font-feature-settings:
|
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue',
|
||||||
'liga' 1,
|
sans-serif;
|
||||||
'calt' 1;
|
|
||||||
font-optical-sizing: auto;
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@@ -236,9 +234,7 @@ body,
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
button,
|
button,
|
||||||
input,
|
input {
|
||||||
textarea,
|
|
||||||
select {
|
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
@@ -1302,7 +1298,8 @@ button {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
color: #f5f5f7;
|
color: #f5f5f7;
|
||||||
font-family: var(--sky-font-family);
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
|
||||||
}
|
}
|
||||||
.darkchat-page button,
|
.darkchat-page button,
|
||||||
.darkchat-page input,
|
.darkchat-page input,
|
||||||
@@ -2823,6 +2820,7 @@ button {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.app-icon--calculator {
|
.app-icon--calculator {
|
||||||
background: linear-gradient(145deg, #76767b, #1b1b1d);
|
background: linear-gradient(145deg, #76767b, #1b1b1d);
|
||||||
@@ -7581,17 +7579,18 @@ button {
|
|||||||
min-height: var(--springboard-grid-row);
|
min-height: var(--springboard-grid-row);
|
||||||
}
|
}
|
||||||
.app-icon-button {
|
.app-icon-button {
|
||||||
font-family: var(--sky-font-family);
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
.app-icon-label {
|
.app-icon-label {
|
||||||
min-height: var(--sky-home-label-height);
|
min-height: 15px;
|
||||||
font-size: var(--sky-home-label-font-size);
|
font-size: 11.5px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: -0.15px;
|
letter-spacing: -0.15px;
|
||||||
line-height: var(--sky-home-label-height);
|
line-height: 15px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.springboard-edit-add {
|
.springboard-edit-add {
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
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 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',
|
|
||||||
)
|
|
||||||
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('uses a compact transparent shell with dedicated admin workspaces', () => {
|
|
||||||
expect(source).toContain('background: transparent')
|
|
||||||
expect(source).toContain('width: min(76vw, 1220px)')
|
|
||||||
expect(source).toContain('height: min(74vh, 700px)')
|
|
||||||
expect(source).toContain('--admin-row-hover: linear-gradient')
|
|
||||||
expect(source).toContain('--admin-row-active: linear-gradient')
|
|
||||||
expect(source).toContain('background: var(--admin-nav-active)')
|
|
||||||
expect(source).not.toContain('admin-panel-brand__mark')
|
|
||||||
expect(source).not.toContain('admin-panel-profile-heading__status')
|
|
||||||
expect(source).not.toContain('backdrop-filter: blur(2px)')
|
|
||||||
|
|
||||||
for (const tab of [
|
|
||||||
'overview',
|
|
||||||
'players',
|
|
||||||
'devices',
|
|
||||||
'apps',
|
|
||||||
'accounts',
|
|
||||||
'messages',
|
|
||||||
'calls',
|
|
||||||
'moderation',
|
|
||||||
'audit',
|
|
||||||
]) {
|
|
||||||
expect(source).toContain(`selectTab('${tab}')`)
|
|
||||||
expect(source).toContain(`t('tabs.${tab}')`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('removes manual reload and applies a persistent global accent choice', () => {
|
|
||||||
expect(source).not.toContain('<RefreshCw')
|
|
||||||
expect(source).not.toContain("kind: 'refresh'")
|
|
||||||
expect(source).toContain("'sky-phone-admin-accent'")
|
|
||||||
expect(source).toContain(':style="{ \'--admin-accent\': accentColor }"')
|
|
||||||
expect(source).toContain('color-mix(in srgb, var(--admin-green)')
|
|
||||||
})
|
|
||||||
|
|
||||||
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 = [[')
|
|
||||||
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(
|
|
||||||
'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
@@ -328,10 +328,7 @@ onBeforeUnmount(() => {
|
|||||||
class="app-icon"
|
class="app-icon"
|
||||||
:class="[
|
:class="[
|
||||||
app.iconClass,
|
app.iconClass,
|
||||||
{
|
{ 'app-icon--image': !iconFailed && app.id !== 'calendar' },
|
||||||
'app-icon--image':
|
|
||||||
!iconFailed && Boolean(app.iconImage) && app.id !== 'calendar',
|
|
||||||
},
|
|
||||||
]"
|
]"
|
||||||
:style="iconStyle"
|
:style="iconStyle"
|
||||||
>
|
>
|
||||||
@@ -340,7 +337,7 @@ onBeforeUnmount(() => {
|
|||||||
<b>{{ calendarDay }}</b>
|
<b>{{ calendarDay }}</b>
|
||||||
</span>
|
</span>
|
||||||
<img
|
<img
|
||||||
v-else-if="!iconFailed && app.iconImage"
|
v-else-if="!iconFailed"
|
||||||
:src="app.iconImage"
|
:src="app.iconImage"
|
||||||
alt=""
|
alt=""
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
|||||||
@@ -331,7 +331,8 @@ function finishPageSwipe(event: PointerEvent): void {
|
|||||||
z-index: 70;
|
z-index: 70;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: var(--sky-font-family);
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-folder-backdrop {
|
.home-folder-backdrop {
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ onBeforeUnmount(() => {
|
|||||||
rgb(30 38 42 / 35%),
|
rgb(30 38 42 / 35%),
|
||||||
rgb(0 0 0 / 84%) 72%
|
rgb(0 0 0 / 84%) 72%
|
||||||
);
|
);
|
||||||
font-family: var(--sky-font-family);
|
font-family: 'Segoe UI', Arial, sans-serif;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ onBeforeUnmount(() => {
|
|||||||
z-index: 40;
|
z-index: 40;
|
||||||
display: flex;
|
display: flex;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
font-family: var(--sky-font-family);
|
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio-hud[data-horizontal='left'] {
|
.radio-hud[data-horizontal='left'] {
|
||||||
|
|||||||
@@ -677,7 +677,9 @@ onBeforeUnmount(() => {
|
|||||||
backdrop-filter: blur(26px) saturate(125%);
|
backdrop-filter: blur(26px) saturate(125%);
|
||||||
-webkit-backdrop-filter: blur(26px) saturate(125%);
|
-webkit-backdrop-filter: blur(26px) saturate(125%);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--sky-font-family);
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
|
||||||
|
'Segoe UI', sans-serif;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
|||||||
@@ -748,7 +748,6 @@ export function getPhoneAppLabel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isPhoneAppRemovable(app: PhoneAppDefinition): boolean {
|
export function isPhoneAppRemovable(app: PhoneAppDefinition): boolean {
|
||||||
if (app.adminOnly) return false
|
|
||||||
return app.kind === 'external'
|
return app.kind === 'external'
|
||||||
? app.removable && !app.defaultInstalled
|
? app.removable && !app.defaultInstalled
|
||||||
: !DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
: !DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
||||||
|
|||||||
@@ -29,10 +29,4 @@ describe('neutral phone navigation contract', () => {
|
|||||||
expect(navigationSource).toContain('if not installed_apps[normalized_app_id] then')
|
expect(navigationSource).toContain('if not installed_apps[normalized_app_id] then')
|
||||||
expect(navigationSource).toContain('if current_app_id ~= normalized_app_id then')
|
expect(navigationSource).toContain('if current_app_id ~= normalized_app_id then')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('defers command-driven app routes until setup or device unlock completes', () => {
|
|
||||||
expect(appSource).toContain('if (setupRequired.value || isLocked.value)')
|
|
||||||
expect(appSource).toContain('pendingUnlockRoute.value = requestedRoute')
|
|
||||||
expect(appSource).toContain("void router.replace(requestedRoute ?? '/')")
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
import { defineStore } from 'pinia'
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AdminAuditEntry,
|
|
||||||
AdminActivityResponse,
|
|
||||||
AdminBootstrap,
|
|
||||||
AdminCallActivity,
|
|
||||||
AdminCredential,
|
|
||||||
AdminMessageActivity,
|
|
||||||
AdminPlayerDetail,
|
|
||||||
AdminPlayerSummary,
|
|
||||||
AdminStats,
|
|
||||||
} from '@/types/admin'
|
|
||||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
|
||||||
|
|
||||||
const EMPTY_STATS: AdminStats = { accounts: 0, devices: 0, online: 0 }
|
|
||||||
|
|
||||||
export const useAdminStore = defineStore('admin', {
|
|
||||||
state: () => ({
|
|
||||||
actionKey: '',
|
|
||||||
activityKey: '',
|
|
||||||
audit: [] as AdminAuditEntry[],
|
|
||||||
detailLoading: false,
|
|
||||||
error: '',
|
|
||||||
initialized: false,
|
|
||||||
loading: false,
|
|
||||||
players: [] as AdminPlayerSummary[],
|
|
||||||
revealedCredentials: {} as Record<string, AdminCredential>,
|
|
||||||
deviceActivity: {} as Record<
|
|
||||||
string,
|
|
||||||
{ calls?: AdminCallActivity[]; messages?: AdminMessageActivity[] }
|
|
||||||
>,
|
|
||||||
selectedPlayer: null as AdminPlayerDetail | null,
|
|
||||||
stats: { ...EMPTY_STATS },
|
|
||||||
}),
|
|
||||||
actions: {
|
|
||||||
async load(): Promise<boolean> {
|
|
||||||
this.loading = true
|
|
||||||
const response = await nuiCall<AdminBootstrap>('admin:bootstrap')
|
|
||||||
this.loading = false
|
|
||||||
if (!response.success || !response.data) {
|
|
||||||
this.error = response.error ?? 'request_failed'
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
this.players = response.data.players
|
|
||||||
this.stats = response.data.stats
|
|
||||||
this.audit = response.data.audit
|
|
||||||
this.error = ''
|
|
||||||
this.initialized = true
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
async openPlayer(source: number): Promise<boolean> {
|
|
||||||
this.detailLoading = true
|
|
||||||
this.revealedCredentials = {}
|
|
||||||
const response = await nuiCall<AdminPlayerDetail>('admin:player', {
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
this.detailLoading = false
|
|
||||||
if (!response.success || !response.data) {
|
|
||||||
this.error = response.error ?? 'request_failed'
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
this.selectedPlayer = response.data
|
|
||||||
this.error = ''
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
closePlayer(): void {
|
|
||||||
this.selectedPlayer = null
|
|
||||||
this.revealedCredentials = {}
|
|
||||||
},
|
|
||||||
async saveApps(
|
|
||||||
source: number,
|
|
||||||
imei: string,
|
|
||||||
revision: number,
|
|
||||||
changes: Array<{ appId: string; installed: boolean }>,
|
|
||||||
): Promise<NuiResponse<AdminPlayerDetail>> {
|
|
||||||
this.actionKey = `${imei}:save`
|
|
||||||
const response = await nuiCall<AdminPlayerDetail>('admin:save-apps', {
|
|
||||||
changes,
|
|
||||||
imei,
|
|
||||||
revision,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
this.actionKey = ''
|
|
||||||
if (response.success && response.data) {
|
|
||||||
this.selectedPlayer = response.data
|
|
||||||
this.error = ''
|
|
||||||
} else {
|
|
||||||
this.error = response.error ?? 'request_failed'
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
},
|
|
||||||
async revealPassword(
|
|
||||||
source: number,
|
|
||||||
imei: string,
|
|
||||||
): Promise<NuiResponse<AdminCredential>> {
|
|
||||||
this.actionKey = `${imei}:password`
|
|
||||||
const response = await nuiCall<AdminCredential>('admin:reveal-password', {
|
|
||||||
imei,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
this.actionKey = ''
|
|
||||||
if (response.success && response.data) {
|
|
||||||
this.revealedCredentials[imei] = response.data
|
|
||||||
this.error = ''
|
|
||||||
} else {
|
|
||||||
this.error = response.error ?? 'request_failed'
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
},
|
|
||||||
async loadActivity(
|
|
||||||
source: number,
|
|
||||||
imei: string,
|
|
||||||
kind: 'messages' | 'calls',
|
|
||||||
): Promise<boolean> {
|
|
||||||
this.activityKey = `${imei}:${kind}`
|
|
||||||
const response = await nuiCall<AdminActivityResponse>('admin:activity', {
|
|
||||||
imei,
|
|
||||||
kind,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
this.activityKey = ''
|
|
||||||
if (!response.success || !response.data) {
|
|
||||||
this.error = response.error ?? 'request_failed'
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const activity = this.deviceActivity[imei] ?? {}
|
|
||||||
if (response.data.kind === 'messages') {
|
|
||||||
activity.messages = response.data.entries
|
|
||||||
} else {
|
|
||||||
activity.calls = response.data.entries
|
|
||||||
}
|
|
||||||
this.deviceActivity[imei] = activity
|
|
||||||
this.error = ''
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
async resetPasscode(
|
|
||||||
source: number,
|
|
||||||
imei: string,
|
|
||||||
): Promise<NuiResponse<AdminPlayerDetail>> {
|
|
||||||
this.actionKey = `${imei}:reset-passcode`
|
|
||||||
const response = await nuiCall<AdminPlayerDetail>(
|
|
||||||
'admin:reset-passcode',
|
|
||||||
{ imei, source },
|
|
||||||
)
|
|
||||||
this.actionKey = ''
|
|
||||||
if (response.success && response.data) {
|
|
||||||
this.selectedPlayer = response.data
|
|
||||||
delete this.revealedCredentials[imei]
|
|
||||||
this.error = ''
|
|
||||||
} else {
|
|
||||||
this.error = response.error ?? 'request_failed'
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
},
|
|
||||||
async changeNumber(
|
|
||||||
source: number,
|
|
||||||
imei: string,
|
|
||||||
phoneNumber: string,
|
|
||||||
): Promise<NuiResponse<AdminPlayerDetail>> {
|
|
||||||
this.actionKey = `${imei}:change-number`
|
|
||||||
const response = await nuiCall<AdminPlayerDetail>('admin:change-number', {
|
|
||||||
imei,
|
|
||||||
phoneNumber,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
this.actionKey = ''
|
|
||||||
if (response.success && response.data) {
|
|
||||||
this.selectedPlayer = response.data
|
|
||||||
delete this.deviceActivity[imei]
|
|
||||||
this.error = ''
|
|
||||||
} else {
|
|
||||||
this.error = response.error ?? 'request_failed'
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
},
|
|
||||||
async factoryReset(
|
|
||||||
source: number,
|
|
||||||
imei: string,
|
|
||||||
): Promise<NuiResponse<AdminPlayerDetail>> {
|
|
||||||
this.actionKey = `${imei}:factory-reset`
|
|
||||||
const response = await nuiCall<AdminPlayerDetail>('admin:factory-reset', {
|
|
||||||
imei,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
this.actionKey = ''
|
|
||||||
if (response.success && response.data) {
|
|
||||||
this.selectedPlayer = response.data
|
|
||||||
delete this.deviceActivity[imei]
|
|
||||||
delete this.revealedCredentials[imei]
|
|
||||||
this.error = ''
|
|
||||||
} else {
|
|
||||||
this.error = response.error ?? 'request_failed'
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -75,13 +75,6 @@ describe('app store', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('drops the retired admin app from persisted phone layouts', () => {
|
|
||||||
const apps = useAppStoreStore()
|
|
||||||
|
|
||||||
apps.hydrate({ claimedApps: ['admin'] })
|
|
||||||
expect(apps.homeLayout.grid).not.toContain('admin')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('migrates current layouts so dock apps are not repeated in the grid', () => {
|
it('migrates current layouts so dock apps are not repeated in the grid', () => {
|
||||||
const apps = useAppStoreStore()
|
const apps = useAppStoreStore()
|
||||||
|
|
||||||
|
|||||||
@@ -58,12 +58,11 @@ function getDefaultDockIds(): LaunchablePhoneAppId[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
|
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
|
||||||
return PHONE_APPS.filter((app) => {
|
return PHONE_APPS.filter((app) =>
|
||||||
if (app.adminOnly) return false
|
isExternalPhoneApp(app)
|
||||||
return isExternalPhoneApp(app)
|
|
||||||
? app.defaultInstalled
|
? app.defaultInstalled
|
||||||
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
|
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id),
|
||||||
}).map((app) => app.id)
|
).map((app) => app.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
|
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
|
||||||
@@ -238,15 +237,13 @@ export const useAppStoreStore = defineStore('app-store', {
|
|||||||
layoutVersion === 5 ||
|
layoutVersion === 5 ||
|
||||||
layoutVersion === 6
|
layoutVersion === 6
|
||||||
this.claimedApps = Array.isArray(data?.claimedApps)
|
this.claimedApps = Array.isArray(data?.claimedApps)
|
||||||
? data.claimedApps.filter((id): id is LaunchablePhoneAppId => {
|
? data.claimedApps.filter(
|
||||||
if (typeof id !== 'string') return false
|
(id): id is LaunchablePhoneAppId =>
|
||||||
const app = getPhoneApp(id)
|
typeof id === 'string' &&
|
||||||
if (app?.adminOnly) return false
|
(isPhoneAppId(id) ||
|
||||||
return (
|
(supportsPersistedExternalApps &&
|
||||||
isPhoneAppId(id) ||
|
isValidExternalPhoneAppId(id))),
|
||||||
(supportsPersistedExternalApps && isValidExternalPhoneAppId(id))
|
)
|
||||||
)
|
|
||||||
})
|
|
||||||
: []
|
: []
|
||||||
this.uninstalledApps = Array.isArray(data?.uninstalledApps)
|
this.uninstalledApps = Array.isArray(data?.uninstalledApps)
|
||||||
? data.uninstalledApps.filter((id): id is LaunchablePhoneAppId => {
|
? data.uninstalledApps.filter((id): id is LaunchablePhoneAppId => {
|
||||||
@@ -311,10 +308,9 @@ export const useAppStoreStore = defineStore('app-store', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
isInstalled(appId: LaunchablePhoneAppId): boolean {
|
isInstalled(appId: LaunchablePhoneAppId): boolean {
|
||||||
const app = getPhoneApp(appId)
|
|
||||||
if (app?.adminOnly) return false
|
|
||||||
if (this.uninstalledApps.includes(appId)) return false
|
if (this.uninstalledApps.includes(appId)) return false
|
||||||
if (this.claimedApps.includes(appId)) return true
|
if (this.claimedApps.includes(appId)) return true
|
||||||
|
const app = getPhoneApp(appId)
|
||||||
if (!app) return false
|
if (!app) return false
|
||||||
return isExternalPhoneApp(app)
|
return isExternalPhoneApp(app)
|
||||||
? app.defaultInstalled
|
? app.defaultInstalled
|
||||||
|
|||||||
+36
-439
@@ -805,257 +805,7 @@ const citywarnFallbackLocales = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminPanelFallbackLocales = {
|
|
||||||
name: 'Phone Admin',
|
|
||||||
subtitle: 'Administration',
|
|
||||||
navigation: 'Admin navigation',
|
|
||||||
refresh: 'Refresh admin data',
|
|
||||||
loading: 'Loading protected data...',
|
|
||||||
tabs: {
|
|
||||||
overview: 'Overview',
|
|
||||||
players: 'Players',
|
|
||||||
devices: 'Devices',
|
|
||||||
apps: 'Apps',
|
|
||||||
accounts: 'Accounts',
|
|
||||||
messages: 'Messages',
|
|
||||||
calls: 'Calls',
|
|
||||||
moderation: 'Moderation',
|
|
||||||
audit: 'Audit',
|
|
||||||
},
|
|
||||||
overview: {
|
|
||||||
eyebrow: 'Server',
|
|
||||||
title: 'Dashboard',
|
|
||||||
body: 'Players, devices, apps, and phone data.',
|
|
||||||
stats: 'Server phone statistics',
|
|
||||||
online: 'Online',
|
|
||||||
devices: 'Devices',
|
|
||||||
accounts: 'Accounts',
|
|
||||||
audit: 'Audit entries',
|
|
||||||
control: 'Navigation',
|
|
||||||
features: 'Modules',
|
|
||||||
featuresBody: 'Open an administration module.',
|
|
||||||
recent: 'Recent activity',
|
|
||||||
playerFeature: 'Identity, finances, job, and duty',
|
|
||||||
deviceFeature: 'IMEI, SIM, number, and activity',
|
|
||||||
appFeature: 'Install or remove phone apps',
|
|
||||||
accountFeature: 'Account access and protected credentials',
|
|
||||||
messageFeature: 'Review recent SMS activity',
|
|
||||||
callFeature: 'Review recent call activity',
|
|
||||||
moderationFeature: 'Reset access, number, or device data',
|
|
||||||
auditFeature: 'Review sensitive admin actions',
|
|
||||||
},
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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',
|
|
||||||
body: 'Inspect every phone assigned to the selected player.',
|
|
||||||
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',
|
|
||||||
apps: 'Claimed apps',
|
|
||||||
account: 'Linked account',
|
|
||||||
},
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
activity: {
|
|
||||||
protected: 'Protected activity',
|
|
||||||
messagesTitle: 'Messages',
|
|
||||||
messagesBody: 'Recent SMS activity for the selected SIM.',
|
|
||||||
callsTitle: 'Calls',
|
|
||||||
callsBody: 'Recent call activity for the selected SIM.',
|
|
||||||
loading: 'Loading activity...',
|
|
||||||
incoming: 'Incoming',
|
|
||||||
outgoing: 'Outgoing',
|
|
||||||
mediaMessage: '{type} message',
|
|
||||||
noMessages: 'No message activity found.',
|
|
||||||
noCalls: 'No call activity found.',
|
|
||||||
status: {
|
|
||||||
completed: 'Completed',
|
|
||||||
missed: 'Missed',
|
|
||||||
rejected: 'Rejected',
|
|
||||||
busy: 'Busy',
|
|
||||||
unanswered: 'Unanswered',
|
|
||||||
cancelled: 'Cancelled',
|
|
||||||
failed: 'Failed',
|
|
||||||
ringing: 'Ringing',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
moderation: {
|
|
||||||
eyebrow: 'Device administration',
|
|
||||||
title: 'Moderation actions',
|
|
||||||
body: 'Every action is server-authorized, rate-limited, and audited.',
|
|
||||||
resetPasscode: 'Reset passcode',
|
|
||||||
resetPasscodeBody: 'Remove the device PIN and clear failed attempts.',
|
|
||||||
changeNumber: 'Change number',
|
|
||||||
changeNumberBody: 'Assign a new unique number to the current SIM.',
|
|
||||||
factoryReset: 'Factory reset',
|
|
||||||
factoryResetBody: 'Clear local device data and disconnect the account.',
|
|
||||||
saveFirst: 'Save or discard pending app changes first.',
|
|
||||||
phoneNumber: 'New phone number',
|
|
||||||
phoneNumberPlaceholder: 'Enter the full configured number',
|
|
||||||
typeToConfirm: 'Type {word} to confirm the factory reset.',
|
|
||||||
confirmWord: 'RESET',
|
|
||||||
cancel: 'Cancel',
|
|
||||||
'reset-passcodeSuccess': 'Passcode reset.',
|
|
||||||
'change-numberSuccess': 'Phone number changed.',
|
|
||||||
'factory-resetSuccess': 'Phone factory reset completed.',
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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.',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultLocales: LocaleTree = {
|
const defaultLocales: LocaleTree = {
|
||||||
AdminPanel: adminPanelFallbackLocales,
|
|
||||||
Apps: {
|
Apps: {
|
||||||
citywarn: citywarnFallbackLocales,
|
citywarn: citywarnFallbackLocales,
|
||||||
crypto: cryptoFallbackLocales,
|
crypto: cryptoFallbackLocales,
|
||||||
@@ -2682,190 +2432,46 @@ const defaultLocales: LocaleTree = {
|
|||||||
'neon-drop': 'Neon block-dropping puzzle',
|
'neon-drop': 'Neon block-dropping puzzle',
|
||||||
},
|
},
|
||||||
previews: {
|
previews: {
|
||||||
citywarn: {
|
citywarn: { first: 'Live alerts', second: 'Safety zones', third: 'Incident updates' },
|
||||||
first: 'Live alerts',
|
crypto: { first: 'Synthetic markets', second: 'Portfolio', third: 'Wallet transfers' },
|
||||||
second: 'Safety zones',
|
health: { first: 'Activity rings', second: 'Medical ID', third: 'Health records' },
|
||||||
third: 'Incident updates',
|
'weazel-news': { first: 'Top stories', second: 'Local reports', third: 'Breaking news' },
|
||||||
},
|
companies: { first: 'Business directory', second: 'Job requests', third: 'Services' },
|
||||||
crypto: {
|
music: { first: 'Now playing', second: 'Playlists', third: 'Music library' },
|
||||||
first: 'Synthetic markets',
|
picstagram: { first: 'Photo feed', second: 'Stories', third: 'Profiles' },
|
||||||
second: 'Portfolio',
|
feather: { first: 'Short posts', second: 'Following feed', third: 'Conversations' },
|
||||||
third: 'Wallet transfers',
|
fliptok: { first: 'Video feed', second: 'Creator tools', third: 'Trends' },
|
||||||
},
|
flare: { first: 'Discover people', second: 'Matches', third: 'Live moments' },
|
||||||
health: {
|
calendar: { first: 'Upcoming events', second: 'Day planner', third: 'Reminders' },
|
||||||
first: 'Activity rings',
|
radio: { first: 'Live channels', second: 'Team radio', third: 'Favorites' },
|
||||||
second: 'Medical ID',
|
'local-pages': { first: 'Local pages', second: 'Reviews', third: 'City discovery' },
|
||||||
third: 'Health records',
|
crewlink: { first: 'Crew roster', second: 'Shared locations', third: 'Coordination' },
|
||||||
},
|
phone: { first: 'Recent calls', second: 'Contacts', third: 'Voicemail' },
|
||||||
'weazel-news': {
|
messages: { first: 'Conversations', second: 'Media sharing', third: 'Quick replies' },
|
||||||
first: 'Top stories',
|
darkchat: { first: 'Private chats', second: 'Secure groups', third: 'Invitations' },
|
||||||
second: 'Local reports',
|
garage: { first: 'Vehicle list', second: 'Parking locations', third: 'Valet' },
|
||||||
third: 'Breaking news',
|
house: { first: 'Property access', second: 'Residents', third: 'Management' },
|
||||||
},
|
map: { first: 'Live navigation', second: 'Nearby places', third: 'Route guidance' },
|
||||||
companies: {
|
skyride: { first: 'Ride booking', second: 'Driver tracking', third: 'Trip history' },
|
||||||
first: 'Business directory',
|
banking: { first: 'Account balance', second: 'Transfers', third: 'Transactions' },
|
||||||
second: 'Job requests',
|
billing: { first: 'Open invoices', second: 'Payment requests', third: 'Payment history' },
|
||||||
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' },
|
mail: { first: 'Inbox', second: 'Attachments', third: 'Mailboxes' },
|
||||||
notes: { first: 'Notes', second: 'Checklists', third: 'Pinned ideas' },
|
notes: { first: 'Notes', second: 'Checklists', third: 'Pinned ideas' },
|
||||||
memos: {
|
memos: { first: 'Voice recordings', second: 'Playback', third: 'Favorites' },
|
||||||
first: 'Voice recordings',
|
calculator: { first: 'Basic calculation', second: 'Scientific tools', third: 'History' },
|
||||||
second: 'Playback',
|
camera: { first: 'Photo mode', second: 'Video capture', third: 'Zoom controls' },
|
||||||
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' },
|
clock: { first: 'World clock', second: 'Alarms', third: 'Timers' },
|
||||||
weather: {
|
weather: { first: 'Current weather', second: 'Hourly forecast', third: 'Seven-day outlook' },
|
||||||
first: 'Current weather',
|
photos: { first: 'Media library', second: 'Albums', third: 'Shared media' },
|
||||||
second: 'Hourly forecast',
|
settings: { first: 'Device controls', second: 'Privacy', third: 'Personalization' },
|
||||||
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' },
|
snake: { first: 'High score', second: 'Speed', third: 'Classic grid' },
|
||||||
memory: {
|
memory: { first: 'Matched pairs', second: 'Best time', third: 'Card themes' },
|
||||||
first: 'Matched pairs',
|
'number-merge': { first: 'Highest tile', second: 'Score', third: 'Strategy grid' },
|
||||||
second: 'Best time',
|
minesweeper: { first: 'Mine counter', second: 'Best time', third: 'Difficulty' },
|
||||||
third: 'Card themes',
|
'tower-stack': { first: 'Tower height', second: 'Perfect drops', third: 'High score' },
|
||||||
},
|
'sky-flappy': { first: 'Flight score', second: 'Best run', third: 'Obstacles' },
|
||||||
'number-merge': {
|
citymarkt: { first: 'Listings', second: 'Categories', third: 'Saved offers' },
|
||||||
first: 'Highest tile',
|
'neon-drop': { first: 'Lines cleared', second: 'Level', third: 'Neon pieces' },
|
||||||
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: {
|
search: {
|
||||||
recommended: 'Recommended',
|
recommended: 'Recommended',
|
||||||
@@ -5577,15 +5183,6 @@ export const usePhoneStore = defineStore('phone', {
|
|||||||
this.cameraLandscape = false
|
this.cameraLandscape = false
|
||||||
this.isOpen = 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 {
|
open(payload: PhoneOpenPayload = {}): void {
|
||||||
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
|
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
|
||||||
const nextToken = payload.token ?? this.deviceSessionToken
|
const nextToken = payload.token ?? this.deviceSessionToken
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
export type AdminStats = {
|
|
||||||
accounts: number
|
|
||||||
devices: number
|
|
||||||
online: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminPlayerSummary = {
|
|
||||||
deviceCount: number
|
|
||||||
grade: number
|
|
||||||
identifier: string
|
|
||||||
job: string
|
|
||||||
name: string
|
|
||||||
onDuty: boolean
|
|
||||||
phoneNumber: string | null
|
|
||||||
serverName: string
|
|
||||||
source: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminDevice = {
|
|
||||||
account: {
|
|
||||||
email: string
|
|
||||||
id: number
|
|
||||||
passwordAvailable: boolean
|
|
||||||
} | null
|
|
||||||
apps: {
|
|
||||||
claimed: string[]
|
|
||||||
revision: number
|
|
||||||
uninstalled: string[]
|
|
||||||
}
|
|
||||||
createdAt: string
|
|
||||||
imei: string
|
|
||||||
name: string
|
|
||||||
number: string | null
|
|
||||||
security: {
|
|
||||||
enabled: boolean
|
|
||||||
failedAttempts: number
|
|
||||||
length: number | null
|
|
||||||
lockedUntil: number
|
|
||||||
}
|
|
||||||
simRegistered: boolean
|
|
||||||
simType: string | null
|
|
||||||
updatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminPlayerDetail = {
|
|
||||||
birthdate: string
|
|
||||||
devices: AdminDevice[]
|
|
||||||
firstName: string
|
|
||||||
identifier: string
|
|
||||||
job: {
|
|
||||||
grade: number
|
|
||||||
gradeLabel: string
|
|
||||||
label: string
|
|
||||||
name: string
|
|
||||||
onDuty: boolean
|
|
||||||
}
|
|
||||||
lastName: string
|
|
||||||
money: {
|
|
||||||
bank: number
|
|
||||||
cash: number
|
|
||||||
currency: string
|
|
||||||
}
|
|
||||||
name: string
|
|
||||||
serverName: string
|
|
||||||
source: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminAuditEntry = {
|
|
||||||
action: string
|
|
||||||
actorName: string
|
|
||||||
createdAt: string
|
|
||||||
details: Record<string, unknown>
|
|
||||||
deviceImei: string | null
|
|
||||||
id: number
|
|
||||||
targetIdentifier: string
|
|
||||||
targetSource: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminBootstrap = {
|
|
||||||
audit: AdminAuditEntry[]
|
|
||||||
players: AdminPlayerSummary[]
|
|
||||||
stats: AdminStats
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminCredential = {
|
|
||||||
email: string
|
|
||||||
password: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminMessageActivity = {
|
|
||||||
body: string
|
|
||||||
createdAt: string
|
|
||||||
direction: 'incoming' | 'outgoing'
|
|
||||||
id: string
|
|
||||||
messageType: string
|
|
||||||
otherNumber: string
|
|
||||||
readAt: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminCallActivity = {
|
|
||||||
answeredAt: string | null
|
|
||||||
direction: 'incoming' | 'outgoing'
|
|
||||||
durationSeconds: number
|
|
||||||
endedAt: string | null
|
|
||||||
id: string
|
|
||||||
otherNumber: string
|
|
||||||
startedAt: string
|
|
||||||
status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminActivityResponse =
|
|
||||||
| { entries: AdminMessageActivity[]; kind: 'messages' }
|
|
||||||
| { entries: AdminCallActivity[]; kind: 'calls' }
|
|
||||||
@@ -68,7 +68,6 @@ export type AppLaunchOrigin = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PhoneAppDefinitionBase = {
|
type PhoneAppDefinitionBase = {
|
||||||
adminOnly?: boolean
|
|
||||||
category: PhoneAppCategory
|
category: PhoneAppCategory
|
||||||
dockOrder: number | null
|
dockOrder: number | null
|
||||||
gridOrder: number
|
gridOrder: number
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
|
|
||||||
const sourceDirectory = fileURLToPath(new URL('..', import.meta.url))
|
|
||||||
const tokensSource = readFileSync(new URL('./tokens.css', import.meta.url), 'utf8')
|
|
||||||
const mainCssSource = readFileSync(
|
|
||||||
new URL('../assets/main.css', import.meta.url),
|
|
||||||
'utf8',
|
|
||||||
)
|
|
||||||
|
|
||||||
function styleSources(directory: string): Array<{
|
|
||||||
file: string
|
|
||||||
source: string
|
|
||||||
}> {
|
|
||||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
||||||
const path = join(directory, entry.name)
|
|
||||||
if (entry.isDirectory()) return styleSources(path)
|
|
||||||
if (!/\.(?:css|vue)$/.test(entry.name)) return []
|
|
||||||
return [{ file: path, source: readFileSync(path, 'utf8') }]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Inter font contract', () => {
|
|
||||||
it('bundles normal and italic variable fonts for every supported weight', () => {
|
|
||||||
expect(
|
|
||||||
existsSync(new URL('../assets/fonts/InterVariable.woff2', import.meta.url)),
|
|
||||||
).toBe(true)
|
|
||||||
expect(
|
|
||||||
existsSync(
|
|
||||||
new URL('../assets/fonts/InterVariable-Italic.woff2', import.meta.url),
|
|
||||||
),
|
|
||||||
).toBe(true)
|
|
||||||
expect(tokensSource.match(/@font-face/g)).toHaveLength(2)
|
|
||||||
expect(tokensSource.match(/font-weight:\s*100 900;/g)).toHaveLength(2)
|
|
||||||
expect(tokensSource).toContain("--sky-font-family: 'Inter', Arial, sans-serif;")
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies the shared font to the document and native form controls', () => {
|
|
||||||
expect(mainCssSource).toMatch(
|
|
||||||
/:root\s*\{[\s\S]*?font-family:\s*var\(--sky-font-family\);/,
|
|
||||||
)
|
|
||||||
expect(mainCssSource).toMatch(
|
|
||||||
/button,\s*input,\s*textarea,\s*select\s*\{\s*font:\s*inherit;/,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not bypass the shared token with a generic system UI stack', () => {
|
|
||||||
const genericSystemStack =
|
|
||||||
/font-family\s*:\s*(?:-apple-system|BlinkMacSystemFont|system-ui|ui-sans-serif|['"]Segoe UI['"])/
|
|
||||||
const violations = styleSources(sourceDirectory)
|
|
||||||
.filter(({ source }) => genericSystemStack.test(source))
|
|
||||||
.map(({ file }) => file.slice(sourceDirectory.length + 1))
|
|
||||||
|
|
||||||
expect(violations).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Vendored
+3
-2
@@ -525,8 +525,9 @@
|
|||||||
height: var(--sky-widget-label-height);
|
height: var(--sky-widget-label-height);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--sky-widget-label-color, #fff);
|
color: var(--sky-widget-label-color, #fff);
|
||||||
font-family: var(--sky-font-family);
|
font-family:
|
||||||
font-size: var(--sky-home-label-font-size);
|
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
||||||
|
font-size: 11.5px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: -0.15px;
|
letter-spacing: -0.15px;
|
||||||
line-height: var(--sky-widget-label-height);
|
line-height: var(--sky-widget-label-height);
|
||||||
|
|||||||
@@ -1,19 +1,3 @@
|
|||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('../assets/fonts/InterVariable.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 100 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('../assets/fonts/InterVariable-Italic.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--sky-device-pixel-ratio: 1;
|
--sky-device-pixel-ratio: 1;
|
||||||
--sky-hairline-scale: 1;
|
--sky-hairline-scale: 1;
|
||||||
@@ -42,7 +26,9 @@
|
|||||||
--sky-font-title: 17px;
|
--sky-font-title: 17px;
|
||||||
--sky-font-medium-title: 24px;
|
--sky-font-medium-title: 24px;
|
||||||
--sky-font-large-title: 34px;
|
--sky-font-large-title: 34px;
|
||||||
--sky-font-family: 'Inter', Arial, sans-serif;
|
--sky-font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF UI Text',
|
||||||
|
'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
--sky-transition-fast: 100ms;
|
--sky-transition-fast: 100ms;
|
||||||
--sky-transition-normal: 200ms;
|
--sky-transition-normal: 200ms;
|
||||||
--sky-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
--sky-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
@@ -69,10 +55,8 @@
|
|||||||
--sky-shadow-thumb:
|
--sky-shadow-thumb:
|
||||||
0 0.5px 4px rgba(0, 0, 0, 0.12), 0 6px 13px rgba(0, 0, 0, 0.12);
|
0 0.5px 4px rgba(0, 0, 0, 0.12), 0 6px 13px rgba(0, 0, 0, 0.12);
|
||||||
--sky-glass-highlight-color: rgba(255, 255, 255, 0.5);
|
--sky-glass-highlight-color: rgba(255, 255, 255, 0.5);
|
||||||
--sky-home-label-font-size: 13px;
|
|
||||||
--sky-home-label-height: 16px;
|
|
||||||
--sky-widget-label-gap: 5px;
|
--sky-widget-label-gap: 5px;
|
||||||
--sky-widget-label-height: var(--sky-home-label-height);
|
--sky-widget-label-height: 15px;
|
||||||
--sky-widget-radius-small: 23px;
|
--sky-widget-radius-small: 23px;
|
||||||
--sky-widget-radius-medium: 25px;
|
--sky-widget-radius-medium: 25px;
|
||||||
--sky-widget-radius-large: 28px;
|
--sky-widget-radius-large: 28px;
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ const previewModules = import.meta.glob<string>(
|
|||||||
|
|
||||||
const APP_STORE_PREVIEW_IMAGES = Object.fromEntries(
|
const APP_STORE_PREVIEW_IMAGES = Object.fromEntries(
|
||||||
Object.entries(previewModules).map(([path, imageUrl]) => {
|
Object.entries(previewModules).map(([path, imageUrl]) => {
|
||||||
const appId = path
|
const appId = path.split('/').at(-1)?.replace(/\.jpg$/, '')
|
||||||
.split('/')
|
|
||||||
.at(-1)
|
|
||||||
?.replace(/\.jpg$/, '')
|
|
||||||
if (!appId) throw new Error(`Invalid App Store preview path: ${path}`)
|
if (!appId) throw new Error(`Invalid App Store preview path: ${path}`)
|
||||||
return [appId, imageUrl]
|
return [appId, imageUrl]
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ function escapeRegExp(value: string): string {
|
|||||||
describe('App Store preview catalog', () => {
|
describe('App Store preview catalog', () => {
|
||||||
it('contains a real captured screenshot for every built-in store app', () => {
|
it('contains a real captured screenshot for every built-in store app', () => {
|
||||||
const storeAppIds = PHONE_APPS.filter(
|
const storeAppIds = PHONE_APPS.filter(
|
||||||
(app) =>
|
(app) => app.kind !== 'external' && app.id !== 'app-store',
|
||||||
app.kind !== 'external' && app.id !== 'app-store' && !app.adminOnly,
|
|
||||||
).map((app) => app.id)
|
).map((app) => app.id)
|
||||||
|
|
||||||
expect([...APP_STORE_PREVIEW_IMAGE_IDS].sort()).toEqual(storeAppIds.sort())
|
expect([...APP_STORE_PREVIEW_IMAGE_IDS].sort()).toEqual(storeAppIds.sort())
|
||||||
@@ -26,8 +25,7 @@ describe('App Store preview catalog', () => {
|
|||||||
|
|
||||||
it('provides specialized preview data for every built-in store app', () => {
|
it('provides specialized preview data for every built-in store app', () => {
|
||||||
const storeAppIds = PHONE_APPS.filter(
|
const storeAppIds = PHONE_APPS.filter(
|
||||||
(app) =>
|
(app) => app.kind !== 'external' && app.id !== 'app-store',
|
||||||
app.kind !== 'external' && app.id !== 'app-store' && !app.adminOnly,
|
|
||||||
).map((app) => app.id)
|
).map((app) => app.id)
|
||||||
|
|
||||||
expect([...PREVIEWABLE_BUILTIN_APP_IDS].sort()).toEqual(
|
expect([...PREVIEWABLE_BUILTIN_APP_IDS].sort()).toEqual(
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const launchStyle = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="app && !app.adminOnly"
|
v-if="app"
|
||||||
class="app-window"
|
class="app-window"
|
||||||
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
|
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
|
||||||
:style="launchStyle"
|
:style="launchStyle"
|
||||||
|
|||||||
@@ -19,31 +19,11 @@ const mainCss = readFileSync(
|
|||||||
new URL('../assets/main.css', import.meta.url),
|
new URL('../assets/main.css', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
)
|
)
|
||||||
const tokensCss = readFileSync(
|
|
||||||
new URL('../ui/tokens.css', import.meta.url),
|
|
||||||
'utf8',
|
|
||||||
)
|
|
||||||
const foundationCss = readFileSync(
|
|
||||||
new URL('../ui/foundation.css', import.meta.url),
|
|
||||||
'utf8',
|
|
||||||
)
|
|
||||||
const builtInWallpaperCss = mainCss.slice(
|
const builtInWallpaperCss = mainCss.slice(
|
||||||
mainCss.indexOf('.wallpaper--midnight'),
|
mainCss.indexOf('.wallpaper--midnight'),
|
||||||
mainCss.indexOf('.wallpaper--custom'),
|
mainCss.indexOf('.wallpaper--custom'),
|
||||||
)
|
)
|
||||||
|
|
||||||
describe('Springboard page swipe contract', () => {
|
describe('Springboard page swipe contract', () => {
|
||||||
it('keeps app and widget labels on the larger shared home typography', () => {
|
|
||||||
expect(tokensCss).toContain('--sky-home-label-font-size: 13px;')
|
|
||||||
expect(tokensCss).toContain('--sky-home-label-height: 16px;')
|
|
||||||
expect(mainCss).toMatch(
|
|
||||||
/\.app-icon-label\s*\{[\s\S]*?font-size:\s*var\(--sky-home-label-font-size\);/,
|
|
||||||
)
|
|
||||||
expect(foundationCss).toMatch(
|
|
||||||
/\.sky-widget-frame__label\s*\{[\s\S]*?font-size:\s*var\(--sky-home-label-font-size\);/,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps the built-in wallpapers visually restrained', () => {
|
it('keeps the built-in wallpapers visually restrained', () => {
|
||||||
expect(builtInWallpaperCss).not.toMatch(/(?:conic|repeating-\w+)-gradient/)
|
expect(builtInWallpaperCss).not.toMatch(/(?:conic|repeating-\w+)-gradient/)
|
||||||
expect(builtInWallpaperCss.match(/radial-gradient/g)).toHaveLength(12)
|
expect(builtInWallpaperCss.match(/radial-gradient/g)).toHaveLength(12)
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ const downloadDateDescription = computed(() =>
|
|||||||
)
|
)
|
||||||
const catalog = computed(() =>
|
const catalog = computed(() =>
|
||||||
PHONE_APPS.filter((app): app is LaunchablePhoneAppDefinition => {
|
PHONE_APPS.filter((app): app is LaunchablePhoneAppDefinition => {
|
||||||
if (!isLaunchablePhoneApp(app) || app.id === 'app-store' || app.adminOnly) {
|
if (!isLaunchablePhoneApp(app) || app.id === 'app-store') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +140,6 @@ const dailyCandidates = computed(() =>
|
|||||||
PHONE_APPS.filter(
|
PHONE_APPS.filter(
|
||||||
(app): app is LaunchablePhoneAppDefinition =>
|
(app): app is LaunchablePhoneAppDefinition =>
|
||||||
isLaunchablePhoneApp(app) &&
|
isLaunchablePhoneApp(app) &&
|
||||||
!app.adminOnly &&
|
|
||||||
!isExternalPhoneApp(app) &&
|
!isExternalPhoneApp(app) &&
|
||||||
app.id !== 'app-store' &&
|
app.id !== 'app-store' &&
|
||||||
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
||||||
|
|||||||
@@ -791,7 +791,7 @@ onBeforeUnmount(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
color: #f6f7f9;
|
color: #f6f7f9;
|
||||||
background: #07090c;
|
background: #07090c;
|
||||||
font-family: var(--sky-font-family);
|
font-family: Inter, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
.billing-app--light {
|
.billing-app--light {
|
||||||
--billing-border: rgb(15 23 42 / 10%);
|
--billing-border: rgb(15 23 42 / 10%);
|
||||||
|
|||||||
@@ -1156,7 +1156,13 @@ onMounted(async () => {
|
|||||||
padding: 47px 0 24px;
|
padding: 47px 0 24px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--label);
|
color: var(--label);
|
||||||
font-family: var(--sky-font-family);
|
font-family:
|
||||||
|
Inter,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'SF Pro Display',
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar--light {
|
.calendar--light {
|
||||||
|
|||||||
@@ -2195,7 +2195,7 @@ onMounted(async () => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #151613;
|
background: #151613;
|
||||||
color: #f8f8f4;
|
color: #f8f8f4;
|
||||||
font-family: var(--sky-font-family);
|
font-family: Inter, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
.citymarkt--light {
|
.citymarkt--light {
|
||||||
--ink: #fff;
|
--ink: #fff;
|
||||||
|
|||||||
@@ -3586,7 +3586,7 @@ onMounted(async () => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #12171b !important;
|
background: #12171b !important;
|
||||||
color: #f7f8f4;
|
color: #f7f8f4;
|
||||||
font-family: var(--sky-font-family);
|
font-family: Inter, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
.feather-app--active.feather-app--light {
|
.feather-app--active.feather-app--light {
|
||||||
--feather-panel: #f0f1ec;
|
--feather-panel: #f0f1ec;
|
||||||
|
|||||||
@@ -3395,7 +3395,7 @@ onBeforeUnmount(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: var(--sky-font-family);
|
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
|
||||||
}
|
}
|
||||||
.state,
|
.state,
|
||||||
.light-empty,
|
.light-empty,
|
||||||
|
|||||||
@@ -1526,7 +1526,7 @@ onMounted(async () => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #12171b;
|
background: #12171b;
|
||||||
color: #f7f7f2;
|
color: #f7f7f2;
|
||||||
font-family: var(--sky-font-family);
|
font-family: Inter, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
.pages--light {
|
.pages--light {
|
||||||
--yellow: #8a6500;
|
--yellow: #8a6500;
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ onBeforeUnmount(() => {
|
|||||||
background:
|
background:
|
||||||
radial-gradient(circle at 88% 8%, rgb(255 255 255 / 72%), transparent 28%),
|
radial-gradient(circle at 88% 8%, rgb(255 255 255 / 72%), transparent 28%),
|
||||||
linear-gradient(155deg, #f4efff 0%, #e7ddff 55%, #d9cdf7 100%);
|
linear-gradient(155deg, #f4efff 0%, #e7ddff 55%, #d9cdf7 100%);
|
||||||
font-family: var(--sky-font-family);
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -420,7 +420,7 @@ onBeforeUnmount(() => {
|
|||||||
background:
|
background:
|
||||||
radial-gradient(circle at 88% 7%, rgb(108 231 218 / 32%), transparent 30%),
|
radial-gradient(circle at 88% 7%, rgb(108 231 218 / 32%), transparent 30%),
|
||||||
linear-gradient(155deg, #effcf7 0%, #cfeee5 52%, #abdcd7 100%);
|
linear-gradient(155deg, #effcf7 0%, #cfeee5 52%, #abdcd7 100%);
|
||||||
font-family: var(--sky-font-family);
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ onBeforeUnmount(() => {
|
|||||||
padding: 52px 14px 25px;
|
padding: 52px 14px 25px;
|
||||||
color: #f5fbff;
|
color: #f5fbff;
|
||||||
background: radial-gradient(circle at 50% 0, #253163, #090c22 58%, #050715);
|
background: radial-gradient(circle at 50% 0, #253163, #090c22 58%, #050715);
|
||||||
font-family: var(--sky-font-family);
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
|||||||
background:
|
background:
|
||||||
radial-gradient(circle at 86% 8%, rgb(255 192 94 / 28%), transparent 29%),
|
radial-gradient(circle at 86% 8%, rgb(255 192 94 / 28%), transparent 29%),
|
||||||
linear-gradient(155deg, #fff3dc 0%, #f3d8b7 55%, #e8b98d 100%);
|
linear-gradient(155deg, #fff3dc 0%, #f3d8b7 55%, #e8b98d 100%);
|
||||||
font-family: var(--sky-font-family);
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.flappy-app { --sky-a:#50d8f2;--sky-b:#765ce8;--tower:#574be8;--tower-light:#9b94ff;--tower-dark:#3429a6;--tower-glow:#79e7ff; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:var(--sky-font-family);user-select:none;touch-action:manipulation; }
|
.flappy-app { --sky-a:#50d8f2;--sky-b:#765ce8;--tower:#574be8;--tower-light:#9b94ff;--tower-dark:#3429a6;--tower-glow:#79e7ff; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;user-select:none;touch-action:manipulation; }
|
||||||
.flappy-app--playing { padding:0; }
|
.flappy-app--playing { padding:0; }
|
||||||
.flappy-app--neon { --sky-a:#151c58;--sky-b:#a329a2;--tower:#19dfe6;--tower-light:#82ffff;--tower-dark:#087f91;--tower-glow:#26fbff; }.flappy-app--storm { --sky-a:#6f8497;--sky-b:#2b3955;--tower:#df765f;--tower-light:#ffb18e;--tower-dark:#8d3e39;--tower-glow:#ff997d; }
|
.flappy-app--neon { --sky-a:#151c58;--sky-b:#a329a2;--tower:#19dfe6;--tower-light:#82ffff;--tower-dark:#087f91;--tower-glow:#26fbff; }.flappy-app--storm { --sky-a:#6f8497;--sky-b:#2b3955;--tower:#df765f;--tower-light:#ffb18e;--tower-dark:#8d3e39;--tower-glow:#ff997d; }
|
||||||
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button:not(.sky-button--glass),.flappy-toolbar button:not(.sky-button--glass){width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}.flappy-header .sky-button--glass,.flappy-toolbar .sky-button--glass{color:#fff}
|
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button:not(.sky-button--glass),.flappy-toolbar button:not(.sky-button--glass){width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}.flappy-header .sky-button--glass,.flappy-toolbar .sky-button--glass{color:#fff}
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ onBeforeUnmount(() => {
|
|||||||
background:
|
background:
|
||||||
radial-gradient(circle at 85% 8%, rgb(100 211 91 / 22%), transparent 30%),
|
radial-gradient(circle at 85% 8%, rgb(100 211 91 / 22%), transparent 30%),
|
||||||
linear-gradient(165deg, #142b25 0%, #0c1715 62%, #08100f 100%);
|
linear-gradient(165deg, #142b25 0%, #0c1715 62%, #08100f 100%);
|
||||||
font-family: var(--sky-font-family);
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.tower-app { position: absolute; inset: 0; overflow: hidden; padding: 52px 16px 27px; color: #eef5ff; background: radial-gradient(circle at 75% 8%, #7146c866, transparent 35%), linear-gradient(170deg, #161634, #242054 52%, #10132c); font-family: var(--sky-font-family); user-select: none; touch-action: manipulation; }
|
.tower-app { position: absolute; inset: 0; overflow: hidden; padding: 52px 16px 27px; color: #eef5ff; background: radial-gradient(circle at 75% 8%, #7146c866, transparent 35%), linear-gradient(170deg, #161634, #242054 52%, #10132c); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; user-select: none; touch-action: manipulation; }
|
||||||
.tower-app--playing { padding: 0; }
|
.tower-app--playing { padding: 0; }
|
||||||
.tower-header { height: 50px; display: flex; align-items: center; justify-content: space-between; }
|
.tower-header { height: 50px; display: flex; align-items: center; justify-content: space-between; }
|
||||||
.tower-header span { display: block; color: #c1b8f1; font-size: 10px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
|
.tower-header span { display: block; color: #c1b8f1; font-size: 10px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
|
||||||
|
|||||||
@@ -3098,7 +3098,9 @@ const deviceData = {
|
|||||||
ringtoneVolume: 80,
|
ringtoneVolume: 80,
|
||||||
streamerMode: false,
|
streamerMode: false,
|
||||||
wallpaper: 'custom',
|
wallpaper: 'custom',
|
||||||
wallpaperHistory: [{ imageUrl: demoWallpaperUrl, wallpaper: 'custom' }],
|
wallpaperHistory: [
|
||||||
|
{ imageUrl: demoWallpaperUrl, wallpaper: 'custom' },
|
||||||
|
],
|
||||||
wallpaperImageUrl: demoWallpaperUrl,
|
wallpaperImageUrl: demoWallpaperUrl,
|
||||||
},
|
},
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -4648,100 +4650,6 @@ function companyWorkContext(testScenario = '') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminMockApps = {
|
|
||||||
claimed: ['citymarkt', 'darkchat', 'feather', 'local-pages'],
|
|
||||||
revision: 3,
|
|
||||||
uninstalled: ['crypto', 'skyride'],
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminMockDevices = {
|
|
||||||
1: { account: true, number: '555-0101', security: true },
|
|
||||||
2: { account: true, number: '555-0102', security: true },
|
|
||||||
}
|
|
||||||
|
|
||||||
function adminMockPlayerDetail(source = 1) {
|
|
||||||
const primary = source === 1
|
|
||||||
const deviceState = adminMockDevices[source] ?? adminMockDevices[1]
|
|
||||||
return {
|
|
||||||
birthdate: primary ? '1994-04-16' : '1998-11-03',
|
|
||||||
devices: [
|
|
||||||
{
|
|
||||||
account: deviceState.account
|
|
||||||
? {
|
|
||||||
email: primary ? 'demo@ifruit.com' : 'jordan@ifruit.com',
|
|
||||||
id: primary ? 1 : 2,
|
|
||||||
passwordAvailable: true,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
apps: { ...adminMockApps },
|
|
||||||
createdAt: '2026-08-15 18:42:00',
|
|
||||||
imei: primary ? '356938035643809' : '356938035643810',
|
|
||||||
name: primary ? 'Personal iFruit Phone' : 'Service iFruit Phone',
|
|
||||||
number: deviceState.number,
|
|
||||||
security: {
|
|
||||||
enabled: deviceState.security,
|
|
||||||
failedAttempts: 0,
|
|
||||||
length: deviceState.security ? 6 : null,
|
|
||||||
lockedUntil: 0,
|
|
||||||
},
|
|
||||||
simRegistered: true,
|
|
||||||
simType: 'standard',
|
|
||||||
updatedAt: '2026-08-20 19:04:00',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
firstName: primary ? 'Alex' : 'Jordan',
|
|
||||||
identifier: primary ? 'char1:demo' : 'char1:jordan',
|
|
||||||
job: {
|
|
||||||
grade: primary ? 4 : 1,
|
|
||||||
gradeLabel: primary ? 'Chief' : 'Officer',
|
|
||||||
label: 'Los Santos Police Department',
|
|
||||||
name: 'police',
|
|
||||||
onDuty: true,
|
|
||||||
},
|
|
||||||
lastName: primary ? 'Morgan' : 'Blake',
|
|
||||||
money: {
|
|
||||||
bank: primary ? 182450 : 28450,
|
|
||||||
cash: primary ? 2740 : 950,
|
|
||||||
currency: '$',
|
|
||||||
},
|
|
||||||
name: primary ? 'Alex Morgan' : 'Jordan Blake',
|
|
||||||
serverName: primary ? 'Skyline' : 'JordanB',
|
|
||||||
source,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function adminMockBootstrap() {
|
|
||||||
return {
|
|
||||||
audit: [
|
|
||||||
{
|
|
||||||
action: 'grant_app',
|
|
||||||
actorName: 'Skyline',
|
|
||||||
createdAt: '2026-08-20 19:04:00',
|
|
||||||
details: { appId: 'darkchat' },
|
|
||||||
deviceImei: '356938035643810',
|
|
||||||
id: 1,
|
|
||||||
targetIdentifier: 'char1:jordan',
|
|
||||||
targetSource: 2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
players: [1, 2].map((source) => {
|
|
||||||
const player = adminMockPlayerDetail(source)
|
|
||||||
return {
|
|
||||||
deviceCount: player.devices.length,
|
|
||||||
grade: player.job.grade,
|
|
||||||
identifier: player.identifier,
|
|
||||||
job: player.job.name,
|
|
||||||
name: player.name,
|
|
||||||
onDuty: player.job.onDuty,
|
|
||||||
phoneNumber: player.devices[0]?.number ?? null,
|
|
||||||
serverName: player.serverName,
|
|
||||||
source,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
stats: { accounts: 24, devices: 31, online: 2 },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }
|
||||||
@@ -4755,128 +4663,6 @@ app.post('/api/:endpoint', async (request, response, next) => {
|
|||||||
response.json({ success: true, data: musicBootstrap() })
|
response.json({ success: true, data: musicBootstrap() })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (endpoint === 'admin:bootstrap') {
|
|
||||||
response.json({ success: true, data: adminMockBootstrap() })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (endpoint === 'admin:player') {
|
|
||||||
response.json({
|
|
||||||
success: true,
|
|
||||||
data: adminMockPlayerDetail(Number(request.body.source) || 1),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
data: adminMockPlayerDetail(Number(request.body.source) || 1),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (endpoint === 'admin:close') {
|
|
||||||
response.json({ success: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (endpoint === 'admin:reveal-password') {
|
|
||||||
response.json({
|
|
||||||
success: true,
|
|
||||||
data: { email: 'demo@ifruit.com', password: 'mock-only-password' },
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (endpoint === 'admin:activity') {
|
|
||||||
if (request.body.kind === 'messages') {
|
|
||||||
response.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
kind: 'messages',
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
body: 'Meet at Mission Row in ten minutes.',
|
|
||||||
createdAt: '2026-08-20 19:03:00',
|
|
||||||
direction: 'outgoing',
|
|
||||||
id: 'admin-message-1',
|
|
||||||
messageType: 'text',
|
|
||||||
otherNumber: '555-0144',
|
|
||||||
readAt: '2026-08-20 19:03:30',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
body: 'Copy, I am on my way.',
|
|
||||||
createdAt: '2026-08-20 18:58:00',
|
|
||||||
direction: 'incoming',
|
|
||||||
id: 'admin-message-2',
|
|
||||||
messageType: 'text',
|
|
||||||
otherNumber: '555-0199',
|
|
||||||
readAt: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
kind: 'calls',
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
answeredAt: '2026-08-20 18:49:05',
|
|
||||||
direction: 'incoming',
|
|
||||||
durationSeconds: 184,
|
|
||||||
endedAt: '2026-08-20 18:52:09',
|
|
||||||
id: 'admin-call-1',
|
|
||||||
otherNumber: '555-0177',
|
|
||||||
startedAt: '2026-08-20 18:49:00',
|
|
||||||
status: 'completed',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
answeredAt: null,
|
|
||||||
direction: 'outgoing',
|
|
||||||
durationSeconds: 0,
|
|
||||||
endedAt: '2026-08-20 17:13:18',
|
|
||||||
id: 'admin-call-2',
|
|
||||||
otherNumber: '555-0112',
|
|
||||||
startedAt: '2026-08-20 17:13:00',
|
|
||||||
status: 'missed',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (endpoint === 'admin:reset-passcode') {
|
|
||||||
const source = Number(request.body.source) || 1
|
|
||||||
adminMockDevices[source].security = false
|
|
||||||
response.json({ success: true, data: adminMockPlayerDetail(source) })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (endpoint === 'admin:change-number') {
|
|
||||||
const source = Number(request.body.source) || 1
|
|
||||||
adminMockDevices[source].number = String(request.body.phoneNumber ?? '')
|
|
||||||
response.json({ success: true, data: adminMockPlayerDetail(source) })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (endpoint === 'admin:factory-reset') {
|
|
||||||
const source = Number(request.body.source) || 1
|
|
||||||
adminMockDevices[source].account = false
|
|
||||||
adminMockDevices[source].security = false
|
|
||||||
response.json({ success: true, data: adminMockPlayerDetail(source) })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (endpoint === 'music:add-youtube') {
|
if (endpoint === 'music:add-youtube') {
|
||||||
const value = String(request.body.url ?? '')
|
const value = String(request.body.url ?? '')
|
||||||
const customTitle = String(request.body.title ?? '').trim()
|
const customTitle = String(request.body.title ?? '').trim()
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
|
||||||
|
|
||||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
||||||
This license is copied below, and is also available with a FAQ at:
|
|
||||||
http://scripts.sil.org/OFL
|
|
||||||
|
|
||||||
-----------------------------------------------------------
|
|
||||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
||||||
-----------------------------------------------------------
|
|
||||||
|
|
||||||
PREAMBLE
|
|
||||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
||||||
development of collaborative font projects, to support the font creation
|
|
||||||
efforts of academic and linguistic communities, and to provide a free and
|
|
||||||
open framework in which fonts may be shared and improved in partnership
|
|
||||||
with others.
|
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
|
||||||
redistributed and/or sold with any software provided that any reserved
|
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
|
||||||
however, cannot be released under any other type of license. The
|
|
||||||
requirement for fonts to remain under this license does not apply
|
|
||||||
to any document created using the fonts or their derivatives.
|
|
||||||
|
|
||||||
DEFINITIONS
|
|
||||||
"Font Software" refers to the set of files released by the Copyright
|
|
||||||
Holder(s) under this license and clearly marked as such. This may
|
|
||||||
include source files, build scripts and documentation.
|
|
||||||
|
|
||||||
"Reserved Font Name" refers to any names specified as such after the
|
|
||||||
copyright statement(s).
|
|
||||||
|
|
||||||
"Original Version" refers to the collection of Font Software components as
|
|
||||||
distributed by the Copyright Holder(s).
|
|
||||||
|
|
||||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
||||||
or substituting -- in part or in whole -- any of the components of the
|
|
||||||
Original Version, by changing formats or by porting the Font Software to a
|
|
||||||
new environment.
|
|
||||||
|
|
||||||
"Author" refers to any designer, engineer, programmer, technical
|
|
||||||
writer or other person who contributed to the Font Software.
|
|
||||||
|
|
||||||
PERMISSION AND CONDITIONS
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
||||||
redistribute, and sell modified and unmodified copies of the Font
|
|
||||||
Software, subject to the following conditions:
|
|
||||||
|
|
||||||
1) Neither the Font Software nor any of its individual components,
|
|
||||||
in Original or Modified Versions, may be sold by itself.
|
|
||||||
|
|
||||||
2) Original or Modified Versions of the Font Software may be bundled,
|
|
||||||
redistributed and/or sold with any software, provided that each copy
|
|
||||||
contains the above copyright notice and this license. These can be
|
|
||||||
included either as stand-alone text files, human-readable headers or
|
|
||||||
in the appropriate machine-readable metadata fields within text or
|
|
||||||
binary files as long as those fields can be easily viewed by the user.
|
|
||||||
|
|
||||||
3) No Modified Version of the Font Software may use the Reserved Font
|
|
||||||
Name(s) unless explicit written permission is granted by the corresponding
|
|
||||||
Copyright Holder. This restriction only applies to the primary font name as
|
|
||||||
presented to the users.
|
|
||||||
|
|
||||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
||||||
Software shall not be used to promote, endorse or advertise any
|
|
||||||
Modified Version, except to acknowledge the contribution(s) of the
|
|
||||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
||||||
permission.
|
|
||||||
|
|
||||||
5) The Font Software, modified or unmodified, in part or in whole,
|
|
||||||
must be distributed entirely under this license, and must not be
|
|
||||||
distributed under any other license. The requirement for fonts to
|
|
||||||
remain under this license does not apply to any document created
|
|
||||||
using the Font Software.
|
|
||||||
|
|
||||||
TERMINATION
|
|
||||||
This license becomes null and void if any of the above conditions are not met.
|
|
||||||
|
|
||||||
DISCLAIMER
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
||||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
||||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
|
||||||
DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
@@ -61,18 +61,6 @@ Config.Security = {
|
|||||||
AttemptsPerMinute = 12,
|
AttemptsPerMinute = 12,
|
||||||
}
|
}
|
||||||
|
|
||||||
Config.AdminPanel = {
|
|
||||||
Enabled = true,
|
|
||||||
Command = "phoneadmin",
|
|
||||||
AdminGroups = { "admin", "superadmin" },
|
|
||||||
MaximumPlayers = 128,
|
|
||||||
ReadRequestsPerMinute = 60,
|
|
||||||
ActionRequestsPerMinute = 30,
|
|
||||||
CredentialRevealsPerMinute = 6,
|
|
||||||
AuditLimit = 40,
|
|
||||||
ActivityLimit = 40,
|
|
||||||
}
|
|
||||||
|
|
||||||
Config.Sim = {
|
Config.Sim = {
|
||||||
Enabled = true, -- false: devices receive a persistent random number automatically; hex/esx require false
|
Enabled = true, -- false: devices receive a persistent random number automatically; hex/esx require false
|
||||||
RegisteredItem = "sky_phone_sim_registered",
|
RegisteredItem = "sky_phone_sim_registered",
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
Locales["de"] = {
|
Locales["de"] = {
|
||||||
CommandDescription = "Öffne dein Handy.",
|
CommandDescription = "Öffne dein Handy.",
|
||||||
AdminCommand = {
|
|
||||||
CommandDescription = "Öffne das geschützte Handy-Admin-Panel.",
|
|
||||||
Errors = {
|
|
||||||
disabled = "Das Handy-Admin-Panel ist deaktiviert.",
|
|
||||||
not_authorized = "Du hast keinen Zugriff auf das Handy-Admin-Panel.",
|
|
||||||
default = "Das Handy-Admin-Panel konnte nicht geöffnet werden.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Controls = {
|
Controls = {
|
||||||
OpenPhone = "Handy öffnen",
|
OpenPhone = "Handy öffnen",
|
||||||
},
|
},
|
||||||
@@ -201,27 +193,6 @@ Locales["de"] = {
|
|||||||
contacts = { name = "Favoriten", description = "Rufe deine Lieblingskontakte an oder schreibe ihnen.", choose = "Lieblingskontakte" },
|
contacts = { name = "Favoriten", description = "Rufe deine Lieblingskontakte an oder schreibe ihnen.", choose = "Lieblingskontakte" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
AdminPanel = {
|
|
||||||
name = "Phone Admin", subtitle = "Administration", navigation = "Admin-Navigation", refresh = "Admin-Daten aktualisieren", loading = "Geschützte Daten werden geladen...",
|
|
||||||
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit" },
|
|
||||||
overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", auditFeature = "Sensible Admin-Aktionen prüfen" },
|
|
||||||
appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } },
|
|
||||||
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", body = "Prüfe alle Handys des ausgewählten Spielers.", 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", apps = "Zugewiesene Apps", account = "Verknüpfter Account" },
|
|
||||||
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" },
|
|
||||||
activity = { protected = "Geschützte Aktivität", messagesTitle = "Nachrichten", messagesBody = "Letzte SMS-Aktivitäten der ausgewählten SIM.", callsTitle = "Anrufe", callsBody = "Letzte Anrufaktivitäten der ausgewählten SIM.", loading = "Aktivitäten werden geladen...", incoming = "Eingehend", outgoing = "Ausgehend", mediaMessage = "{type}-Nachricht", noMessages = "Keine Nachrichtenaktivität gefunden.", noCalls = "Keine Anrufaktivität gefunden.", status = { completed = "Abgeschlossen", missed = "Verpasst", rejected = "Abgelehnt", busy = "Besetzt", unanswered = "Unbeantwortet", cancelled = "Abgebrochen", failed = "Fehlgeschlagen", ringing = "Klingelt" } },
|
|
||||||
moderation = {
|
|
||||||
eyebrow = "Geräteverwaltung", title = "Moderationsaktionen", body = "Jede Aktion wird serverseitig autorisiert, rate-limitiert und protokolliert.", resetPasscode = "Gerätecode zurücksetzen", resetPasscodeBody = "Entfernt die Geräte-PIN und fehlgeschlagene Versuche.", changeNumber = "Nummer ändern", changeNumberBody = "Weist der aktuellen SIM eine neue eindeutige Nummer zu.", factoryReset = "Werksreset", factoryResetBody = "Löscht lokale Gerätedaten und trennt den Account.", saveFirst = "Speichere oder verwirf zuerst offene App-Änderungen.", phoneNumber = "Neue Telefonnummer", phoneNumberPlaceholder = "Vollständige konfigurierte Nummer eingeben", typeToConfirm = "Tippe {word}, um den Werksreset zu bestätigen.", confirmWord = "RESET", cancel = "Abbrechen", ["reset-passcodeSuccess"] = "Gerätecode zurückgesetzt.", ["change-numberSuccess"] = "Telefonnummer geändert.", ["factory-resetSuccess"] = "Werksreset abgeschlossen.",
|
|
||||||
dialogs = { ["reset-passcodeTitle"] = "Gerätecode zurücksetzen?", ["reset-passcodeBody"] = "Der Spieler kann dieses Handy danach ohne die bisherige PIN entsperren.", ["change-numberTitle"] = "Telefonnummer ändern?", ["change-numberBody"] = "Die neue Nummer muss dem Serverformat entsprechen und eindeutig sein.", ["factory-resetTitle"] = "Werksreset für dieses Handy?", ["factory-resetBody"] = "Lokale Gerätedaten, App-Einstellungen, Sicherheit und der verknüpfte Account werden gelöscht. Das kann nicht rückgängig gemacht werden." },
|
|
||||||
confirm = { ["reset-passcode"] = "Gerätecode zurücksetzen", ["change-number"] = "Nummer ändern", ["factory-reset"] = "Werksreset" },
|
|
||||||
},
|
|
||||||
audit = { eyebrow = "Nachvollziehbarkeit", title = "Audit-Verlauf", body = "Sensible Anzeigen und App-Änderungen werden hier protokolliert.", empty = "Noch keine Admin-Aktionen", emptyBody = "Geschützte Aktionen erscheinen hier, nachdem sie ausgeführt wurden.", by = "{actor} · Ziel-ID {target}", actions = { grant_app = "App installiert", revoke_app = "App entfernt", reveal_account_password = "Passwort angezeigt", view_messages = "Nachrichten angesehen", view_calls = "Anrufe angesehen", reset_passcode = "Gerätecode zurückgesetzt", change_number = "Telefonnummer geändert", factory_reset = "Werksreset ausgeführt" } },
|
|
||||||
editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Spielerverzeichnis", audit = "Audit-Protokoll", selectPlayer = "Wähle einen Spieler, um Identität, Geräte, Zugangsdaten und App-Zugriffe zu prüfen.", save = "Änderungen speichern", saveHint = "Offene Änderungen übernehmen", saved = "Änderungen gespeichert.", unsaved = "Ungespeicherte Änderungen", close = "Admin-Panel schließen", refresh = "Live-Daten aktualisieren", online = "LIVE", profile = "PROFIL", financial = "FINANZEN", device = "GERÄT", security = "SICHERHEIT", noAutoSave = "Manuelles Speichern", noAutoSaveBody = "Änderungen bleiben lokal, bis der grüne Haken gedrückt wird.", discardTitle = "Ungespeicherte Änderungen verwerfen?", discardBody = "Deine vorgemerkten App-Änderungen wurden noch nicht gespeichert.", keepEditing = "Weiter bearbeiten", discard = "Änderungen verwerfen", saveFailed = "Einige Änderungen konnten nicht gespeichert werden.", noSelection = "Kein Spieler ausgewählt" },
|
|
||||||
errors = { not_authorized = "Du hast keinen Zugriff auf das Admin-Panel.", rate_limited = "Zu viele Admin-Anfragen. Bitte warte.", player_unavailable = "Dieser Spieler ist nicht mehr online.", device_not_owned = "Dieses Handy gehört nicht mehr zum ausgewählten Spieler.", invalid_app = "Diese App ist nicht auf dem Server registriert.", app_protected = "Diese System-App kann nicht entfernt werden.", revision_conflict = "Das Handy wurde zwischenzeitlich geändert. Aktualisiere und versuche es erneut.", account_not_found = "Mit diesem Handy ist kein iFruit-Account verknüpft.", invalid_phone_number = "Gib eine Telefonnummer im konfigurierten Serverformat ein.", phone_number_unchanged = "Diese SIM verwendet diese Telefonnummer bereits.", phone_number_taken = "Diese Telefonnummer ist bereits vergeben.", no_sim = "Dieses Handy besitzt keine änderbare SIM.", passcode_not_set = "Für dieses Handy ist kein Gerätecode eingerichtet.", device_not_found = "Dieses Handy existiert nicht mehr.", metadata_unsupported = "Die Inventar-Metadaten des Handys konnten nicht aktualisiert werden.", invalid_request = "Die Admin-Anfrage war ungültig.", request_failed = "Die Admin-Anfrage ist fehlgeschlagen.", default = "Das Admin-Panel ist vorübergehend nicht verfügbar." },
|
|
||||||
},
|
|
||||||
Apps = {
|
Apps = {
|
||||||
health = {
|
health = {
|
||||||
name = "Gesundheit",
|
name = "Gesundheit",
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
Locales["en"] = {
|
Locales["en"] = {
|
||||||
CommandDescription = "Open your phone.",
|
CommandDescription = "Open your phone.",
|
||||||
AdminCommand = {
|
|
||||||
CommandDescription = "Open the protected phone admin panel.",
|
|
||||||
Errors = {
|
|
||||||
disabled = "The phone admin panel is disabled.",
|
|
||||||
not_authorized = "You do not have access to the phone admin panel.",
|
|
||||||
default = "The phone admin panel could not be opened.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Controls = {
|
Controls = {
|
||||||
OpenPhone = "Open phone",
|
OpenPhone = "Open phone",
|
||||||
},
|
},
|
||||||
@@ -201,27 +193,6 @@ Locales["en"] = {
|
|||||||
contacts = { name = "Favorites", description = "Call or message your favorite contacts.", choose = "Favorite Contacts" },
|
contacts = { name = "Favorites", description = "Call or message your favorite contacts.", choose = "Favorite Contacts" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
AdminPanel = {
|
|
||||||
name = "Phone Admin", subtitle = "Administration", navigation = "Admin navigation", refresh = "Refresh admin data", loading = "Loading protected data...",
|
|
||||||
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit" },
|
|
||||||
overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", messageFeature = "Review recent SMS activity", callFeature = "Review recent call activity", moderationFeature = "Reset access, number, or device data", auditFeature = "Review sensitive admin actions" },
|
|
||||||
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" } },
|
|
||||||
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", body = "Inspect every phone assigned to the selected player.", 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", apps = "Claimed apps", account = "Linked account" },
|
|
||||||
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" },
|
|
||||||
activity = { protected = "Protected activity", messagesTitle = "Messages", messagesBody = "Recent SMS activity for the selected SIM.", callsTitle = "Calls", callsBody = "Recent call activity for the selected SIM.", loading = "Loading activity...", incoming = "Incoming", outgoing = "Outgoing", mediaMessage = "{type} message", noMessages = "No message activity found.", noCalls = "No call activity found.", status = { completed = "Completed", missed = "Missed", rejected = "Rejected", busy = "Busy", unanswered = "Unanswered", cancelled = "Cancelled", failed = "Failed", ringing = "Ringing" } },
|
|
||||||
moderation = {
|
|
||||||
eyebrow = "Device administration", title = "Moderation actions", body = "Every action is server-authorized, rate-limited, and audited.", resetPasscode = "Reset passcode", resetPasscodeBody = "Remove the device PIN and clear failed attempts.", changeNumber = "Change number", changeNumberBody = "Assign a new unique number to the current SIM.", factoryReset = "Factory reset", factoryResetBody = "Clear local device data and disconnect the account.", saveFirst = "Save or discard pending app changes first.", phoneNumber = "New phone number", phoneNumberPlaceholder = "Enter the full configured number", typeToConfirm = "Type {word} to confirm the factory reset.", confirmWord = "RESET", cancel = "Cancel", ["reset-passcodeSuccess"] = "Passcode reset.", ["change-numberSuccess"] = "Phone number changed.", ["factory-resetSuccess"] = "Phone factory reset completed.",
|
|
||||||
dialogs = { ["reset-passcodeTitle"] = "Reset device passcode?", ["reset-passcodeBody"] = "The player can unlock this phone without the previous PIN afterward.", ["change-numberTitle"] = "Change phone number?", ["change-numberBody"] = "The new number must match the configured server number format and be unique.", ["factory-resetTitle"] = "Factory reset this phone?", ["factory-resetBody"] = "This clears local device data, app settings, security, and the linked account. This cannot be undone." },
|
|
||||||
confirm = { ["reset-passcode"] = "Reset passcode", ["change-number"] = "Change number", ["factory-reset"] = "Factory reset" },
|
|
||||||
},
|
|
||||||
audit = { eyebrow = "Accountability", title = "Audit trail", body = "Sensitive reveals and remote app changes are recorded here.", empty = "No admin actions yet", emptyBody = "Protected actions will appear here after they are performed.", by = "{actor} · target ID {target}", actions = { grant_app = "App installed", revoke_app = "App removed", reveal_account_password = "Password revealed", view_messages = "Messages viewed", view_calls = "Calls viewed", reset_passcode = "Passcode reset", change_number = "Phone number changed", factory_reset = "Phone factory reset" } },
|
|
||||||
editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Player directory", audit = "Audit log", selectPlayer = "Select a player to inspect identity, devices, credentials, and app access.", save = "Save changes", saveHint = "Apply pending changes", saved = "Changes saved.", unsaved = "Unsaved changes", close = "Close admin panel", refresh = "Refresh live data", online = "LIVE", profile = "PROFILE", financial = "FINANCIAL", device = "DEVICE", security = "SECURITY", noAutoSave = "Manual save", noAutoSaveBody = "Changes stay local until the green check is pressed.", discardTitle = "Discard unsaved changes?", discardBody = "Your staged app changes have not been saved.", keepEditing = "Keep editing", discard = "Discard changes", saveFailed = "Some changes could not be saved.", noSelection = "No player selected" },
|
|
||||||
errors = { not_authorized = "You do not have access to the admin panel.", rate_limited = "Too many admin requests. Please wait.", player_unavailable = "That player is no longer online.", device_not_owned = "That phone no longer belongs to the selected player.", invalid_app = "That app is not registered on the server.", app_protected = "This system app cannot be removed.", revision_conflict = "The phone changed in the meantime. Refresh and try again.", account_not_found = "No iFruit account is linked to this phone.", invalid_phone_number = "Enter a phone number in the configured server format.", phone_number_unchanged = "This SIM already uses that phone number.", phone_number_taken = "That phone number is already assigned.", no_sim = "This phone has no SIM that can be changed.", passcode_not_set = "This phone has no passcode configured.", device_not_found = "This phone no longer exists.", metadata_unsupported = "The phone inventory metadata could not be updated.", invalid_request = "The admin request was invalid.", request_failed = "The admin request failed.", default = "The admin panel is temporarily unavailable." },
|
|
||||||
},
|
|
||||||
Apps = {
|
Apps = {
|
||||||
health = {
|
health = {
|
||||||
name = "Health",
|
name = "Health",
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ server_scripts {
|
|||||||
'source/server/phone.lua',
|
'source/server/phone.lua',
|
||||||
'source/server/device_directory.lua',
|
'source/server/device_directory.lua',
|
||||||
'source/server/db_migrate.lua',
|
'source/server/db_migrate.lua',
|
||||||
'source/server/admin.lua',
|
|
||||||
'source/server/lb_phone_migration.lua',
|
'source/server/lb_phone_migration.lua',
|
||||||
'source/server/custom_app_storage.lua',
|
'source/server/custom_app_storage.lua',
|
||||||
'source/server/payphones.lua',
|
'source/server/payphones.lua',
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ local blocked_phone_controls = { 24, 140, 141, 142, 257, 263, 264 }
|
|||||||
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
|
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
|
||||||
local focused_control_groups = { 0, 1, 2 }
|
local focused_control_groups = { 0, 1, 2 }
|
||||||
local state = {
|
local state = {
|
||||||
admin_panel_open = false,
|
|
||||||
activity_suspended = false,
|
activity_suspended = false,
|
||||||
allow_movement = Config.Phone.AllowMovement,
|
allow_movement = Config.Phone.AllowMovement,
|
||||||
call_focus = false,
|
call_focus = false,
|
||||||
@@ -43,15 +42,6 @@ function SkyPhoneFocus.ApplyGameInputControls(block_look)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function SkyPhoneFocus.Resolve(state)
|
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
|
if state.activity_suspended then
|
||||||
return { block_game = false, cursor = false, focused = false, game_input = false, keep_input = false }
|
return { block_game = false, cursor = false, focused = false, game_input = false, keep_input = false }
|
||||||
end
|
end
|
||||||
@@ -122,15 +112,6 @@ function SkyPhoneFocus.SetPhone(open, cursor_disabled)
|
|||||||
SkyPhoneFocus.Reapply()
|
SkyPhoneFocus.Reapply()
|
||||||
end
|
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)
|
function SkyPhoneFocus.SetCall(active)
|
||||||
state.call_focus = active == true
|
state.call_focus = active == true
|
||||||
SkyPhoneFocus.Reapply()
|
SkyPhoneFocus.Reapply()
|
||||||
@@ -161,7 +142,6 @@ function SkyPhoneFocus.SetExternalGameInput(owner_resource, allow_game_input)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function SkyPhoneFocus.Reset()
|
function SkyPhoneFocus.Reset()
|
||||||
state.admin_panel_open = false
|
|
||||||
state.activity_suspended = false
|
state.activity_suspended = false
|
||||||
state.call_focus = false
|
state.call_focus = false
|
||||||
state.camera_active = false
|
state.camera_active = false
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ local equipped_phone_number = nil
|
|||||||
local nui_generation = 0
|
local nui_generation = 0
|
||||||
local live_activity_active = false
|
local live_activity_active = false
|
||||||
local open_home_requested = false
|
local open_home_requested = false
|
||||||
local admin_panel_open = false
|
|
||||||
|
|
||||||
local function get_equipped_phone_number()
|
local function get_equipped_phone_number()
|
||||||
if not device_payload or not device_payload.device.sim then
|
if not device_payload or not device_payload.device.sim then
|
||||||
@@ -65,26 +64,6 @@ Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true
|
|||||||
|
|
||||||
local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
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()
|
local function send_open_message()
|
||||||
if not device_payload then
|
if not device_payload then
|
||||||
return
|
return
|
||||||
@@ -245,9 +224,6 @@ RegisterNUICallback("ui:ready", function(data, cb)
|
|||||||
if open_requested and device_payload then
|
if open_requested and device_payload then
|
||||||
send_open_message()
|
send_open_message()
|
||||||
end
|
end
|
||||||
if admin_panel_open then
|
|
||||||
send_admin_panel_open()
|
|
||||||
end
|
|
||||||
SkyPhoneCalls.ReplayNui()
|
SkyPhoneCalls.ReplayNui()
|
||||||
SkyPhoneSimPicker.ReplayNui()
|
SkyPhoneSimPicker.ReplayNui()
|
||||||
SkyPhoneFocus.Reapply()
|
SkyPhoneFocus.Reapply()
|
||||||
@@ -293,42 +269,12 @@ RegisterNUICallback("ui:opened", function(data, cb)
|
|||||||
cb({ success = true })
|
cb({ success = true })
|
||||||
end)
|
end)
|
||||||
|
|
||||||
RegisterNetEvent("sky_phone:admin:launch", function()
|
|
||||||
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)
|
|
||||||
local messages = locale.AdminCommand.Errors
|
|
||||||
Bridge.Framework.Notify(
|
|
||||||
"iFruit",
|
|
||||||
messages[error_code] or messages.default,
|
|
||||||
"error",
|
|
||||||
5000
|
|
||||||
)
|
|
||||||
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)
|
RegisterNUICallback("ui:input-focus", function(data, cb)
|
||||||
if type(data) ~= "table" or type(data.active) ~= "boolean" then
|
if type(data) ~= "table" or type(data.active) ~= "boolean" then
|
||||||
cb({ success = false, error = "invalid_request" })
|
cb({ success = false, error = "invalid_request" })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
SkyPhoneFocus.SetTextInputFocused(
|
SkyPhoneFocus.SetTextInputFocused(data.active and (is_open or open_requested))
|
||||||
data.active and (is_open or open_requested or admin_panel_open)
|
|
||||||
)
|
|
||||||
cb({ success = true })
|
cb({ success = true })
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -413,13 +359,6 @@ CreateThread(function()
|
|||||||
if Config.TestData.Enabled then
|
if Config.TestData.Enabled then
|
||||||
TriggerEvent("chat:addSuggestion", "/" .. Config.TestData.Command, locale.TestData.CommandDescription)
|
TriggerEvent("chat:addSuggestion", "/" .. Config.TestData.Command, locale.TestData.CommandDescription)
|
||||||
end
|
end
|
||||||
if Config.AdminPanel.Enabled then
|
|
||||||
TriggerEvent(
|
|
||||||
"chat:addSuggestion",
|
|
||||||
"/" .. Config.AdminPanel.Command,
|
|
||||||
locale.AdminCommand.CommandDescription
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
AddEventHandler("onResourceStop", function(resource_name)
|
AddEventHandler("onResourceStop", function(resource_name)
|
||||||
@@ -430,7 +369,6 @@ AddEventHandler("onResourceStop", function(resource_name)
|
|||||||
is_open = false
|
is_open = false
|
||||||
open_requested = false
|
open_requested = false
|
||||||
open_without_focus = false
|
open_without_focus = false
|
||||||
admin_panel_open = false
|
|
||||||
|
|
||||||
TriggerEvent("sky_phone:animation:reset")
|
TriggerEvent("sky_phone:animation:reset")
|
||||||
SkyPhoneCalls.Reset()
|
SkyPhoneCalls.Reset()
|
||||||
@@ -443,7 +381,4 @@ AddEventHandler("onResourceStop", function(resource_name)
|
|||||||
if Config.TestData.Enabled then
|
if Config.TestData.Enabled then
|
||||||
TriggerEvent("chat:removeSuggestion", "/" .. Config.TestData.Command)
|
TriggerEvent("chat:removeSuggestion", "/" .. Config.TestData.Command)
|
||||||
end
|
end
|
||||||
if Config.AdminPanel.Enabled then
|
|
||||||
TriggerEvent("chat:removeSuggestion", "/" .. Config.AdminPanel.Command)
|
|
||||||
end
|
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
local callback_groups = {
|
local callback_groups = {
|
||||||
account = [[login register logout devices remove-device]],
|
account = [[login register logout devices remove-device]],
|
||||||
admin = [[
|
|
||||||
bootstrap player save-apps reveal-password activity
|
|
||||||
reset-passcode change-number factory-reset
|
|
||||||
]],
|
|
||||||
banking = [[overview transfer]],
|
banking = [[overview transfer]],
|
||||||
billing = [[overview list detail markRead pay dispute]],
|
billing = [[overview list detail markRead pay dispute]],
|
||||||
calendar = [[list create update delete]],
|
calendar = [[list create update delete]],
|
||||||
|
|||||||
@@ -1,864 +0,0 @@
|
|||||||
Bridge.Database.AfterMigration("sky_phone", function()
|
|
||||||
local BUILTIN_APPS = {
|
|
||||||
banking = true,
|
|
||||||
billing = true,
|
|
||||||
calculator = true,
|
|
||||||
calendar = true,
|
|
||||||
camera = true,
|
|
||||||
citymarkt = true,
|
|
||||||
citywarn = true,
|
|
||||||
clock = true,
|
|
||||||
companies = true,
|
|
||||||
crewlink = true,
|
|
||||||
crypto = true,
|
|
||||||
darkchat = true,
|
|
||||||
feather = true,
|
|
||||||
flare = true,
|
|
||||||
fliptok = true,
|
|
||||||
garage = true,
|
|
||||||
health = true,
|
|
||||||
house = true,
|
|
||||||
["app-store"] = true,
|
|
||||||
["local-pages"] = true,
|
|
||||||
mail = true,
|
|
||||||
map = true,
|
|
||||||
memos = true,
|
|
||||||
memory = true,
|
|
||||||
messages = true,
|
|
||||||
minesweeper = true,
|
|
||||||
music = true,
|
|
||||||
["neon-drop"] = true,
|
|
||||||
notes = true,
|
|
||||||
["number-merge"] = true,
|
|
||||||
phone = true,
|
|
||||||
photos = true,
|
|
||||||
picstagram = true,
|
|
||||||
radio = true,
|
|
||||||
settings = true,
|
|
||||||
["sky-flappy"] = true,
|
|
||||||
skyride = true,
|
|
||||||
snake = true,
|
|
||||||
["tower-stack"] = true,
|
|
||||||
weather = true,
|
|
||||||
["weazel-news"] = true,
|
|
||||||
}
|
|
||||||
|
|
||||||
local DEFAULT_INSTALLED_APPS = {
|
|
||||||
["app-store"] = true,
|
|
||||||
calculator = true,
|
|
||||||
calendar = true,
|
|
||||||
camera = true,
|
|
||||||
citywarn = true,
|
|
||||||
clock = true,
|
|
||||||
health = true,
|
|
||||||
mail = true,
|
|
||||||
map = true,
|
|
||||||
memos = true,
|
|
||||||
messages = true,
|
|
||||||
notes = true,
|
|
||||||
phone = true,
|
|
||||||
photos = true,
|
|
||||||
settings = true,
|
|
||||||
weather = true,
|
|
||||||
}
|
|
||||||
|
|
||||||
local PROTECTED_APPS = {
|
|
||||||
["app-store"] = true,
|
|
||||||
camera = true,
|
|
||||||
citywarn = true,
|
|
||||||
health = true,
|
|
||||||
mail = true,
|
|
||||||
messages = true,
|
|
||||||
phone = true,
|
|
||||||
photos = true,
|
|
||||||
settings = true,
|
|
||||||
}
|
|
||||||
|
|
||||||
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 trim(value)
|
|
||||||
if type(value) ~= "string" then
|
|
||||||
return ""
|
|
||||||
end
|
|
||||||
return value:match("^%s*(.-)%s*$")
|
|
||||||
end
|
|
||||||
|
|
||||||
local function player_name(source)
|
|
||||||
local first_name = trim(Bridge.Framework.GetFirstname(source))
|
|
||||||
local last_name = trim(Bridge.Framework.GetLastname(source))
|
|
||||||
local character_name = trim((first_name .. " " .. last_name))
|
|
||||||
return character_name ~= "" and character_name or GetPlayerName(source) or ("Player %s"):format(source)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function require_admin(source, operation, maximum)
|
|
||||||
if not Config.AdminPanel.Enabled
|
|
||||||
or not Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)
|
|
||||||
then
|
|
||||||
Bridge.Debug("warn", "[sky_phone] Rejected admin panel access from source %s.", tostring(source))
|
|
||||||
return nil, { success = false, error = "not_authorized" }
|
|
||||||
end
|
|
||||||
if not SkyPhone.AllowOperation(source, "admin_" .. operation, maximum, 60) then
|
|
||||||
return nil, { success = false, error = "rate_limited" }
|
|
||||||
end
|
|
||||||
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
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
if not Bridge.Framework.GetIdentifier(player_source) then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
return player_source
|
|
||||||
end
|
|
||||||
|
|
||||||
local function collect_device_imeis(source, identifier)
|
|
||||||
local imeis = {}
|
|
||||||
local seen = {}
|
|
||||||
local function add_imei(imei)
|
|
||||||
if SkyPhoneImei.IsValid(imei) and not seen[imei] then
|
|
||||||
seen[imei] = true
|
|
||||||
imeis[#imeis + 1] = imei
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
for _, item in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
|
|
||||||
add_imei(item.metadata and item.metadata.imei)
|
|
||||||
end
|
|
||||||
|
|
||||||
local rows = Bridge.Database.Query([[
|
|
||||||
SELECT `device_imei` AS `imei`
|
|
||||||
FROM `sky_phone_character_devices`
|
|
||||||
WHERE `owner_identifier` = ?
|
|
||||||
UNION
|
|
||||||
SELECT device.`imei`
|
|
||||||
FROM `sky_phone_devices` device
|
|
||||||
JOIN `sky_phone_sims` sim ON sim.`id` = device.`sim_id`
|
|
||||||
WHERE sim.`owner_identifier` = ?
|
|
||||||
]], { identifier, identifier })
|
|
||||||
for _, row in ipairs(rows) do
|
|
||||||
add_imei(row.imei)
|
|
||||||
end
|
|
||||||
|
|
||||||
table.sort(imeis)
|
|
||||||
return imeis
|
|
||||||
end
|
|
||||||
|
|
||||||
local function normalize_app_ids(value)
|
|
||||||
local normalized = {}
|
|
||||||
local seen = {}
|
|
||||||
if type(value) ~= "table" then
|
|
||||||
return normalized
|
|
||||||
end
|
|
||||||
|
|
||||||
for index = 1, #value do
|
|
||||||
local app_id = value[index]
|
|
||||||
if type(app_id) == "string"
|
|
||||||
and #app_id > 0
|
|
||||||
and #app_id <= 64
|
|
||||||
and not seen[app_id]
|
|
||||||
then
|
|
||||||
seen[app_id] = true
|
|
||||||
normalized[#normalized + 1] = app_id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return normalized
|
|
||||||
end
|
|
||||||
|
|
||||||
local function load_app_payload(encoded)
|
|
||||||
if type(encoded) ~= "string" or encoded == "" then
|
|
||||||
return {}, {}, {}
|
|
||||||
end
|
|
||||||
local payload = json.decode(encoded)
|
|
||||||
if type(payload) ~= "table" then
|
|
||||||
error("[sky_phone] Stored admin target app payload is not a JSON object.")
|
|
||||||
end
|
|
||||||
return payload, normalize_app_ids(payload.claimedApps), normalize_app_ids(payload.uninstalledApps)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function load_player_devices(source, identifier)
|
|
||||||
local imeis = collect_device_imeis(source, identifier)
|
|
||||||
if #imeis == 0 then
|
|
||||||
return {}
|
|
||||||
end
|
|
||||||
|
|
||||||
local placeholders = {}
|
|
||||||
for index = 1, #imeis do
|
|
||||||
placeholders[index] = "?"
|
|
||||||
end
|
|
||||||
local rows = Bridge.Database.Query(([[
|
|
||||||
SELECT device.`imei`, device.`device_name`, device.`created_at`, device.`updated_at`,
|
|
||||||
sim.`phone_number`, sim.`sim_type`, sim.`registered_at`,
|
|
||||||
account.`id` AS `account_id`, account.`email` AS `account_email`,
|
|
||||||
security.`passcode_length`, security.`failed_attempts`, security.`locked_until`,
|
|
||||||
app_data.`payload` AS `apps_payload`, app_data.`revision` AS `apps_revision`
|
|
||||||
FROM `sky_phone_devices` device
|
|
||||||
LEFT JOIN `sky_phone_sims` sim ON sim.`id` = device.`sim_id`
|
|
||||||
LEFT JOIN `sky_phone_accounts` account ON account.`id` = device.`account_id`
|
|
||||||
LEFT JOIN `sky_phone_device_security` security ON security.`device_imei` = device.`imei`
|
|
||||||
LEFT JOIN `sky_phone_device_data` app_data
|
|
||||||
ON app_data.`device_imei` = device.`imei` AND app_data.`namespace` = 'apps'
|
|
||||||
WHERE device.`imei` IN (%s)
|
|
||||||
ORDER BY device.`updated_at` DESC, device.`imei` ASC
|
|
||||||
]]):format(table.concat(placeholders, ", ")), imeis)
|
|
||||||
|
|
||||||
local devices = {}
|
|
||||||
for _, row in ipairs(rows) do
|
|
||||||
local _, claimed_apps, uninstalled_apps = load_app_payload(row.apps_payload)
|
|
||||||
devices[#devices + 1] = {
|
|
||||||
imei = row.imei,
|
|
||||||
name = row.device_name,
|
|
||||||
createdAt = row.created_at,
|
|
||||||
updatedAt = row.updated_at,
|
|
||||||
number = row.phone_number,
|
|
||||||
simType = row.sim_type,
|
|
||||||
simRegistered = row.registered_at ~= nil,
|
|
||||||
account = row.account_id and {
|
|
||||||
id = tonumber(row.account_id),
|
|
||||||
email = row.account_email,
|
|
||||||
passwordAvailable = true,
|
|
||||||
} or nil,
|
|
||||||
security = {
|
|
||||||
enabled = row.passcode_length ~= nil,
|
|
||||||
length = row.passcode_length and tonumber(row.passcode_length) or nil,
|
|
||||||
failedAttempts = tonumber(row.failed_attempts) or 0,
|
|
||||||
lockedUntil = tonumber(row.locked_until) or 0,
|
|
||||||
},
|
|
||||||
apps = {
|
|
||||||
claimed = claimed_apps,
|
|
||||||
uninstalled = uninstalled_apps,
|
|
||||||
revision = tonumber(row.apps_revision) or 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
end
|
|
||||||
return devices
|
|
||||||
end
|
|
||||||
|
|
||||||
local function build_player_summary(source)
|
|
||||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
|
||||||
local devices = load_player_devices(source, identifier)
|
|
||||||
local job = Bridge.Framework.GetJob(source)
|
|
||||||
return {
|
|
||||||
source = source,
|
|
||||||
identifier = identifier,
|
|
||||||
name = player_name(source),
|
|
||||||
serverName = GetPlayerName(source) or "",
|
|
||||||
job = job.label ~= "" and job.label or job.name,
|
|
||||||
grade = job.grade,
|
|
||||||
onDuty = job.onDuty,
|
|
||||||
deviceCount = #devices,
|
|
||||||
phoneNumber = devices[1] and devices[1].number or nil,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
local function list_players()
|
|
||||||
local sources = Bridge.Framework.GetPlayers()
|
|
||||||
table.sort(sources, function(left, right)
|
|
||||||
return tonumber(left) < tonumber(right)
|
|
||||||
end)
|
|
||||||
|
|
||||||
local players = {}
|
|
||||||
local maximum = math.max(1, math.floor(tonumber(Config.AdminPanel.MaximumPlayers) or 128))
|
|
||||||
for index = 1, math.min(#sources, maximum) do
|
|
||||||
local player_source = tonumber(sources[index])
|
|
||||||
if player_source and Bridge.Framework.GetIdentifier(player_source) then
|
|
||||||
players[#players + 1] = build_player_summary(player_source)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return players
|
|
||||||
end
|
|
||||||
|
|
||||||
local function load_player_detail(source)
|
|
||||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
|
||||||
local job = Bridge.Framework.GetJob(source)
|
|
||||||
return {
|
|
||||||
source = source,
|
|
||||||
identifier = identifier,
|
|
||||||
name = player_name(source),
|
|
||||||
serverName = GetPlayerName(source) or "",
|
|
||||||
firstName = trim(Bridge.Framework.GetFirstname(source)),
|
|
||||||
lastName = trim(Bridge.Framework.GetLastname(source)),
|
|
||||||
birthdate = trim(Bridge.Framework.GetBirthdate(source)),
|
|
||||||
job = {
|
|
||||||
name = job.name,
|
|
||||||
label = job.label,
|
|
||||||
grade = job.grade,
|
|
||||||
gradeLabel = job.gradeLabel,
|
|
||||||
onDuty = job.onDuty,
|
|
||||||
},
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
local function find_owned_device(source, imei)
|
|
||||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
|
||||||
for _, device in ipairs(load_player_devices(source, identifier)) do
|
|
||||||
if device.imei == imei then
|
|
||||||
return device, identifier
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return nil, identifier
|
|
||||||
end
|
|
||||||
|
|
||||||
local function load_device_sim(imei)
|
|
||||||
local rows = Bridge.Database.Query([[
|
|
||||||
SELECT sim.`id`, sim.`phone_number`
|
|
||||||
FROM `sky_phone_devices` device
|
|
||||||
JOIN `sky_phone_sims` sim ON sim.`id` = device.`sim_id`
|
|
||||||
WHERE device.`imei` = ?
|
|
||||||
LIMIT 1
|
|
||||||
]], { imei })
|
|
||||||
return rows[1]
|
|
||||||
end
|
|
||||||
|
|
||||||
local function app_metadata(app_id)
|
|
||||||
if BUILTIN_APPS[app_id] then
|
|
||||||
return {
|
|
||||||
defaultInstalled = DEFAULT_INSTALLED_APPS[app_id] == true,
|
|
||||||
removable = PROTECTED_APPS[app_id] ~= true,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
if SkyPhoneApps.GetPolicy(app_id) then
|
|
||||||
return { defaultInstalled = false, removable = true }
|
|
||||||
end
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
local function remove_app_id(values, app_id)
|
|
||||||
local next_values = {}
|
|
||||||
for index = 1, #values do
|
|
||||||
if values[index] ~= app_id then
|
|
||||||
next_values[#next_values + 1] = values[index]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return next_values
|
|
||||||
end
|
|
||||||
|
|
||||||
local function add_app_id(values, app_id)
|
|
||||||
for index = 1, #values do
|
|
||||||
if values[index] == app_id then
|
|
||||||
return values
|
|
||||||
end
|
|
||||||
end
|
|
||||||
values[#values + 1] = app_id
|
|
||||||
return values
|
|
||||||
end
|
|
||||||
|
|
||||||
local function write_audit(actor_source, target_source, target_identifier, imei, action, details)
|
|
||||||
local actor_identifier = Bridge.Framework.GetIdentifier(actor_source)
|
|
||||||
local result = Bridge.Database.Query([[
|
|
||||||
INSERT INTO `sky_phone_admin_audit`
|
|
||||||
(`actor_identifier`, `actor_name`, `target_identifier`, `target_source`, `device_imei`, `action`, `details`)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
]], {
|
|
||||||
actor_identifier,
|
|
||||||
player_name(actor_source),
|
|
||||||
target_identifier,
|
|
||||||
target_source,
|
|
||||||
imei,
|
|
||||||
action,
|
|
||||||
json.encode(details or {}),
|
|
||||||
})
|
|
||||||
if affected_rows(result) ~= 1 then
|
|
||||||
error("[sky_phone] Admin audit insert did not affect exactly one row.")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function load_audit()
|
|
||||||
local limit = math.max(1, math.min(100, math.floor(tonumber(Config.AdminPanel.AuditLimit) or 40)))
|
|
||||||
local rows = Bridge.Database.Query(([[
|
|
||||||
SELECT `id`, `actor_name`, `target_identifier`, `target_source`, `device_imei`,
|
|
||||||
`action`, `details`, `created_at`
|
|
||||||
FROM `sky_phone_admin_audit`
|
|
||||||
ORDER BY `id` DESC
|
|
||||||
LIMIT %s
|
|
||||||
]]):format(limit), {})
|
|
||||||
local audit = {}
|
|
||||||
for _, row in ipairs(rows) do
|
|
||||||
audit[#audit + 1] = {
|
|
||||||
id = tonumber(row.id),
|
|
||||||
actorName = row.actor_name,
|
|
||||||
targetIdentifier = row.target_identifier,
|
|
||||||
targetSource = row.target_source and tonumber(row.target_source) or nil,
|
|
||||||
deviceImei = row.device_imei,
|
|
||||||
action = row.action,
|
|
||||||
details = json.decode(row.details),
|
|
||||||
createdAt = row.created_at,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
return audit
|
|
||||||
end
|
|
||||||
|
|
||||||
Bridge.Callbacks.Register("sky_phone:admin:bootstrap", function(source)
|
|
||||||
local authorized, error_response = require_admin(
|
|
||||||
source,
|
|
||||||
"bootstrap",
|
|
||||||
Config.AdminPanel.ReadRequestsPerMinute
|
|
||||||
)
|
|
||||||
if not authorized then
|
|
||||||
return error_response
|
|
||||||
end
|
|
||||||
|
|
||||||
local players = list_players()
|
|
||||||
local totals = Bridge.Database.Query([[
|
|
||||||
SELECT
|
|
||||||
(SELECT COUNT(*) FROM `sky_phone_devices`) AS `devices`,
|
|
||||||
(SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts`
|
|
||||||
]], {})
|
|
||||||
return {
|
|
||||||
success = true,
|
|
||||||
data = {
|
|
||||||
players = players,
|
|
||||||
stats = {
|
|
||||||
online = #players,
|
|
||||||
devices = tonumber(totals[1] and totals[1].devices) or 0,
|
|
||||||
accounts = tonumber(totals[1] and totals[1].accounts) or 0,
|
|
||||||
},
|
|
||||||
audit = load_audit(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
end)
|
|
||||||
|
|
||||||
Bridge.Callbacks.Register("sky_phone:admin:player", function(source, data)
|
|
||||||
local authorized, error_response = require_admin(
|
|
||||||
source,
|
|
||||||
"player",
|
|
||||||
Config.AdminPanel.ReadRequestsPerMinute
|
|
||||||
)
|
|
||||||
if not authorized then
|
|
||||||
return error_response
|
|
||||||
end
|
|
||||||
local target_source = normalize_source(data and data.source)
|
|
||||||
if not target_source then
|
|
||||||
return { success = false, error = "player_unavailable" }
|
|
||||||
end
|
|
||||||
return { success = true, data = load_player_detail(target_source) }
|
|
||||||
end)
|
|
||||||
|
|
||||||
Bridge.Callbacks.Register("sky_phone:admin:save-apps", function(source, data)
|
|
||||||
local authorized, error_response = require_admin(
|
|
||||||
source,
|
|
||||||
"save_apps",
|
|
||||||
Config.AdminPanel.ActionRequestsPerMinute
|
|
||||||
)
|
|
||||||
if not authorized then
|
|
||||||
return error_response
|
|
||||||
end
|
|
||||||
if type(data) ~= "table"
|
|
||||||
or not SkyPhoneImei.IsValid(data.imei)
|
|
||||||
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
|
|
||||||
|
|
||||||
local target_source = normalize_source(data.source)
|
|
||||||
if not target_source then
|
|
||||||
return { success = false, error = "player_unavailable" }
|
|
||||||
end
|
|
||||||
local target_device, target_identifier = find_owned_device(target_source, data.imei)
|
|
||||||
if not target_device then
|
|
||||||
return { success = false, error = "device_not_owned" }
|
|
||||||
end
|
|
||||||
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([[
|
|
||||||
SELECT `payload`, `revision`
|
|
||||||
FROM `sky_phone_device_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)
|
|
||||||
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
|
|
||||||
end
|
|
||||||
payload.claimedApps = claimed_apps
|
|
||||||
payload.uninstalledApps = #uninstalled_apps > 0 and uninstalled_apps or nil
|
|
||||||
|
|
||||||
local encoded = json.encode(payload)
|
|
||||||
if #encoded > 100000 then
|
|
||||||
return { success = false, error = "payload_too_large" }
|
|
||||||
end
|
|
||||||
|
|
||||||
if rows[1] then
|
|
||||||
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, data.revision })
|
|
||||||
if affected_rows(result) ~= 1 then
|
|
||||||
return { success = false, error = "revision_conflict" }
|
|
||||||
end
|
|
||||||
else
|
|
||||||
local result = Bridge.Database.Query([[
|
|
||||||
INSERT IGNORE INTO `sky_phone_device_data` (`device_imei`, `namespace`, `payload`)
|
|
||||||
VALUES (?, 'apps', ?)
|
|
||||||
]], { data.imei, encoded })
|
|
||||||
if affected_rows(result) ~= 1 then
|
|
||||||
return { success = false, error = "revision_conflict" }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
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 authorized, error_response = require_admin(
|
|
||||||
source,
|
|
||||||
"reveal_password",
|
|
||||||
Config.AdminPanel.CredentialRevealsPerMinute
|
|
||||||
)
|
|
||||||
if not authorized then
|
|
||||||
return error_response
|
|
||||||
end
|
|
||||||
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
|
|
||||||
return { success = false, error = "invalid_request" }
|
|
||||||
end
|
|
||||||
|
|
||||||
local target_source = normalize_source(data.source)
|
|
||||||
if not target_source then
|
|
||||||
return { success = false, error = "player_unavailable" }
|
|
||||||
end
|
|
||||||
local device, target_identifier = find_owned_device(target_source, data.imei)
|
|
||||||
if not device then
|
|
||||||
return { success = false, error = "device_not_owned" }
|
|
||||||
end
|
|
||||||
|
|
||||||
local accounts = Bridge.Database.Query([[
|
|
||||||
SELECT account.`email`, account.`password`
|
|
||||||
FROM `sky_phone_devices` device
|
|
||||||
JOIN `sky_phone_accounts` account ON account.`id` = device.`account_id`
|
|
||||||
WHERE device.`imei` = ?
|
|
||||||
LIMIT 1
|
|
||||||
]], { data.imei })
|
|
||||||
if not accounts[1] then
|
|
||||||
return { success = false, error = "account_not_found" }
|
|
||||||
end
|
|
||||||
|
|
||||||
write_audit(
|
|
||||||
source,
|
|
||||||
target_source,
|
|
||||||
target_identifier,
|
|
||||||
data.imei,
|
|
||||||
"reveal_account_password",
|
|
||||||
{ email = accounts[1].email }
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
success = true,
|
|
||||||
data = {
|
|
||||||
email = accounts[1].email,
|
|
||||||
password = accounts[1].password,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
end)
|
|
||||||
|
|
||||||
Bridge.Callbacks.Register("sky_phone:admin:activity", function(source, data)
|
|
||||||
local authorized, error_response = require_admin(
|
|
||||||
source,
|
|
||||||
"activity",
|
|
||||||
Config.AdminPanel.ReadRequestsPerMinute
|
|
||||||
)
|
|
||||||
if not authorized then
|
|
||||||
return error_response
|
|
||||||
end
|
|
||||||
if type(data) ~= "table"
|
|
||||||
or not SkyPhoneImei.IsValid(data.imei)
|
|
||||||
or (data.kind ~= "messages" and data.kind ~= "calls")
|
|
||||||
then
|
|
||||||
return { success = false, error = "invalid_request" }
|
|
||||||
end
|
|
||||||
|
|
||||||
local target_source = normalize_source(data.source)
|
|
||||||
if not target_source then
|
|
||||||
return { success = false, error = "player_unavailable" }
|
|
||||||
end
|
|
||||||
local device, target_identifier = find_owned_device(target_source, data.imei)
|
|
||||||
if not device then
|
|
||||||
return { success = false, error = "device_not_owned" }
|
|
||||||
end
|
|
||||||
local audit_action = data.kind == "messages" and "view_messages" or "view_calls"
|
|
||||||
local sim = load_device_sim(data.imei)
|
|
||||||
if not sim then
|
|
||||||
write_audit(
|
|
||||||
source,
|
|
||||||
target_source,
|
|
||||||
target_identifier,
|
|
||||||
data.imei,
|
|
||||||
audit_action,
|
|
||||||
{ count = 0 }
|
|
||||||
)
|
|
||||||
return { success = true, data = { kind = data.kind, entries = {} } }
|
|
||||||
end
|
|
||||||
|
|
||||||
local limit = math.max(1, math.min(100, math.floor(tonumber(Config.AdminPanel.ActivityLimit) or 40)))
|
|
||||||
local entries = {}
|
|
||||||
if data.kind == "messages" then
|
|
||||||
local rows = Bridge.Database.Query(([[
|
|
||||||
SELECT `id`, `sender_sim_id`, `sender_number`, `recipient_number`, `message_type`,
|
|
||||||
`body`, `read_at`, `created_at`
|
|
||||||
FROM `sky_phone_sms_messages`
|
|
||||||
WHERE `sender_sim_id` = ? OR `recipient_sim_id` = ?
|
|
||||||
ORDER BY `created_at` DESC
|
|
||||||
LIMIT %s
|
|
||||||
]]):format(limit), { sim.id, sim.id })
|
|
||||||
for _, row in ipairs(rows) do
|
|
||||||
local outgoing = row.sender_sim_id == sim.id
|
|
||||||
entries[#entries + 1] = {
|
|
||||||
id = row.id,
|
|
||||||
direction = outgoing and "outgoing" or "incoming",
|
|
||||||
otherNumber = outgoing and row.recipient_number or row.sender_number,
|
|
||||||
messageType = row.message_type,
|
|
||||||
body = row.body,
|
|
||||||
readAt = row.read_at,
|
|
||||||
createdAt = row.created_at,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
else
|
|
||||||
local rows = Bridge.Database.Query(([[
|
|
||||||
SELECT `id`, `caller_sim_id`, `caller_number`, `callee_number`, `status`,
|
|
||||||
`started_at`, `answered_at`, `ended_at`, `duration_seconds`
|
|
||||||
FROM `sky_phone_calls`
|
|
||||||
WHERE `caller_sim_id` = ? OR `callee_sim_id` = ?
|
|
||||||
ORDER BY `started_at` DESC
|
|
||||||
LIMIT %s
|
|
||||||
]]):format(limit), { sim.id, sim.id })
|
|
||||||
for _, row in ipairs(rows) do
|
|
||||||
local outgoing = row.caller_sim_id == sim.id
|
|
||||||
entries[#entries + 1] = {
|
|
||||||
id = row.id,
|
|
||||||
direction = outgoing and "outgoing" or "incoming",
|
|
||||||
otherNumber = outgoing and row.callee_number or row.caller_number,
|
|
||||||
status = row.status,
|
|
||||||
startedAt = row.started_at,
|
|
||||||
answeredAt = row.answered_at,
|
|
||||||
endedAt = row.ended_at,
|
|
||||||
durationSeconds = tonumber(row.duration_seconds) or 0,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
write_audit(
|
|
||||||
source,
|
|
||||||
target_source,
|
|
||||||
target_identifier,
|
|
||||||
data.imei,
|
|
||||||
audit_action,
|
|
||||||
{ count = #entries }
|
|
||||||
)
|
|
||||||
return { success = true, data = { kind = data.kind, entries = entries } }
|
|
||||||
end)
|
|
||||||
|
|
||||||
Bridge.Callbacks.Register("sky_phone:admin:reset-passcode", function(source, data)
|
|
||||||
local authorized, error_response = require_admin(
|
|
||||||
source,
|
|
||||||
"reset_passcode",
|
|
||||||
Config.AdminPanel.ActionRequestsPerMinute
|
|
||||||
)
|
|
||||||
if not authorized then
|
|
||||||
return error_response
|
|
||||||
end
|
|
||||||
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
|
|
||||||
return { success = false, error = "invalid_request" }
|
|
||||||
end
|
|
||||||
|
|
||||||
local target_source = normalize_source(data.source)
|
|
||||||
if not target_source then
|
|
||||||
return { success = false, error = "player_unavailable" }
|
|
||||||
end
|
|
||||||
local device, target_identifier = find_owned_device(target_source, data.imei)
|
|
||||||
if not device then
|
|
||||||
return { success = false, error = "device_not_owned" }
|
|
||||||
end
|
|
||||||
local result = Bridge.Database.Query(
|
|
||||||
"DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
|
|
||||||
{ data.imei }
|
|
||||||
)
|
|
||||||
if affected_rows(result) ~= 1 then
|
|
||||||
return { success = false, error = "passcode_not_set" }
|
|
||||||
end
|
|
||||||
|
|
||||||
write_audit(source, target_source, target_identifier, data.imei, "reset_passcode", {})
|
|
||||||
SkyPhone.RefreshDevice(data.imei)
|
|
||||||
return { success = true, data = load_player_detail(target_source) }
|
|
||||||
end)
|
|
||||||
|
|
||||||
Bridge.Callbacks.Register("sky_phone:admin:change-number", function(source, data)
|
|
||||||
local authorized, error_response = require_admin(
|
|
||||||
source,
|
|
||||||
"change_number",
|
|
||||||
Config.AdminPanel.ActionRequestsPerMinute
|
|
||||||
)
|
|
||||||
if not authorized then
|
|
||||||
return error_response
|
|
||||||
end
|
|
||||||
if type(data) ~= "table"
|
|
||||||
or not SkyPhoneImei.IsValid(data.imei)
|
|
||||||
or (type(data.phoneNumber) ~= "string" and type(data.phoneNumber) ~= "number")
|
|
||||||
then
|
|
||||||
return { success = false, error = "invalid_request" }
|
|
||||||
end
|
|
||||||
|
|
||||||
local target_source = normalize_source(data.source)
|
|
||||||
if not target_source then
|
|
||||||
return { success = false, error = "player_unavailable" }
|
|
||||||
end
|
|
||||||
local device, target_identifier = find_owned_device(target_source, data.imei)
|
|
||||||
if not device then
|
|
||||||
return { success = false, error = "device_not_owned" }
|
|
||||||
end
|
|
||||||
local sim = load_device_sim(data.imei)
|
|
||||||
if not sim then
|
|
||||||
return { success = false, error = "no_sim" }
|
|
||||||
end
|
|
||||||
|
|
||||||
local changed, number_or_error = SkyPhoneSim.ChangeNumber(
|
|
||||||
target_source,
|
|
||||||
data.imei,
|
|
||||||
sim.id,
|
|
||||||
data.phoneNumber
|
|
||||||
)
|
|
||||||
if not changed then
|
|
||||||
return { success = false, error = number_or_error }
|
|
||||||
end
|
|
||||||
|
|
||||||
SkyPhoneCompanies.ClearCallAvailability(target_source)
|
|
||||||
SkyPhoneCalls.EndForSim(sim.id, "number_changed")
|
|
||||||
write_audit(source, target_source, target_identifier, data.imei, "change_number", {
|
|
||||||
previousNumber = sim.phone_number,
|
|
||||||
phoneNumber = number_or_error,
|
|
||||||
})
|
|
||||||
SkyPhone.RefreshDevice(data.imei)
|
|
||||||
return { success = true, data = load_player_detail(target_source) }
|
|
||||||
end)
|
|
||||||
|
|
||||||
Bridge.Callbacks.Register("sky_phone:admin:factory-reset", function(source, data)
|
|
||||||
local authorized, error_response = require_admin(
|
|
||||||
source,
|
|
||||||
"factory_reset",
|
|
||||||
Config.AdminPanel.ActionRequestsPerMinute
|
|
||||||
)
|
|
||||||
if not authorized then
|
|
||||||
return error_response
|
|
||||||
end
|
|
||||||
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
|
|
||||||
return { success = false, error = "invalid_request" }
|
|
||||||
end
|
|
||||||
|
|
||||||
local target_source = normalize_source(data.source)
|
|
||||||
if not target_source then
|
|
||||||
return { success = false, error = "player_unavailable" }
|
|
||||||
end
|
|
||||||
local device, target_identifier = find_owned_device(target_source, data.imei)
|
|
||||||
if not device then
|
|
||||||
return { success = false, error = "device_not_owned" }
|
|
||||||
end
|
|
||||||
|
|
||||||
local reset, phone_number_or_error = SkyPhonePersistence.FactoryReset(data.imei)
|
|
||||||
if not reset then
|
|
||||||
return { success = false, error = phone_number_or_error }
|
|
||||||
end
|
|
||||||
|
|
||||||
write_audit(source, target_source, target_identifier, data.imei, "factory_reset", {})
|
|
||||||
if phone_number_or_error then
|
|
||||||
TriggerEvent("sky_phone:server:factoryReset", target_source, phone_number_or_error)
|
|
||||||
end
|
|
||||||
SkyPhone.RefreshDevice(data.imei)
|
|
||||||
return { success = true, data = load_player_detail(target_source) }
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
@@ -416,46 +416,6 @@ local schema = {
|
|||||||
},
|
},
|
||||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name = "sky_phone_admin_audit",
|
|
||||||
columns = {
|
|
||||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
|
||||||
{
|
|
||||||
name = "actor_identifier",
|
|
||||||
type = "VARCHAR(80) NOT NULL",
|
|
||||||
characterSet = "ascii",
|
|
||||||
collation = "ascii_bin",
|
|
||||||
},
|
|
||||||
{ name = "actor_name", type = "VARCHAR(120) NOT NULL" },
|
|
||||||
{
|
|
||||||
name = "target_identifier",
|
|
||||||
type = "VARCHAR(80) NOT NULL",
|
|
||||||
characterSet = "ascii",
|
|
||||||
collation = "ascii_bin",
|
|
||||||
},
|
|
||||||
{ name = "target_source", type = "INT UNSIGNED NULL" },
|
|
||||||
{
|
|
||||||
name = "device_imei",
|
|
||||||
type = "CHAR(15) NULL",
|
|
||||||
characterSet = "ascii",
|
|
||||||
collation = "ascii_bin",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "action",
|
|
||||||
type = "VARCHAR(48) NOT NULL",
|
|
||||||
characterSet = "ascii",
|
|
||||||
collation = "ascii_bin",
|
|
||||||
},
|
|
||||||
{ name = "details", type = "LONGTEXT NOT NULL" },
|
|
||||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
|
||||||
},
|
|
||||||
primaryKey = "id",
|
|
||||||
indexes = {
|
|
||||||
{ name = "idx_sky_phone_admin_audit_created", columns = "(`created_at`, `id`)" },
|
|
||||||
{ name = "idx_sky_phone_admin_audit_target", columns = "(`target_identifier`, `created_at`)" },
|
|
||||||
},
|
|
||||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name = "sky_phone_notes",
|
name = "sky_phone_notes",
|
||||||
columns = {
|
columns = {
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
Bridge.Database.AfterMigration("sky_phone", function()
|
Bridge.Database.AfterMigration("sky_phone", function()
|
||||||
SkyPhonePersistence = {}
|
|
||||||
|
|
||||||
local max_device_data_bytes = 100000
|
local max_device_data_bytes = 100000
|
||||||
local allowed_device_namespaces = {
|
local allowed_device_namespaces = {
|
||||||
settings = true,
|
settings = true,
|
||||||
@@ -152,69 +150,6 @@ Bridge.Callbacks.Register("sky_phone:notifications:save", function(source, data)
|
|||||||
return { success = true }
|
return { success = true }
|
||||||
end)
|
end)
|
||||||
|
|
||||||
function SkyPhonePersistence.FactoryReset(imei)
|
|
||||||
if not SkyPhoneImei.IsValid(imei) then
|
|
||||||
return false, "invalid_request"
|
|
||||||
end
|
|
||||||
|
|
||||||
local device = SkyPhone.LoadDevice(imei)
|
|
||||||
if not device then
|
|
||||||
return false, "device_not_found"
|
|
||||||
end
|
|
||||||
local phone_number = device and device.phone_number or nil
|
|
||||||
local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(imei)
|
|
||||||
if not Bridge.Database.Transaction({
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
|
|
||||||
params = { imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?",
|
|
||||||
params = { imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_custom_app_data` WHERE `device_imei` = ?",
|
|
||||||
params = { imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_notes` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
|
||||||
params = { imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_media` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
|
||||||
params = { imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_music_playlists` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
|
||||||
params = { imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_music_youtube_songs` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
|
||||||
params = { imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
|
||||||
params = { imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
|
||||||
params = { imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_fliptok_sessions` WHERE `device_imei` = ?",
|
|
||||||
params = { imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "UPDATE `sky_phone_devices` SET `account_id` = NULL, `device_name` = ? WHERE `imei` = ?",
|
|
||||||
params = { Config.Phone.DeviceName, imei },
|
|
||||||
},
|
|
||||||
}) then
|
|
||||||
return false, "request_failed"
|
|
||||||
end
|
|
||||||
SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids)
|
|
||||||
return true, phone_number
|
|
||||||
end
|
|
||||||
|
|
||||||
Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
||||||
if not SkyPhone.AllowOperation(source, "factory_reset", 3, 60) then
|
if not SkyPhone.AllowOperation(source, "factory_reset", 3, 60) then
|
||||||
return { success = false, error = "rate_limited" }
|
return { success = false, error = "rate_limited" }
|
||||||
@@ -223,11 +158,58 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
|||||||
if not session then
|
if not session then
|
||||||
return error_response
|
return error_response
|
||||||
end
|
end
|
||||||
|
local device = SkyPhone.LoadDevice(session.imei)
|
||||||
local reset, phone_number = SkyPhonePersistence.FactoryReset(session.imei)
|
local phone_number = device and device.phone_number or nil
|
||||||
if not reset then
|
local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(session.imei)
|
||||||
return { success = false, error = phone_number }
|
if not Bridge.Database.Transaction({
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
|
||||||
|
params = { session.imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?",
|
||||||
|
params = { session.imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_custom_app_data` WHERE `device_imei` = ?",
|
||||||
|
params = { session.imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_notes` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||||
|
params = { session.imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_media` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||||
|
params = { session.imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_music_playlists` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||||
|
params = { session.imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_music_youtube_songs` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||||
|
params = { session.imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||||
|
params = { session.imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||||
|
params = { session.imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_fliptok_sessions` WHERE `device_imei` = ?",
|
||||||
|
params = { session.imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "UPDATE `sky_phone_devices` SET `account_id` = NULL, `device_name` = ? WHERE `imei` = ?",
|
||||||
|
params = { Config.Phone.DeviceName, session.imei },
|
||||||
|
},
|
||||||
|
}) then
|
||||||
|
return { success = false, error = "request_failed" }
|
||||||
end
|
end
|
||||||
|
SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids)
|
||||||
session.unlocked = true
|
session.unlocked = true
|
||||||
if phone_number then
|
if phone_number then
|
||||||
TriggerEvent("sky_phone:server:factoryReset", source, phone_number)
|
TriggerEvent("sky_phone:server:factoryReset", source, phone_number)
|
||||||
|
|||||||
@@ -153,62 +153,6 @@ end
|
|||||||
|
|
||||||
SkyPhoneSim.PrepareDevice = prepare_device
|
SkyPhoneSim.PrepareDevice = prepare_device
|
||||||
|
|
||||||
function SkyPhoneSim.ChangeNumber(source, imei, sim_id, value)
|
|
||||||
local number = SkyPhoneSimNumber.Normalize(value, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
|
|
||||||
if not number or SkyPhoneCompanies.IsServiceNumber(number) then
|
|
||||||
return false, "invalid_phone_number"
|
|
||||||
end
|
|
||||||
|
|
||||||
local sim = load_sim(sim_id)
|
|
||||||
if not sim then
|
|
||||||
return false, "no_sim"
|
|
||||||
end
|
|
||||||
if sim.phone_number == number then
|
|
||||||
return false, "phone_number_unchanged"
|
|
||||||
end
|
|
||||||
|
|
||||||
local existing = Bridge.Database.Query(
|
|
||||||
"SELECT `id` FROM `sky_phone_sims` WHERE `phone_number` = ? AND `id` <> ? LIMIT 1",
|
|
||||||
{ number, sim_id }
|
|
||||||
)
|
|
||||||
if existing[1] then
|
|
||||||
return false, "phone_number_taken"
|
|
||||||
end
|
|
||||||
|
|
||||||
local phone_slot
|
|
||||||
if unique_phones then
|
|
||||||
for _, slot in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
|
|
||||||
if slot.metadata and slot.metadata.imei == imei then
|
|
||||||
phone_slot = slot
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local previous_number = sim.phone_number
|
|
||||||
sim.phone_number = number
|
|
||||||
if phone_slot and not set_phone_sim_metadata(source, phone_slot, sim) then
|
|
||||||
return false, "metadata_unsupported"
|
|
||||||
end
|
|
||||||
|
|
||||||
local result = Bridge.Database.Query([[
|
|
||||||
UPDATE IGNORE `sky_phone_sims`
|
|
||||||
SET `phone_number` = ?
|
|
||||||
WHERE `id` = ? AND `phone_number` = ?
|
|
||||||
]], { number, sim_id, previous_number })
|
|
||||||
if affected_rows(result) ~= 1 then
|
|
||||||
if phone_slot then
|
|
||||||
sim.phone_number = previous_number
|
|
||||||
if not set_phone_sim_metadata(source, phone_slot, sim) then
|
|
||||||
error("[sky_phone] Could not restore SIM metadata after a failed admin number change.")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return false, "phone_number_taken"
|
|
||||||
end
|
|
||||||
|
|
||||||
return true, number
|
|
||||||
end
|
|
||||||
|
|
||||||
local function resolve_used_sim(source, used_item, item_name)
|
local function resolve_used_sim(source, used_item, item_name)
|
||||||
local slot_id = used_item and (used_item.slot or used_item.id)
|
local slot_id = used_item and (used_item.slot or used_item.id)
|
||||||
local slot = slot_id and Bridge.Inventory.GetSlot(source, slot_id) or nil
|
local slot = slot_id and Bridge.Inventory.GetSlot(source, slot_id) or nil
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ local ALLOWED_PERMISSIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
local RESERVED_APP_IDS = {
|
local RESERVED_APP_IDS = {
|
||||||
admin = true,
|
|
||||||
["app-store"] = true,
|
["app-store"] = true,
|
||||||
banking = true,
|
banking = true,
|
||||||
crypto = true,
|
crypto = true,
|
||||||
|
|||||||
@@ -185,21 +185,6 @@ CREATE TABLE IF NOT EXISTS `sky_phone_device_security` (
|
|||||||
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
|
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
|
||||||
) 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_admin_audit` (
|
|
||||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
`actor_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
|
||||||
`actor_name` VARCHAR(120) NOT NULL,
|
|
||||||
`target_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
|
||||||
`target_source` INT UNSIGNED NULL,
|
|
||||||
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
|
||||||
`action` VARCHAR(48) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
|
||||||
`details` LONGTEXT NOT NULL,
|
|
||||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_sky_phone_admin_audit_created` (`created_at`, `id`),
|
|
||||||
KEY `idx_sky_phone_admin_audit_target` (`target_identifier`, `created_at`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `sky_phone_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,
|
||||||
|
|||||||
@@ -23,11 +23,7 @@ Bridge = {
|
|||||||
},
|
},
|
||||||
Debug = function()
|
Debug = function()
|
||||||
end,
|
end,
|
||||||
Framework = {
|
Framework = {},
|
||||||
HasAdminGroup = function()
|
|
||||||
return true
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
Inventory = {
|
Inventory = {
|
||||||
GetResourceName = function()
|
GetResourceName = function()
|
||||||
return "test_inventory"
|
return "test_inventory"
|
||||||
@@ -40,11 +36,6 @@ Bridge = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Config = {
|
Config = {
|
||||||
AdminPanel = {
|
|
||||||
AdminGroups = { "admin" },
|
|
||||||
Command = "phoneadmin",
|
|
||||||
Enabled = true,
|
|
||||||
},
|
|
||||||
Phone = {
|
Phone = {
|
||||||
DevelopmentCommand = false,
|
DevelopmentCommand = false,
|
||||||
DeviceName = "Test Phone",
|
DeviceName = "Test Phone",
|
||||||
@@ -88,12 +79,6 @@ function AddEventHandler(_, callback)
|
|||||||
assert(type(callback) == "function")
|
assert(type(callback) == "function")
|
||||||
end
|
end
|
||||||
|
|
||||||
function RegisterCommand(name, callback, restricted)
|
|
||||||
assert(name == "phoneadmin")
|
|
||||||
assert(type(callback) == "function")
|
|
||||||
assert(restricted == false)
|
|
||||||
end
|
|
||||||
|
|
||||||
function TriggerClientEvent()
|
function TriggerClientEvent()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user