mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +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
|
||||
|
||||
Reference in New Issue
Block a user