diff --git a/frontend/src/App.vue b/frontend/src/App.vue index f738f51..d448a44 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -630,6 +630,8 @@ function loadUnlockedPhoneData(): void { } function completePhoneSetup(): void { + const requestedRoute = pendingUnlockRoute.value + pendingUnlockRoute.value = null setupPreviewDismissed.value = true setupAppearanceSelected.value = false isLocked.value = false @@ -637,7 +639,7 @@ function completePhoneSetup(): void { passcodeVisible.value = false passcodeRequired.value = false controlCenterOpened.value = false - void router.replace('/') + void router.replace(requestedRoute ?? '/') loadUnlockedPhoneData() } @@ -766,7 +768,12 @@ function onMessage(event: MessageEvent): void { isPhoneAppId(data.appId) && appStore.isInstalled(data.appId) ) { - void router.push(`/apps/${data.appId}`) + const requestedRoute = `/apps/${data.appId}` + if (setupRequired.value || isLocked.value) { + pendingUnlockRoute.value = requestedRoute + } else { + void router.push(requestedRoute) + } } else { console.error('[Navigation] Ignored an unavailable app target.') } diff --git a/frontend/src/phoneNavigation.contract.test.ts b/frontend/src/phoneNavigation.contract.test.ts index 973eb17..2f5d743 100644 --- a/frontend/src/phoneNavigation.contract.test.ts +++ b/frontend/src/phoneNavigation.contract.test.ts @@ -29,4 +29,10 @@ describe('neutral phone navigation contract', () => { expect(navigationSource).toContain('if not installed_apps[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 ?? '/')") + }) }) diff --git a/frontend/src/views/apps/AdminApp.contract.test.ts b/frontend/src/views/apps/AdminApp.contract.test.ts index dfbaf47..5e6048a 100644 --- a/frontend/src/views/apps/AdminApp.contract.test.ts +++ b/frontend/src/views/apps/AdminApp.contract.test.ts @@ -11,6 +11,14 @@ const server = readFileSync( new URL('../../../../sky_phone/source/server/admin.lua', import.meta.url), 'utf8', ) +const phoneServer = readFileSync( + new URL('../../../../sky_phone/source/server/phone.lua', import.meta.url), + 'utf8', +) +const phoneClient = readFileSync( + new URL('../../../../sky_phone/source/client/main.lua', import.meta.url), + 'utf8', +) const bridge = readFileSync( new URL( '../../../../sky_phone/source/client/nui_server_bridge.lua', @@ -71,6 +79,24 @@ describe('admin command center contracts', () => { expect(config).toContain('Config.AdminPanel = {') }) + it('opens directly from a server-authorized configurable command', () => { + expect(config).toContain('Command = "phoneadmin"') + expect(phoneServer).toContain( + 'RegisterCommand(Config.AdminPanel.Command, function(command_source)', + ) + expect(phoneServer).toContain( + 'Bridge.Framework.HasAdminGroup(player_source, Config.AdminPanel.AdminGroups)', + ) + expect(phoneServer).toContain( + 'TriggerClientEvent("sky_phone:admin:launch", player_source)', + ) + expect(phoneServer).toContain('if not sessions[player_source] then') + expect(phoneClient).toContain( + 'RegisterNetEvent("sky_phone:admin:launch"', + ) + expect(phoneClient).toContain('requested_app_id = "admin"') + }) + it('validates ownership and app policy before remote device mutation', () => { expect(server).toContain('find_owned_device(target_source, data.imei)') expect(server).toContain('app_metadata(data.appId)') diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index e074030..3ea92df 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -63,6 +63,7 @@ Config.Security = { Config.AdminPanel = { Enabled = true, + Command = "phoneadmin", AdminGroups = { "admin", "superadmin" }, MaximumPlayers = 128, ReadRequestsPerMinute = 60, diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua index 05477e5..0fb864d 100644 --- a/sky_phone/config/locales/de.lua +++ b/sky_phone/config/locales/de.lua @@ -1,5 +1,13 @@ Locales["de"] = { 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 = { OpenPhone = "Handy öffnen", }, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index a952bee..b0bde34 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -1,5 +1,13 @@ Locales["en"] = { 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 = { OpenPhone = "Open phone", }, diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 42bf0e2..6a05597 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -8,6 +8,7 @@ local equipped_phone_number = nil local nui_generation = 0 local live_activity_active = false local open_home_requested = false +local requested_app_id = nil local function get_equipped_phone_number() if not device_payload or not device_payload.device.sim then @@ -60,6 +61,24 @@ end SkyPhoneClient.GetState = get_phone_state SkyPhoneClient.GetEquippedPhoneNumber = get_authoritative_phone_number +local function open_requested_app() + if not requested_app_id then + return + end + + local app_id = requested_app_id + requested_app_id = nil + local opened, error_code = SkyPhoneNavigation.Open(app_id) + if not opened then + Bridge.Debug( + "warn", + "[sky_phone] Could not open requested app '%s': %s.", + app_id, + tostring(error_code) + ) + end +end + Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true }) local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale) @@ -95,6 +114,7 @@ local function close_phone(close_device_session) local was_open = is_open open_requested = false open_without_focus = false + requested_app_id = nil TriggerEvent("sky_phone:animation:phone", false) is_open = false if was_open then @@ -266,9 +286,27 @@ RegisterNUICallback("ui:opened", function(data, cb) end SkyPhoneFocus.SetPhone(true, open_without_focus) TriggerEvent("sky_phone:animation:phone", true) + open_requested_app() cb({ success = true }) end) +RegisterNetEvent("sky_phone:admin:launch", function() + requested_app_id = "admin" + if is_open then + open_requested_app() + end +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("ui:input-focus", function(data, cb) if type(data) ~= "table" or type(data.active) ~= "boolean" then cb({ success = false, error = "invalid_request" }) @@ -336,9 +374,11 @@ RegisterNetEvent("sky_phone:device:invalidated", function() update_equipped_phone_number(nil) live_activity_active = false open_home_requested = false + requested_app_id = nil end) RegisterNetEvent("sky_phone:device:error", function(error_code) + requested_app_id = nil if not is_open and not open_requested then open_without_focus = false end @@ -359,6 +399,13 @@ CreateThread(function() if Config.TestData.Enabled then TriggerEvent("chat:addSuggestion", "/" .. Config.TestData.Command, locale.TestData.CommandDescription) end + if Config.AdminPanel.Enabled then + TriggerEvent( + "chat:addSuggestion", + "/" .. Config.AdminPanel.Command, + locale.AdminCommand.CommandDescription + ) + end end) AddEventHandler("onResourceStop", function(resource_name) @@ -369,6 +416,7 @@ AddEventHandler("onResourceStop", function(resource_name) is_open = false open_requested = false open_without_focus = false + requested_app_id = nil TriggerEvent("sky_phone:animation:reset") SkyPhoneCalls.Reset() @@ -381,4 +429,7 @@ AddEventHandler("onResourceStop", function(resource_name) if Config.TestData.Enabled then TriggerEvent("chat:removeSuggestion", "/" .. Config.TestData.Command) end + if Config.AdminPanel.Enabled then + TriggerEvent("chat:removeSuggestion", "/" .. Config.AdminPanel.Command) + end end) diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua index eaa50c5..a3063c9 100644 --- a/sky_phone/source/server/phone.lua +++ b/sky_phone/source/server/phone.lua @@ -2,6 +2,10 @@ local phone_open_handler local pending_phone_opens = {} local server_started = false +if type(Config.AdminPanel.Command) ~= "string" or Config.AdminPanel.Command == "" then + error("[sky_phone] Config.AdminPanel.Command must be a non-empty command name.") +end + local function flush_pending_phone_opens() if not server_started or not phone_open_handler then return @@ -915,6 +919,32 @@ end phone_open_handler = open_phone flush_pending_phone_opens() +RegisterCommand(Config.AdminPanel.Command, function(command_source) + local player_source = tonumber(command_source) + if not player_source or player_source < 1 then + Bridge.Debug("warn", "[sky_phone] The admin panel command can only be used by a player.") + return + end + if not Config.AdminPanel.Enabled then + TriggerClientEvent("sky_phone:admin:command-error", player_source, "disabled") + return + end + if not Bridge.Framework.HasAdminGroup(player_source, Config.AdminPanel.AdminGroups) then + Bridge.Debug( + "warn", + "[sky_phone] Rejected admin panel command from source %s.", + tostring(player_source) + ) + TriggerClientEvent("sky_phone:admin:command-error", player_source, "not_authorized") + return + end + + TriggerClientEvent("sky_phone:admin:launch", player_source) + if not sessions[player_source] then + open_phone(player_source, nil) + end +end, false) + function SkyPhone.OpenDeviceForCall(source, imei) local matches = find_device_slots(source, imei) if not matches[1] then diff --git a/tests/server_phone_modules.lua b/tests/server_phone_modules.lua index da9d4cf..63369f1 100644 --- a/tests/server_phone_modules.lua +++ b/tests/server_phone_modules.lua @@ -23,7 +23,11 @@ Bridge = { }, Debug = function() end, - Framework = {}, + Framework = { + HasAdminGroup = function() + return true + end, + }, Inventory = { GetResourceName = function() return "test_inventory" @@ -36,6 +40,11 @@ Bridge = { } Config = { + AdminPanel = { + AdminGroups = { "admin" }, + Command = "phoneadmin", + Enabled = true, + }, Phone = { DevelopmentCommand = false, DeviceName = "Test Phone", @@ -79,6 +88,12 @@ function AddEventHandler(_, callback) assert(type(callback) == "function") end +function RegisterCommand(name, callback, restricted) + assert(name == "phoneadmin") + assert(type(callback) == "function") + assert(restricted == false) +end + function TriggerClientEvent() end