ADD - add device passcode protection

This commit is contained in:
smx.pusha
2026-08-09 00:11:49 +02:00
parent 4da63ac142
commit eefe3b5331
13 changed files with 1128 additions and 10 deletions
+134 -6
View File
@@ -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()"
+215
View File
@@ -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()
})
})
+105 -1
View File
@@ -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 332 character iFruit address.',
invalid_password: 'Password must be 664 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)
+7
View File
@@ -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
}
+245
View File
@@ -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"
+47
View File
@@ -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