mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 00:01:29 +00:00
ADD - add device passcode protection
This commit is contained in:
+134
-6
@@ -14,6 +14,7 @@ import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
|
||||
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
|
||||
import PhoneMediaCapture from '@/components/PhoneMediaCapture.vue'
|
||||
import PhoneLockScreen from '@/components/PhoneLockScreen.vue'
|
||||
import PhonePasscode from '@/components/PhonePasscode.vue'
|
||||
import PhoneNotifications from '@/components/PhoneNotifications.vue'
|
||||
import NotificationPhonePreview from '@/components/NotificationPhonePreview.vue'
|
||||
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
|
||||
@@ -143,6 +144,13 @@ const appTransitionName = computed(() =>
|
||||
)
|
||||
const isLocked = ref(false)
|
||||
const isUnlocking = ref(false)
|
||||
const passcodeBusy = ref(false)
|
||||
const passcodeError = ref('')
|
||||
const passcodeResetKey = ref(0)
|
||||
const passcodeRetrySeconds = ref(0)
|
||||
const passcodeVisible = ref(false)
|
||||
const pendingUnlockRoute = ref<string | null>(null)
|
||||
const unlockedServicesLoaded = ref(false)
|
||||
const controlCenterOpened = ref(false)
|
||||
const simPicker = ref<SimPickerPayload | null>(null)
|
||||
const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
@@ -168,6 +176,7 @@ const phoneFrameImage = computed(
|
||||
)
|
||||
let clockTicker: ReturnType<typeof setInterval> | undefined
|
||||
let unlockTimer: number | undefined
|
||||
let passcodeLockTimer: number | undefined
|
||||
|
||||
function getViewportScale(): number {
|
||||
const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT
|
||||
@@ -185,12 +194,17 @@ function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
media.hydrate(payload.device?.data.media?.payload)
|
||||
appStore.hydrate(payload.device?.data.apps?.payload)
|
||||
widgets.hydrate(payload.device?.data.widgets?.payload)
|
||||
void mail.bootstrap(payload.account?.email ?? '')
|
||||
if (payload.account?.email) void marketplace.loadCounts()
|
||||
}
|
||||
|
||||
function loadUnlockedPhoneData(): void {
|
||||
if (unlockedServicesLoaded.value) return
|
||||
unlockedServicesLoaded.value = true
|
||||
void mail.bootstrap(account.email)
|
||||
if (account.email) void marketplace.loadCounts()
|
||||
else marketplace.setCounts({ active: 0, unread: 0 })
|
||||
void calls.bootstrap()
|
||||
void messages.loadConversations()
|
||||
if (payload.account?.email) void darkchat.bootstrap()
|
||||
if (account.email) void darkchat.bootstrap()
|
||||
}
|
||||
|
||||
async function hydrateDevelopmentPhone(): Promise<void> {
|
||||
@@ -392,8 +406,11 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
) {
|
||||
calls.applyCallState(event.data.data as PhoneCall)
|
||||
controlCenterOpened.value = false
|
||||
isLocked.value = false
|
||||
isUnlocking.value = false
|
||||
if (!phone.security.enabled) {
|
||||
isLocked.value = false
|
||||
isUnlocking.value = false
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
window.setTimeout(() => void router.push('/apps/phone'), 0)
|
||||
} else if (event.data?.type === 'sim:picker' && event.data.data) {
|
||||
simPicker.value = event.data.data as unknown as SimPickerPayload
|
||||
@@ -420,14 +437,78 @@ function updateViewportScale(): void {
|
||||
viewportScale.value = getViewportScale()
|
||||
}
|
||||
|
||||
function unlockPhone(): void {
|
||||
function finishUnlock(): void {
|
||||
if (!isLocked.value) return
|
||||
isUnlocking.value = true
|
||||
isLocked.value = false
|
||||
passcodeVisible.value = false
|
||||
passcodeError.value = ''
|
||||
|
||||
unlockTimer = window.setTimeout(() => {
|
||||
isUnlocking.value = false
|
||||
}, 720)
|
||||
|
||||
if (pendingUnlockRoute.value) {
|
||||
const routePath = pendingUnlockRoute.value
|
||||
pendingUnlockRoute.value = null
|
||||
window.setTimeout(() => void router.push(routePath), 0)
|
||||
}
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
|
||||
function unlockPhone(): void {
|
||||
if (!isLocked.value) return
|
||||
if (phone.security.enabled) {
|
||||
passcodeError.value = ''
|
||||
passcodeVisible.value = true
|
||||
return
|
||||
}
|
||||
finishUnlock()
|
||||
}
|
||||
|
||||
function cancelPasscode(): void {
|
||||
if (passcodeBusy.value) return
|
||||
passcodeVisible.value = false
|
||||
passcodeError.value = ''
|
||||
pendingUnlockRoute.value = null
|
||||
}
|
||||
|
||||
function startPasscodeLock(seconds: number): void {
|
||||
if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer)
|
||||
passcodeRetrySeconds.value = Math.max(1, Math.ceil(seconds))
|
||||
passcodeLockTimer = window.setInterval(() => {
|
||||
passcodeRetrySeconds.value = Math.max(0, passcodeRetrySeconds.value - 1)
|
||||
if (passcodeRetrySeconds.value === 0 && passcodeLockTimer !== undefined) {
|
||||
window.clearInterval(passcodeLockTimer)
|
||||
passcodeLockTimer = undefined
|
||||
passcodeError.value = ''
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function submitUnlockPasscode(passcode: string): Promise<void> {
|
||||
if (passcodeBusy.value || passcodeRetrySeconds.value > 0) return
|
||||
passcodeBusy.value = true
|
||||
const response = await phone.unlockWithPasscode(passcode)
|
||||
passcodeBusy.value = false
|
||||
if (response.success) {
|
||||
finishUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
passcodeResetKey.value += 1
|
||||
if (response.error === 'passcode_locked') {
|
||||
startPasscodeLock(response.data?.retryAfter ?? 30)
|
||||
passcodeError.value = phone.t('LockScreen.passcode.locked', {
|
||||
seconds: String(response.data?.retryAfter ?? 30),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (response.error === 'rate_limited') {
|
||||
passcodeError.value = phone.t('LockScreen.passcode.rateLimited')
|
||||
return
|
||||
}
|
||||
passcodeError.value = phone.t('LockScreen.passcode.incorrect')
|
||||
}
|
||||
|
||||
function toggleControlCenter(): void {
|
||||
@@ -436,6 +517,11 @@ function toggleControlCenter(): void {
|
||||
}
|
||||
|
||||
function unlockCamera(): void {
|
||||
if (phone.security.enabled) {
|
||||
pendingUnlockRoute.value = '/apps/camera'
|
||||
unlockPhone()
|
||||
return
|
||||
}
|
||||
unlockPhone()
|
||||
window.setTimeout(() => void router.push('/apps/camera'), 0)
|
||||
}
|
||||
@@ -524,12 +610,33 @@ watch(
|
||||
controlCenterOpened.value = false
|
||||
isLocked.value = false
|
||||
isUnlocking.value = false
|
||||
passcodeVisible.value = false
|
||||
passcodeBusy.value = false
|
||||
passcodeError.value = ''
|
||||
pendingUnlockRoute.value = null
|
||||
unlockedServicesLoaded.value = false
|
||||
if (passcodeLockTimer !== undefined) {
|
||||
window.clearInterval(passcodeLockTimer)
|
||||
passcodeLockTimer = undefined
|
||||
}
|
||||
return
|
||||
}
|
||||
isLocked.value = true
|
||||
unlockedServicesLoaded.value = false
|
||||
controlCenterOpened.value = false
|
||||
weather.start()
|
||||
isUnlocking.value = false
|
||||
passcodeVisible.value = false
|
||||
passcodeBusy.value = false
|
||||
passcodeError.value = ''
|
||||
passcodeResetKey.value += 1
|
||||
passcodeRetrySeconds.value = Math.max(
|
||||
0,
|
||||
(phone.security.lockedUntil ?? 0) - Math.floor(Date.now() / 1000),
|
||||
)
|
||||
if (passcodeRetrySeconds.value > 0) {
|
||||
startPasscodeLock(passcodeRetrySeconds.value)
|
||||
}
|
||||
phone.setLaunchOrigin(null)
|
||||
void router.replace('/')
|
||||
},
|
||||
@@ -546,6 +653,7 @@ onBeforeUnmount(() => {
|
||||
weather.stop()
|
||||
if (clockTicker) clearInterval(clockTicker)
|
||||
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
|
||||
if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer)
|
||||
window.removeEventListener('message', onMessage)
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
window.removeEventListener('resize', updateViewportScale)
|
||||
@@ -637,6 +745,26 @@ onBeforeUnmount(() => {
|
||||
@unlock="unlockPhone"
|
||||
/>
|
||||
</Transition>
|
||||
<Transition name="lock-screen">
|
||||
<PhonePasscode
|
||||
v-if="isLocked && passcodeVisible"
|
||||
:busy="passcodeBusy"
|
||||
:disabled="passcodeRetrySeconds > 0"
|
||||
:error="passcodeError"
|
||||
:length="phone.security.length ?? 6"
|
||||
:reset-key="passcodeResetKey"
|
||||
:subtitle="
|
||||
passcodeRetrySeconds > 0
|
||||
? phone.t('LockScreen.passcode.tryAgain', {
|
||||
seconds: String(passcodeRetrySeconds),
|
||||
})
|
||||
: phone.t('LockScreen.passcode.unlockSubtitle')
|
||||
"
|
||||
:title="phone.t('LockScreen.passcode.enter')"
|
||||
@cancel="cancelPasscode"
|
||||
@complete="submitUnlockPasscode"
|
||||
/>
|
||||
</Transition>
|
||||
<PhoneNotifications
|
||||
:notification="notifications.current"
|
||||
@close="notifications.dismissCurrent()"
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
<script setup lang="ts">
|
||||
import { Delete } from 'lucide-vue-next'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
busy?: boolean
|
||||
cancelable?: boolean
|
||||
disabled?: boolean
|
||||
error?: string
|
||||
length: 4 | 6
|
||||
resetKey?: number
|
||||
subtitle?: string
|
||||
title: string
|
||||
}>(),
|
||||
{
|
||||
busy: false,
|
||||
cancelable: true,
|
||||
disabled: false,
|
||||
error: '',
|
||||
resetKey: 0,
|
||||
subtitle: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
complete: [passcode: string]
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const digits = ref('')
|
||||
const keypad = [1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
const inputDisabled = computed(() => props.busy || props.disabled)
|
||||
|
||||
function enterDigit(digit: number): void {
|
||||
if (inputDisabled.value || digits.value.length >= props.length) return
|
||||
digits.value += String(digit)
|
||||
if (digits.value.length === props.length) emit('complete', digits.value)
|
||||
}
|
||||
|
||||
function removeDigit(): void {
|
||||
if (inputDisabled.value) return
|
||||
digits.value = digits.value.slice(0, -1)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.resetKey,
|
||||
() => {
|
||||
digits.value = ''
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="passcode-screen" :aria-label="title">
|
||||
<header class="passcode-screen__header">
|
||||
<h1>{{ title }}</h1>
|
||||
<p v-if="subtitle">{{ subtitle }}</p>
|
||||
<div class="passcode-screen__dots" aria-hidden="true">
|
||||
<span
|
||||
v-for="index in length"
|
||||
:key="index"
|
||||
:class="{ 'passcode-screen__dot--filled': digits.length >= index }"
|
||||
></span>
|
||||
</div>
|
||||
<p
|
||||
v-if="error"
|
||||
class="passcode-screen__error"
|
||||
role="alert"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="passcode-screen__keypad">
|
||||
<button
|
||||
v-for="digit in keypad"
|
||||
:key="digit"
|
||||
type="button"
|
||||
:disabled="inputDisabled"
|
||||
@click="enterDigit(digit)"
|
||||
>
|
||||
{{ digit }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="passcode-screen__action"
|
||||
:disabled="!cancelable || busy"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ cancelable ? phone.t('LockScreen.passcode.cancel') : '' }}
|
||||
</button>
|
||||
<button type="button" :disabled="inputDisabled" @click="enterDigit(0)">
|
||||
0
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="passcode-screen__action"
|
||||
:aria-label="phone.t('LockScreen.passcode.delete')"
|
||||
:disabled="inputDisabled || digits.length === 0"
|
||||
@click="removeDigit"
|
||||
>
|
||||
<Delete :size="25" :stroke-width="1.7" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.passcode-screen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 76px 28px 30px;
|
||||
color: white;
|
||||
background:
|
||||
radial-gradient(circle at 50% 16%, rgb(76 92 132 / 46%), transparent 35%),
|
||||
linear-gradient(160deg, #182139, #080b12 72%);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.passcode-screen__header {
|
||||
min-height: 170px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.passcode-screen__header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.passcode-screen__header p {
|
||||
max-width: 270px;
|
||||
margin: 8px auto 0;
|
||||
color: rgb(255 255 255 / 72%);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.passcode-screen__dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.passcode-screen__dots span {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1.5px solid rgb(255 255 255 / 78%);
|
||||
border-radius: 50%;
|
||||
transition: background-color 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.passcode-screen__dots .passcode-screen__dot--filled {
|
||||
background: white;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.passcode-screen__header .passcode-screen__error {
|
||||
color: #ff9b93;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.passcode-screen__keypad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 72px);
|
||||
gap: 15px 20px;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.passcode-screen__keypad button:not(.passcode-screen__action) {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
background: rgb(255 255 255 / 16%);
|
||||
font-size: 30px;
|
||||
font-weight: 400;
|
||||
backdrop-filter: blur(18px);
|
||||
transition: background-color 100ms ease, transform 100ms ease;
|
||||
}
|
||||
|
||||
.passcode-screen__keypad button:not(.passcode-screen__action):active {
|
||||
background: rgb(255 255 255 / 34%);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.passcode-screen__keypad button:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.passcode-screen__keypad .passcode-screen__action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 72px;
|
||||
min-height: 48px;
|
||||
border: 0;
|
||||
color: white;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({
|
||||
nuiCall: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
|
||||
describe('phone passcode store', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', {
|
||||
matchMedia: vi.fn(() => ({ matches: false })),
|
||||
})
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('stores the server security state after setting a six digit passcode', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: {
|
||||
security: { enabled: true, length: 6, lockedUntil: 0 },
|
||||
},
|
||||
success: true,
|
||||
})
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const response = await phone.setPasscode('123456')
|
||||
|
||||
expect(response.success).toBe(true)
|
||||
expect(phone.security).toEqual({
|
||||
enabled: true,
|
||||
length: 6,
|
||||
lockedUntil: 0,
|
||||
})
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('security:set-passcode', {
|
||||
passcode: '123456',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the configured state after a rejected unlock attempt', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
error: 'invalid_passcode',
|
||||
success: false,
|
||||
})
|
||||
|
||||
const phone = usePhoneStore()
|
||||
phone.security = { enabled: true, length: 4, lockedUntil: 0 }
|
||||
await phone.unlockWithPasscode('9999')
|
||||
|
||||
expect(phone.security).toEqual({
|
||||
enabled: true,
|
||||
length: 4,
|
||||
lockedUntil: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the security state after disabling the passcode', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: {
|
||||
security: { enabled: false, length: null, lockedUntil: 0 },
|
||||
},
|
||||
success: true,
|
||||
})
|
||||
|
||||
const phone = usePhoneStore()
|
||||
phone.security = { enabled: true, length: 4, lockedUntil: 0 }
|
||||
await phone.disablePasscode('1234')
|
||||
|
||||
expect(phone.security.enabled).toBe(false)
|
||||
expect(phone.security.length).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,15 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type { AppLaunchOrigin, LaunchablePhoneAppId } from '@/types/apps'
|
||||
import type { DeviceBootstrap, PhoneDevice } from '@/types/device'
|
||||
import type {
|
||||
DeviceBootstrap,
|
||||
DeviceSecurity,
|
||||
PhoneDevice,
|
||||
} from '@/types/device'
|
||||
import { clampPage } from '@/utils/pages'
|
||||
import { cloneJsonData } from '@/utils/clone'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import type { NuiResponse } from '@/utils/nui'
|
||||
import {
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
parsePhonePreferences,
|
||||
@@ -15,12 +20,19 @@ import {
|
||||
|
||||
type LocaleTree = Record<string, unknown>
|
||||
|
||||
export type PasscodeResponseData = {
|
||||
attemptsRemaining?: number
|
||||
retryAfter?: number
|
||||
security?: DeviceSecurity
|
||||
}
|
||||
|
||||
export type PhoneOpenPayload = {
|
||||
account?: DeviceBootstrap['account']
|
||||
device?: PhoneDevice
|
||||
lang?: string
|
||||
locales?: LocaleTree
|
||||
notes?: DeviceBootstrap['notes']
|
||||
security?: DeviceSecurity
|
||||
token?: string
|
||||
}
|
||||
|
||||
@@ -1322,6 +1334,7 @@ const defaultLocales: LocaleTree = {
|
||||
notifications: 'Notifications',
|
||||
sounds: 'Sounds & Haptics',
|
||||
general: 'General Settings',
|
||||
security: 'Passcode & Security',
|
||||
appearance: 'Appearance',
|
||||
allowNotifications: 'Allow Notifications',
|
||||
notificationSounds: 'Sounds',
|
||||
@@ -1366,6 +1379,28 @@ const defaultLocales: LocaleTree = {
|
||||
'This removes the account and all local data from this phone. Cloud data and the IMEI remain.',
|
||||
factoryResetProgress: 'Erasing iFruit Phone',
|
||||
factoryResetWarning: 'Do not turn off this phone. This takes 60 seconds.',
|
||||
passcode: {
|
||||
description:
|
||||
'A passcode protects the contents of this phone. It stays with the device when the SIM or iFruit account changes.',
|
||||
status: 'Passcode',
|
||||
codeLength: 'Code Length',
|
||||
sixDigit: '6-Digit Code',
|
||||
fourDigit: '4-Digit Code',
|
||||
turnOn: 'Turn Passcode On',
|
||||
turnOff: 'Turn Passcode Off',
|
||||
change: 'Change Passcode',
|
||||
enterNew: 'Enter New Passcode',
|
||||
confirmNew: 'Verify New Passcode',
|
||||
enterCurrent: 'Enter Current Passcode',
|
||||
screenSubtitle: 'Use 4 or 6 numbers.',
|
||||
incorrect: 'Incorrect passcode.',
|
||||
mismatch: 'The passcodes did not match.',
|
||||
locked: 'Too many incorrect attempts. Try again later.',
|
||||
rateLimited: 'Too many attempts. Please wait.',
|
||||
failed: 'The passcode could not be updated.',
|
||||
saved: 'Passcode saved.',
|
||||
disabled: 'Passcode turned off.',
|
||||
},
|
||||
accountErrors: {
|
||||
invalid_email: 'Choose a valid 3–32 character iFruit address.',
|
||||
invalid_password: 'Password must be 6–64 characters.',
|
||||
@@ -1460,6 +1495,16 @@ const defaultLocales: LocaleTree = {
|
||||
flashlight: 'Flashlight',
|
||||
camera: 'Camera',
|
||||
swipeUp: 'Swipe up to open',
|
||||
passcode: {
|
||||
enter: 'Enter Passcode',
|
||||
unlockSubtitle: 'Enter the passcode for this phone.',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete digit',
|
||||
incorrect: 'Incorrect passcode',
|
||||
locked: 'Too many attempts. Try again in {seconds} seconds.',
|
||||
tryAgain: 'Try again in {seconds} seconds',
|
||||
rateLimited: 'Too many attempts. Please wait.',
|
||||
},
|
||||
},
|
||||
Home: {
|
||||
appLibrary: 'App Library',
|
||||
@@ -1575,6 +1620,11 @@ export const usePhoneStore = defineStore('phone', {
|
||||
launchOrigin: null as AppLaunchOrigin | null,
|
||||
locales: defaultLocales,
|
||||
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
|
||||
security: {
|
||||
enabled: false,
|
||||
length: null,
|
||||
lockedUntil: 0,
|
||||
} as DeviceSecurity,
|
||||
systemDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||||
}),
|
||||
getters: {
|
||||
@@ -1593,6 +1643,11 @@ export const usePhoneStore = defineStore('phone', {
|
||||
this.lang = payload.lang ?? 'en'
|
||||
this.locales = payload.locales ?? defaultLocales
|
||||
if (payload.device) this.hydrateDevice(payload.device)
|
||||
this.security = payload.security ?? {
|
||||
enabled: false,
|
||||
length: null,
|
||||
lockedUntil: 0,
|
||||
}
|
||||
this.isOpen = true
|
||||
},
|
||||
hydrateDevice(device: PhoneDevice): void {
|
||||
@@ -1662,6 +1717,55 @@ export const usePhoneStore = defineStore('phone', {
|
||||
this.preferences.settings.wallpaper = wallpaper
|
||||
this.saveDeviceNamespace('settings', this.preferences)
|
||||
},
|
||||
async unlockWithPasscode(
|
||||
passcode: string,
|
||||
): Promise<NuiResponse<PasscodeResponseData>> {
|
||||
const response = await nuiCall<PasscodeResponseData>(
|
||||
'security:unlock',
|
||||
{ passcode },
|
||||
)
|
||||
if (response.success && response.data?.security) {
|
||||
this.security = response.data.security
|
||||
}
|
||||
return response
|
||||
},
|
||||
async setPasscode(
|
||||
passcode: string,
|
||||
): Promise<NuiResponse<PasscodeResponseData>> {
|
||||
const response = await nuiCall<PasscodeResponseData>(
|
||||
'security:set-passcode',
|
||||
{ passcode },
|
||||
)
|
||||
if (response.success && response.data?.security) {
|
||||
this.security = response.data.security
|
||||
}
|
||||
return response
|
||||
},
|
||||
async changePasscode(
|
||||
currentPasscode: string,
|
||||
newPasscode: string,
|
||||
): Promise<NuiResponse<PasscodeResponseData>> {
|
||||
const response = await nuiCall<PasscodeResponseData>(
|
||||
'security:change-passcode',
|
||||
{ currentPasscode, newPasscode },
|
||||
)
|
||||
if (response.success && response.data?.security) {
|
||||
this.security = response.data.security
|
||||
}
|
||||
return response
|
||||
},
|
||||
async disablePasscode(
|
||||
passcode: string,
|
||||
): Promise<NuiResponse<PasscodeResponseData>> {
|
||||
const response = await nuiCall<PasscodeResponseData>(
|
||||
'security:disable-passcode',
|
||||
{ passcode },
|
||||
)
|
||||
if (response.success && response.data?.security) {
|
||||
this.security = response.data.security
|
||||
}
|
||||
return response
|
||||
},
|
||||
t(path: string, replacements: Record<string, string> = {}): string {
|
||||
const translated = getByPath(this.locales, path)
|
||||
const fallback = getByPath(defaultLocales, path)
|
||||
|
||||
@@ -19,6 +19,12 @@ export type PhoneNotificationDevicePayload = {
|
||||
settings?: string | null
|
||||
}
|
||||
|
||||
export type DeviceSecurity = {
|
||||
enabled: boolean
|
||||
length: 4 | 6 | null
|
||||
lockedUntil: number
|
||||
}
|
||||
|
||||
export type AccountDevice = {
|
||||
created_at: string
|
||||
current: boolean
|
||||
@@ -37,5 +43,6 @@ export type DeviceBootstrap = {
|
||||
account: IfruitAccount | null
|
||||
device: PhoneDevice
|
||||
notes: Note[]
|
||||
security: DeviceSecurity
|
||||
token: string
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
kPreloader,
|
||||
kRange,
|
||||
kSearchbar,
|
||||
kSegmented,
|
||||
kSegmentedButton,
|
||||
kToast,
|
||||
kToggle,
|
||||
} from 'konsta/vue'
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
import { PHONE_FRAME_COLORS } from '@/config/appearance'
|
||||
import { isLaunchablePhoneApp, PHONE_APPS } from '@/config/apps'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import PhonePasscode from '@/components/PhonePasscode.vue'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import type {
|
||||
LaunchablePhoneAppDefinition,
|
||||
@@ -74,6 +77,7 @@ import {
|
||||
type SettingsView =
|
||||
| 'root'
|
||||
| 'account'
|
||||
| 'security'
|
||||
| 'notifications'
|
||||
| 'notification-detail'
|
||||
| 'sounds'
|
||||
@@ -82,6 +86,14 @@ type SettingsView =
|
||||
| 'wallpaper'
|
||||
type RootToggleKey = 'airplaneMode' | 'streamerMode'
|
||||
type SubmenuView = Exclude<SettingsView, 'root' | 'notification-detail'>
|
||||
type PasscodeFlow =
|
||||
| 'set-new'
|
||||
| 'set-confirm'
|
||||
| 'change-current'
|
||||
| 'change-new'
|
||||
| 'change-confirm'
|
||||
| 'disable'
|
||||
| null
|
||||
|
||||
const FACTORY_RESET_DURATION_MS = 60_000
|
||||
const FACTORY_RESET_CIRCUMFERENCE = 2 * Math.PI * 48
|
||||
@@ -110,6 +122,13 @@ const accountPassword = ref('')
|
||||
const accountConfirm = ref('')
|
||||
const accountSubmitting = ref(false)
|
||||
const accountToast = ref('')
|
||||
const passcodeBusy = ref(false)
|
||||
const passcodeCurrent = ref('')
|
||||
const passcodeError = ref('')
|
||||
const passcodeFirst = ref('')
|
||||
const passcodeFlow = ref<PasscodeFlow>(null)
|
||||
const passcodeLength = ref<4 | 6>(6)
|
||||
const passcodeResetKey = ref(0)
|
||||
const removeDeviceImei = ref('')
|
||||
const removeDevicePassword = ref('')
|
||||
const removeDeviceOpened = ref(false)
|
||||
@@ -152,6 +171,12 @@ const serviceRows = [
|
||||
},
|
||||
]
|
||||
const preferenceRows = [
|
||||
{
|
||||
key: 'security',
|
||||
view: 'security' as const,
|
||||
icon: KeyRound,
|
||||
iconColor: '#34c759',
|
||||
},
|
||||
{
|
||||
key: 'general',
|
||||
view: 'general' as const,
|
||||
@@ -204,6 +229,18 @@ const activeTitle = computed(() => {
|
||||
}
|
||||
return phone.t(`Apps.settings.${activeView.value}`)
|
||||
})
|
||||
const passcodeTitle = computed(() => {
|
||||
if (passcodeFlow.value === 'set-confirm') {
|
||||
return phone.t('Apps.settings.passcode.confirmNew')
|
||||
}
|
||||
if (passcodeFlow.value === 'change-current' || passcodeFlow.value === 'disable') {
|
||||
return phone.t('Apps.settings.passcode.enterCurrent')
|
||||
}
|
||||
if (passcodeFlow.value === 'change-confirm') {
|
||||
return phone.t('Apps.settings.passcode.confirmNew')
|
||||
}
|
||||
return phone.t('Apps.settings.passcode.enterNew')
|
||||
})
|
||||
|
||||
function matchesSearch(key: string): boolean {
|
||||
return (
|
||||
@@ -220,6 +257,9 @@ function updateSearch(event: Event): void {
|
||||
}
|
||||
|
||||
function openView(view: SubmenuView): void {
|
||||
if (view === 'security') {
|
||||
passcodeLength.value = phone.security.length ?? 6
|
||||
}
|
||||
activeView.value = view
|
||||
scrollPageToTop()
|
||||
}
|
||||
@@ -248,6 +288,115 @@ function toggleRootSetting(key: RootToggleKey): void {
|
||||
phone.setPreference(key, !phone.preferences.settings[key])
|
||||
}
|
||||
|
||||
function resetPasscodeInput(): void {
|
||||
passcodeError.value = ''
|
||||
passcodeResetKey.value += 1
|
||||
}
|
||||
|
||||
function beginSetPasscode(): void {
|
||||
passcodeLength.value = phone.security.length ?? passcodeLength.value
|
||||
passcodeFirst.value = ''
|
||||
passcodeCurrent.value = ''
|
||||
passcodeFlow.value = 'set-new'
|
||||
resetPasscodeInput()
|
||||
}
|
||||
|
||||
function beginChangePasscode(): void {
|
||||
passcodeFirst.value = ''
|
||||
passcodeCurrent.value = ''
|
||||
passcodeFlow.value = 'change-current'
|
||||
resetPasscodeInput()
|
||||
}
|
||||
|
||||
function beginDisablePasscode(): void {
|
||||
passcodeLength.value = phone.security.length ?? 6
|
||||
passcodeCurrent.value = ''
|
||||
passcodeFlow.value = 'disable'
|
||||
resetPasscodeInput()
|
||||
}
|
||||
|
||||
function cancelPasscodeFlow(): void {
|
||||
if (passcodeBusy.value) return
|
||||
passcodeFlow.value = null
|
||||
passcodeFirst.value = ''
|
||||
passcodeCurrent.value = ''
|
||||
resetPasscodeInput()
|
||||
}
|
||||
|
||||
function passcodeRequestError(error?: string): string {
|
||||
if (error === 'invalid_passcode') {
|
||||
return phone.t('Apps.settings.passcode.incorrect')
|
||||
}
|
||||
if (error === 'passcode_locked') {
|
||||
return phone.t('Apps.settings.passcode.locked')
|
||||
}
|
||||
if (error === 'rate_limited') {
|
||||
return phone.t('Apps.settings.passcode.rateLimited')
|
||||
}
|
||||
return phone.t('Apps.settings.passcode.failed')
|
||||
}
|
||||
|
||||
async function submitSettingsPasscode(passcode: string): Promise<void> {
|
||||
if (passcodeBusy.value || !passcodeFlow.value) return
|
||||
|
||||
if (passcodeFlow.value === 'set-new') {
|
||||
passcodeFirst.value = passcode
|
||||
passcodeFlow.value = 'set-confirm'
|
||||
resetPasscodeInput()
|
||||
return
|
||||
}
|
||||
if (passcodeFlow.value === 'change-current') {
|
||||
passcodeCurrent.value = passcode
|
||||
passcodeFlow.value = 'change-new'
|
||||
resetPasscodeInput()
|
||||
return
|
||||
}
|
||||
if (passcodeFlow.value === 'change-new') {
|
||||
passcodeFirst.value = passcode
|
||||
passcodeFlow.value = 'change-confirm'
|
||||
resetPasscodeInput()
|
||||
return
|
||||
}
|
||||
if (
|
||||
(passcodeFlow.value === 'set-confirm' ||
|
||||
passcodeFlow.value === 'change-confirm') &&
|
||||
passcode !== passcodeFirst.value
|
||||
) {
|
||||
passcodeError.value = phone.t('Apps.settings.passcode.mismatch')
|
||||
passcodeResetKey.value += 1
|
||||
return
|
||||
}
|
||||
|
||||
passcodeBusy.value = true
|
||||
const response =
|
||||
passcodeFlow.value === 'set-confirm'
|
||||
? await phone.setPasscode(passcode)
|
||||
: passcodeFlow.value === 'change-confirm'
|
||||
? await phone.changePasscode(passcodeCurrent.value, passcode)
|
||||
: await phone.disablePasscode(passcode)
|
||||
passcodeBusy.value = false
|
||||
if (!response.success) {
|
||||
passcodeError.value = passcodeRequestError(response.error)
|
||||
if (
|
||||
passcodeFlow.value === 'change-confirm' &&
|
||||
response.error === 'invalid_passcode'
|
||||
) {
|
||||
passcodeFlow.value = 'change-current'
|
||||
passcodeCurrent.value = ''
|
||||
passcodeFirst.value = ''
|
||||
}
|
||||
passcodeResetKey.value += 1
|
||||
return
|
||||
}
|
||||
|
||||
accountToast.value = phone.t(
|
||||
passcodeFlow.value === 'disable'
|
||||
? 'Apps.settings.passcode.disabled'
|
||||
: 'Apps.settings.passcode.saved',
|
||||
)
|
||||
cancelPasscodeFlow()
|
||||
}
|
||||
|
||||
function updateNumberPreference(
|
||||
key:
|
||||
| 'notificationDurationSeconds'
|
||||
@@ -551,6 +700,15 @@ onBeforeUnmount(() => {
|
||||
<component :is="row.icon" :size="17" :stroke-width="2.25" />
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="row.key === 'security'" #after>
|
||||
{{
|
||||
phone.t(
|
||||
phone.security.enabled
|
||||
? 'Apps.settings.on'
|
||||
: 'Apps.settings.off',
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
</template>
|
||||
@@ -715,6 +873,81 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeView === 'security'">
|
||||
<k-block class="text-sm leading-5 opacity-70">
|
||||
{{ phone.t('Apps.settings.passcode.description') }}
|
||||
</k-block>
|
||||
|
||||
<template v-if="!phone.security.enabled">
|
||||
<k-block-title>{{ phone.t('Apps.settings.passcode.codeLength') }}</k-block-title>
|
||||
<k-block>
|
||||
<k-segmented strong rounded>
|
||||
<k-segmented-button
|
||||
:active="passcodeLength === 6"
|
||||
@click="passcodeLength = 6"
|
||||
>
|
||||
{{ phone.t('Apps.settings.passcode.sixDigit') }}
|
||||
</k-segmented-button>
|
||||
<k-segmented-button
|
||||
:active="passcodeLength === 4"
|
||||
@click="passcodeLength = 4"
|
||||
>
|
||||
{{ phone.t('Apps.settings.passcode.fourDigit') }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</k-block>
|
||||
<k-list strong inset>
|
||||
<k-list-button @click="beginSetPasscode">
|
||||
{{ phone.t('Apps.settings.passcode.turnOn') }}
|
||||
</k-list-button>
|
||||
</k-list>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<k-list strong inset>
|
||||
<k-list-item
|
||||
:title="phone.t('Apps.settings.passcode.status')"
|
||||
:after="phone.t('Apps.settings.on')"
|
||||
/>
|
||||
<k-list-item
|
||||
:title="phone.t('Apps.settings.passcode.codeLength')"
|
||||
:after="
|
||||
phone.t(
|
||||
phone.security.length === 4
|
||||
? 'Apps.settings.passcode.fourDigit'
|
||||
: 'Apps.settings.passcode.sixDigit',
|
||||
)
|
||||
"
|
||||
/>
|
||||
</k-list>
|
||||
<k-block-title>{{ phone.t('Apps.settings.passcode.codeLength') }}</k-block-title>
|
||||
<k-block>
|
||||
<k-segmented strong rounded>
|
||||
<k-segmented-button
|
||||
:active="passcodeLength === 6"
|
||||
@click="passcodeLength = 6"
|
||||
>
|
||||
{{ phone.t('Apps.settings.passcode.sixDigit') }}
|
||||
</k-segmented-button>
|
||||
<k-segmented-button
|
||||
:active="passcodeLength === 4"
|
||||
@click="passcodeLength = 4"
|
||||
>
|
||||
{{ phone.t('Apps.settings.passcode.fourDigit') }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</k-block>
|
||||
<k-list strong inset>
|
||||
<k-list-button @click="beginChangePasscode">
|
||||
{{ phone.t('Apps.settings.passcode.change') }}
|
||||
</k-list-button>
|
||||
<k-list-button class="!text-red-500" @click="beginDisablePasscode">
|
||||
{{ phone.t('Apps.settings.passcode.turnOff') }}
|
||||
</k-list-button>
|
||||
</k-list>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeView === 'notifications'">
|
||||
<k-list strong inset>
|
||||
<k-list-item
|
||||
@@ -1102,6 +1335,18 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</k-page>
|
||||
|
||||
<PhonePasscode
|
||||
v-if="passcodeFlow"
|
||||
:busy="passcodeBusy"
|
||||
:error="passcodeError"
|
||||
:length="passcodeLength"
|
||||
:reset-key="passcodeResetKey"
|
||||
:subtitle="phone.t('Apps.settings.passcode.screenSubtitle')"
|
||||
:title="passcodeTitle"
|
||||
@cancel="cancelPasscodeFlow"
|
||||
@complete="submitSettingsPasscode"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="factoryResetting"
|
||||
class="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-black px-8 text-center text-white"
|
||||
|
||||
@@ -714,6 +714,8 @@ const deviceData = {
|
||||
revision: 1,
|
||||
},
|
||||
}
|
||||
let mockPasscode = ''
|
||||
let mockSecurity = { enabled: false, length: null, lockedUntil: 0 }
|
||||
let mockContacts = [
|
||||
{
|
||||
created_at: isoTime(-14 * 86_400_000),
|
||||
@@ -1435,6 +1437,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
},
|
||||
},
|
||||
notes: mockNotes,
|
||||
security: mockSecurity,
|
||||
token: 'development',
|
||||
},
|
||||
})
|
||||
@@ -1667,12 +1670,56 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
response.json({ success: true, data: { revision } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'security:unlock') {
|
||||
response.json(
|
||||
!mockSecurity.enabled || request.body.passcode === mockPasscode
|
||||
? { success: true, data: { security: mockSecurity } }
|
||||
: { success: false, error: 'invalid_passcode' },
|
||||
)
|
||||
return
|
||||
}
|
||||
if (endpoint === 'security:set-passcode') {
|
||||
mockPasscode = String(request.body.passcode)
|
||||
mockSecurity = {
|
||||
enabled: true,
|
||||
length: mockPasscode.length,
|
||||
lockedUntil: 0,
|
||||
}
|
||||
response.json({ success: true, data: { security: mockSecurity } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'security:change-passcode') {
|
||||
if (request.body.currentPasscode !== mockPasscode) {
|
||||
response.json({ success: false, error: 'invalid_passcode' })
|
||||
return
|
||||
}
|
||||
mockPasscode = String(request.body.newPasscode)
|
||||
mockSecurity = {
|
||||
enabled: true,
|
||||
length: mockPasscode.length,
|
||||
lockedUntil: 0,
|
||||
}
|
||||
response.json({ success: true, data: { security: mockSecurity } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'security:disable-passcode') {
|
||||
if (request.body.passcode !== mockPasscode) {
|
||||
response.json({ success: false, error: 'invalid_passcode' })
|
||||
return
|
||||
}
|
||||
mockPasscode = ''
|
||||
mockSecurity = { enabled: false, length: null, lockedUntil: 0 }
|
||||
response.json({ success: true, data: { security: mockSecurity } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'device:factory-reset') {
|
||||
authenticated = false
|
||||
linkedAccount = null
|
||||
mockNotes = []
|
||||
mockMedia = []
|
||||
calendarEvents = []
|
||||
mockPasscode = ''
|
||||
mockSecurity = { enabled: false, length: null, lockedUntil: 0 }
|
||||
for (const key of Object.keys(deviceData)) delete deviceData[key]
|
||||
response.json({ success: true })
|
||||
return
|
||||
|
||||
@@ -19,6 +19,13 @@ Config.Phone = {
|
||||
DeviceName = "iFruit Phone",
|
||||
}
|
||||
|
||||
Config.Security = {
|
||||
PasscodePepperConvar = "sky_phone_passcode_pepper",
|
||||
MaximumAttempts = 5,
|
||||
LockSeconds = 30,
|
||||
AttemptsPerMinute = 12,
|
||||
}
|
||||
|
||||
Config.Sim = {
|
||||
RegisteredItem = "sky_phone_sim_registered",
|
||||
AnonymousItem = "sky_phone_sim_anonymous",
|
||||
|
||||
@@ -29,6 +29,11 @@ Locales["en"] = {
|
||||
Notifications = { now = "now" },
|
||||
LockScreen = {
|
||||
label = "Lock Screen", flashlight = "Flashlight", camera = "Camera", swipeUp = "Swipe up to open",
|
||||
passcode = {
|
||||
enter = "Enter Passcode", unlockSubtitle = "Enter the passcode for this phone.", cancel = "Cancel", delete = "Delete digit",
|
||||
incorrect = "Incorrect passcode", locked = "Too many attempts. Try again in {seconds} seconds.",
|
||||
tryAgain = "Try again in {seconds} seconds", rateLimited = "Too many attempts. Please wait.",
|
||||
},
|
||||
},
|
||||
Home = {
|
||||
appLibrary = "App Library", appLibrarySearch = "Search apps", allApps = "All Apps", apps = "Apps",
|
||||
@@ -545,7 +550,7 @@ Locales["en"] = {
|
||||
accountInformation = "Account Information", accountStatus = "Account Status", accountStatusValue = "Active",
|
||||
accountStorage = "Cloud Storage", accountStorageValue = "On Device", accountPurchases = "Media & Purchases",
|
||||
accountPurchasesValue = "Available", notifications = "Notifications", sounds = "Sounds & Haptics",
|
||||
general = "General Settings", appearance = "Appearance", allowNotifications = "Allow Notifications",
|
||||
general = "General Settings", security = "Passcode & Security", appearance = "Appearance", allowNotifications = "Allow Notifications",
|
||||
notificationSounds = "Sounds", notificationDuration = "Notification Duration", seconds = "{seconds} seconds",
|
||||
ringtoneVolume = "Ringtone Volume", notificationVolume = "Notification Volume",
|
||||
ringtone = "Ringtone", notificationSound = "Notification Sound", appearanceMode = "Appearance Mode",
|
||||
@@ -559,6 +564,15 @@ Locales["en"] = {
|
||||
removeDeviceBody = "Enter your iFruit password to remove this device from the account.", signOut = "Sign Out",
|
||||
factoryReset = "Erase All Content and Settings", factoryResetBody = "This removes the account and all local data from this phone. Cloud data and the IMEI remain.",
|
||||
factoryResetProgress = "Erasing iFruit Phone", factoryResetWarning = "Do not turn off this phone. This takes 60 seconds.",
|
||||
passcode = {
|
||||
description = "A passcode protects the contents of this phone. It stays with the device when the SIM or iFruit account changes.",
|
||||
status = "Passcode", codeLength = "Code Length", sixDigit = "6-Digit Code", fourDigit = "4-Digit Code",
|
||||
turnOn = "Turn Passcode On", turnOff = "Turn Passcode Off", change = "Change Passcode",
|
||||
enterNew = "Enter New Passcode", confirmNew = "Verify New Passcode", enterCurrent = "Enter Current Passcode",
|
||||
screenSubtitle = "Use 4 or 6 numbers.", incorrect = "Incorrect passcode.", mismatch = "The passcodes did not match.",
|
||||
locked = "Too many incorrect attempts. Try again later.", rateLimited = "Too many attempts. Please wait.",
|
||||
failed = "The passcode could not be updated.", saved = "Passcode saved.", disabled = "Passcode turned off.",
|
||||
},
|
||||
accountErrors = {
|
||||
invalid_email = "Choose a valid 3–32 character iFruit address.", invalid_password = "Password must be 6–64 characters.",
|
||||
invalid_credentials = "Email or password is incorrect.", email_taken = "That iFruit address is already registered.",
|
||||
|
||||
@@ -8,6 +8,10 @@ local call_channel = 0
|
||||
Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true })
|
||||
|
||||
local server_callbacks = {
|
||||
"security:unlock",
|
||||
"security:set-passcode",
|
||||
"security:change-passcode",
|
||||
"security:disable-passcode",
|
||||
"device:save",
|
||||
"device:factory-reset",
|
||||
"account:login",
|
||||
|
||||
@@ -208,6 +208,39 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_device_security",
|
||||
columns = {
|
||||
{
|
||||
name = "device_imei",
|
||||
type = "CHAR(15) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{ name = "passcode_hash", type = "BINARY(32) NOT NULL" },
|
||||
{
|
||||
name = "passcode_salt",
|
||||
type = "CHAR(32) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{ name = "passcode_length", type = "TINYINT UNSIGNED NOT NULL" },
|
||||
{ name = "failed_attempts", type = "TINYINT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{ name = "locked_until", type = "BIGINT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{
|
||||
name = "updated_at",
|
||||
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
|
||||
},
|
||||
},
|
||||
primaryKey = "device_imei",
|
||||
foreignKeys = {
|
||||
{
|
||||
column = "device_imei",
|
||||
references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE",
|
||||
},
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_notes",
|
||||
columns = {
|
||||
|
||||
@@ -7,6 +7,15 @@ local sessions = {}
|
||||
local auth_attempts = {}
|
||||
local operation_attempts = {}
|
||||
local max_device_data_bytes = 100000
|
||||
local passcode_pepper = GetConvar(Config.Security.PasscodePepperConvar, "")
|
||||
if passcode_pepper == "" then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Passcode pepper convar '%s' is empty; configure it before production use.",
|
||||
Config.Security.PasscodePepperConvar,
|
||||
{ always = true }
|
||||
)
|
||||
end
|
||||
local allowed_device_namespaces = {
|
||||
settings = true,
|
||||
notifications = true,
|
||||
@@ -234,6 +243,98 @@ local function load_device_data(imei)
|
||||
return data
|
||||
end
|
||||
|
||||
local function load_device_security(imei)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `passcode_length`, `failed_attempts`, `locked_until`
|
||||
FROM `sky_phone_device_security`
|
||||
WHERE `device_imei` = ?
|
||||
LIMIT 1
|
||||
]], { imei })
|
||||
return rows[1]
|
||||
end
|
||||
|
||||
local function security_status(imei)
|
||||
local security = load_device_security(imei)
|
||||
return {
|
||||
enabled = security ~= nil,
|
||||
length = security and tonumber(security.passcode_length) or nil,
|
||||
lockedUntil = security and tonumber(security.locked_until) or 0,
|
||||
}
|
||||
end
|
||||
|
||||
local function valid_passcode(value)
|
||||
return type(value) == "string"
|
||||
and (#value == 4 or #value == 6)
|
||||
and value:match("^%d+$") ~= nil
|
||||
end
|
||||
|
||||
local function passcode_matches(imei, passcode)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT 1 AS `matches`
|
||||
FROM `sky_phone_device_security`
|
||||
WHERE `device_imei` = ?
|
||||
AND `passcode_hash` = UNHEX(SHA2(CONCAT(?, `passcode_salt`, ?), 256))
|
||||
LIMIT 1
|
||||
]], { imei, passcode_pepper, passcode })
|
||||
return rows[1] ~= nil
|
||||
end
|
||||
|
||||
local function verify_passcode(session, passcode)
|
||||
if not valid_passcode(passcode) then
|
||||
return false, { success = false, error = "invalid_passcode" }
|
||||
end
|
||||
|
||||
local security = load_device_security(session.imei)
|
||||
if not security then
|
||||
return false, { success = false, error = "passcode_not_set" }
|
||||
end
|
||||
|
||||
local now = os.time()
|
||||
local locked_until = tonumber(security.locked_until) or 0
|
||||
if locked_until > now then
|
||||
return false, {
|
||||
success = false,
|
||||
error = "passcode_locked",
|
||||
data = { retryAfter = locked_until - now },
|
||||
}
|
||||
end
|
||||
|
||||
if passcode_matches(session.imei, passcode) then
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_device_security`
|
||||
SET `failed_attempts` = 0, `locked_until` = 0
|
||||
WHERE `device_imei` = ?
|
||||
]], { session.imei })
|
||||
return true
|
||||
end
|
||||
|
||||
local failed_attempts = (tonumber(security.failed_attempts) or 0) + 1
|
||||
if failed_attempts >= Config.Security.MaximumAttempts then
|
||||
local next_unlock = now + Config.Security.LockSeconds
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_device_security`
|
||||
SET `failed_attempts` = 0, `locked_until` = ?
|
||||
WHERE `device_imei` = ?
|
||||
]], { next_unlock, session.imei })
|
||||
return false, {
|
||||
success = false,
|
||||
error = "passcode_locked",
|
||||
data = { retryAfter = Config.Security.LockSeconds },
|
||||
}
|
||||
end
|
||||
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_device_security`
|
||||
SET `failed_attempts` = ?
|
||||
WHERE `device_imei` = ?
|
||||
]], { failed_attempts, session.imei })
|
||||
return false, {
|
||||
success = false,
|
||||
error = "invalid_passcode",
|
||||
data = { attemptsRemaining = Config.Security.MaximumAttempts - failed_attempts },
|
||||
}
|
||||
end
|
||||
|
||||
local function account_devices(account_id, current_imei)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `imei`, `device_name`, `created_at`, `updated_at`
|
||||
@@ -248,7 +349,7 @@ local function account_devices(account_id, current_imei)
|
||||
end
|
||||
|
||||
local function bootstrap(source)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
local session, error_response = SkyPhone.RequireDeviceSession(source)
|
||||
if not session then
|
||||
return nil, error_response
|
||||
end
|
||||
@@ -260,6 +361,7 @@ local function bootstrap(source)
|
||||
|
||||
return {
|
||||
token = session.token,
|
||||
security = security_status(device.imei),
|
||||
device = {
|
||||
imei = device.imei,
|
||||
name = device.device_name,
|
||||
@@ -417,7 +519,7 @@ local function authenticate(source, data, registering)
|
||||
return link_account(source, accounts[1])
|
||||
end
|
||||
|
||||
function SkyPhone.RequireSession(source)
|
||||
function SkyPhone.RequireDeviceSession(source)
|
||||
local session = sessions[source]
|
||||
if not session then
|
||||
return nil, { success = false, error = "device_not_open" }
|
||||
@@ -433,6 +535,17 @@ function SkyPhone.RequireSession(source)
|
||||
return session
|
||||
end
|
||||
|
||||
function SkyPhone.RequireSession(source)
|
||||
local session, error_response = SkyPhone.RequireDeviceSession(source)
|
||||
if not session then
|
||||
return nil, error_response
|
||||
end
|
||||
if not session.unlocked then
|
||||
return nil, { success = false, error = "device_locked" }
|
||||
end
|
||||
return session
|
||||
end
|
||||
|
||||
function SkyPhone.AllowOperation(source, operation, maximum, window_seconds)
|
||||
local now = os.time()
|
||||
operation_attempts[source] = operation_attempts[source] or {}
|
||||
@@ -565,10 +678,12 @@ local function open_phone(source, used_item)
|
||||
return false
|
||||
end
|
||||
|
||||
local security = load_device_security(imei)
|
||||
sessions[source] = {
|
||||
imei = imei,
|
||||
slot = slot.slot,
|
||||
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
|
||||
unlocked = security == nil,
|
||||
}
|
||||
local payload = bootstrap(source)
|
||||
Bridge.Debug(
|
||||
@@ -590,10 +705,12 @@ function SkyPhone.OpenDeviceForCall(source, imei)
|
||||
Bridge.Debug("warn", "[sky_phone] Could not open ringing device %s for source %s.", tostring(imei), tostring(source))
|
||||
return false
|
||||
end
|
||||
local security = load_device_security(imei)
|
||||
sessions[source] = {
|
||||
imei = imei,
|
||||
slot = matches[1].slot,
|
||||
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
|
||||
unlocked = security == nil,
|
||||
}
|
||||
TriggerClientEvent("sky_phone:device:open", source, bootstrap(source))
|
||||
return true
|
||||
@@ -619,6 +736,106 @@ Bridge.Callbacks.Register("sky_phone:device:close", function(source)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:security:unlock", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "security_unlock", Config.Security.AttemptsPerMinute, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local session, error_response = SkyPhone.RequireDeviceSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
if session.unlocked then
|
||||
return { success = true, data = { security = security_status(session.imei) } }
|
||||
end
|
||||
|
||||
local verified, verification_error = verify_passcode(session, data and data.passcode)
|
||||
if not verified then
|
||||
return verification_error
|
||||
end
|
||||
session.unlocked = true
|
||||
return { success = true, data = { security = security_status(session.imei) } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:security:set-passcode", function(source, data)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
local passcode = data and data.passcode
|
||||
if not valid_passcode(passcode) then
|
||||
return { success = false, error = "invalid_passcode" }
|
||||
end
|
||||
if load_device_security(session.imei) then
|
||||
return { success = false, error = "passcode_already_set" }
|
||||
end
|
||||
|
||||
local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {})
|
||||
local salt = salts[1] and salts[1].salt
|
||||
if type(salt) ~= "string" or #salt ~= 32 then
|
||||
error("[sky_phone] Database did not generate a valid passcode salt.")
|
||||
end
|
||||
local result = Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_device_security`
|
||||
(`device_imei`, `passcode_hash`, `passcode_salt`, `passcode_length`)
|
||||
VALUES (?, UNHEX(SHA2(CONCAT(?, ?, ?), 256)), ?, ?)
|
||||
]], { session.imei, passcode_pepper, salt, passcode, salt, #passcode })
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
return { success = true, data = { security = security_status(session.imei) } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:security:change-passcode", function(source, data)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
local new_passcode = data and data.newPasscode
|
||||
if not valid_passcode(new_passcode) then
|
||||
return { success = false, error = "invalid_passcode" }
|
||||
end
|
||||
local verified, verification_error = verify_passcode(session, data and data.currentPasscode)
|
||||
if not verified then
|
||||
return verification_error
|
||||
end
|
||||
|
||||
local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {})
|
||||
local salt = salts[1] and salts[1].salt
|
||||
if type(salt) ~= "string" or #salt ~= 32 then
|
||||
error("[sky_phone] Database did not generate a valid passcode salt.")
|
||||
end
|
||||
local result = Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_device_security`
|
||||
SET `passcode_hash` = UNHEX(SHA2(CONCAT(?, ?, ?), 256)),
|
||||
`passcode_salt` = ?, `passcode_length` = ?, `failed_attempts` = 0, `locked_until` = 0
|
||||
WHERE `device_imei` = ?
|
||||
]], { passcode_pepper, salt, new_passcode, salt, #new_passcode, session.imei })
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
return { success = true, data = { security = security_status(session.imei) } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:security:disable-passcode", function(source, data)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
local verified, verification_error = verify_passcode(session, data and data.passcode)
|
||||
if not verified then
|
||||
return verification_error
|
||||
end
|
||||
local result = Bridge.Database.Query(
|
||||
"DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
|
||||
{ session.imei }
|
||||
)
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
session.unlocked = true
|
||||
return { success = true, data = { security = security_status(session.imei) } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:device:development-open", function(source)
|
||||
if not Config.Phone.DevelopmentCommand then
|
||||
return { success = false, error = "disabled" }
|
||||
@@ -764,6 +981,10 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
||||
end
|
||||
local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(session.imei)
|
||||
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 },
|
||||
@@ -792,6 +1013,7 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids)
|
||||
session.unlocked = true
|
||||
refresh_source(source)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
@@ -92,6 +92,18 @@ CREATE TABLE IF NOT EXISTS `sky_phone_device_data` (
|
||||
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_device_security` (
|
||||
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`passcode_hash` BINARY(32) NOT NULL,
|
||||
`passcode_salt` CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`passcode_length` TINYINT UNSIGNED NOT NULL,
|
||||
`failed_attempts` TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`locked_until` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`device_imei`),
|
||||
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_notes` (
|
||||
`id` VARCHAR(64) NOT NULL,
|
||||
`account_id` BIGINT UNSIGNED NULL,
|
||||
|
||||
Reference in New Issue
Block a user