mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ENH - expand admin device controls
This commit is contained in:
@@ -20,6 +20,17 @@ const phoneServer = readFileSync(
|
||||
new URL('../../../sky_phone/source/server/phone.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const persistenceServer = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/server/phone_persistence.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const simServer = readFileSync(
|
||||
new URL('../../../sky_phone/source/server/sim.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const phoneClient = readFileSync(
|
||||
new URL('../../../sky_phone/source/client/main.lua', import.meta.url),
|
||||
'utf8',
|
||||
@@ -61,8 +72,8 @@ describe('standalone admin panel contracts', () => {
|
||||
|
||||
it('uses a compact transparent shell with dedicated admin workspaces', () => {
|
||||
expect(source).toContain('background: transparent')
|
||||
expect(source).toContain('width: min(84vw, 1420px)')
|
||||
expect(source).toContain('height: min(82vh, 820px)')
|
||||
expect(source).toContain('width: min(76vw, 1220px)')
|
||||
expect(source).toContain('height: min(74vh, 700px)')
|
||||
expect(source).not.toContain('backdrop-filter: blur(2px)')
|
||||
|
||||
for (const tab of [
|
||||
@@ -70,7 +81,10 @@ describe('standalone admin panel contracts', () => {
|
||||
'players',
|
||||
'devices',
|
||||
'apps',
|
||||
'security',
|
||||
'accounts',
|
||||
'messages',
|
||||
'calls',
|
||||
'moderation',
|
||||
'audit',
|
||||
]) {
|
||||
expect(source).toContain(`selectTab('${tab}')`)
|
||||
@@ -78,6 +92,14 @@ describe('standalone admin panel contracts', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('removes manual reload and applies a persistent global accent choice', () => {
|
||||
expect(source).not.toContain('<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"')
|
||||
@@ -90,19 +112,46 @@ describe('standalone admin panel contracts', () => {
|
||||
})
|
||||
|
||||
it('connects every operation through the standard NUI callback bridge', () => {
|
||||
expect(bridge).toContain(
|
||||
'admin = [[bootstrap player save-apps reveal-password]]',
|
||||
)
|
||||
expect(bridge).toContain('admin = [[')
|
||||
for (const endpoint of [
|
||||
'admin:bootstrap',
|
||||
'admin:player',
|
||||
'admin:save-apps',
|
||||
'admin:reveal-password',
|
||||
'admin:activity',
|
||||
'admin:reset-passcode',
|
||||
'admin:change-number',
|
||||
'admin:factory-reset',
|
||||
]) {
|
||||
expect(store).toContain(endpoint)
|
||||
}
|
||||
})
|
||||
|
||||
it('protects activity views and device moderation with ownership and audit checks', () => {
|
||||
for (const endpoint of [
|
||||
'activity',
|
||||
'reset-passcode',
|
||||
'change-number',
|
||||
'factory-reset',
|
||||
]) {
|
||||
expect(server).toContain(
|
||||
`Bridge.Callbacks.Register("sky_phone:admin:${endpoint}"`,
|
||||
)
|
||||
}
|
||||
expect(server).toContain('data.kind ~= "messages"')
|
||||
expect(server).toContain('data.kind ~= "calls"')
|
||||
expect(server).toContain('"view_messages"')
|
||||
expect(server).toContain('"view_calls"')
|
||||
expect(server).toContain('"reset_passcode"')
|
||||
expect(server).toContain('"change_number"')
|
||||
expect(server).toContain('"factory_reset"')
|
||||
expect(simServer).toContain('function SkyPhoneSim.ChangeNumber(')
|
||||
expect(simServer).toContain('UPDATE IGNORE `sky_phone_sims`')
|
||||
expect(persistenceServer).toContain(
|
||||
'function SkyPhonePersistence.FactoryReset(imei)',
|
||||
)
|
||||
})
|
||||
|
||||
it('authorizes every server request without requiring a phone session', () => {
|
||||
expect(server).not.toContain('SkyPhone.RequireSession(source)')
|
||||
expect(server).toContain(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,11 @@ import { defineStore } from 'pinia'
|
||||
|
||||
import type {
|
||||
AdminAuditEntry,
|
||||
AdminActivityResponse,
|
||||
AdminBootstrap,
|
||||
AdminCallActivity,
|
||||
AdminCredential,
|
||||
AdminMessageActivity,
|
||||
AdminPlayerDetail,
|
||||
AdminPlayerSummary,
|
||||
AdminStats,
|
||||
@@ -15,6 +18,7 @@ const EMPTY_STATS: AdminStats = { accounts: 0, devices: 0, online: 0 }
|
||||
export const useAdminStore = defineStore('admin', {
|
||||
state: () => ({
|
||||
actionKey: '',
|
||||
activityKey: '',
|
||||
audit: [] as AdminAuditEntry[],
|
||||
detailLoading: false,
|
||||
error: '',
|
||||
@@ -22,6 +26,10 @@ export const useAdminStore = defineStore('admin', {
|
||||
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 },
|
||||
}),
|
||||
@@ -100,5 +108,91 @@ export const useAdminStore = defineStore('admin', {
|
||||
}
|
||||
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
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
+101
-11
@@ -806,8 +806,8 @@ const citywarnFallbackLocales = {
|
||||
}
|
||||
|
||||
const adminPanelFallbackLocales = {
|
||||
name: 'Command Center',
|
||||
subtitle: 'Protected administration',
|
||||
name: 'Phone Admin',
|
||||
subtitle: 'Administration',
|
||||
navigation: 'Admin navigation',
|
||||
refresh: 'Refresh admin data',
|
||||
loading: 'Loading protected data...',
|
||||
@@ -816,28 +816,46 @@ const adminPanelFallbackLocales = {
|
||||
players: 'Players',
|
||||
devices: 'Devices',
|
||||
apps: 'Apps',
|
||||
security: 'Security',
|
||||
accounts: 'Accounts',
|
||||
messages: 'Messages',
|
||||
calls: 'Calls',
|
||||
moderation: 'Moderation',
|
||||
audit: 'Audit',
|
||||
},
|
||||
overview: {
|
||||
eyebrow: 'Live operations',
|
||||
title: 'Command Center',
|
||||
body: 'Manage active phone devices, app access, and protected account data.',
|
||||
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: 'Admin modules',
|
||||
features: 'Workspace areas',
|
||||
featuresBody: 'Jump directly to a protected administration area.',
|
||||
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',
|
||||
securityFeature: 'Account access and protected credentials',
|
||||
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',
|
||||
@@ -908,9 +926,68 @@ const adminPanelFallbackLocales = {
|
||||
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 WORKSPACE',
|
||||
workspace: 'ADMIN',
|
||||
players: 'Player directory',
|
||||
audit: 'Audit log',
|
||||
selectPlayer:
|
||||
@@ -946,6 +1023,11 @@ const adminPanelFallbackLocales = {
|
||||
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: {
|
||||
@@ -958,6 +1040,14 @@ const adminPanelFallbackLocales = {
|
||||
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.',
|
||||
|
||||
@@ -86,3 +86,28 @@ 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' }
|
||||
|
||||
Reference in New Issue
Block a user