mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
322 lines
10 KiB
Vue
322 lines
10 KiB
Vue
<template>
|
|
<q-page class="q-pa-md">
|
|
<div class="row items-center q-mb-md">
|
|
<q-btn
|
|
flat
|
|
dense
|
|
round
|
|
color="primary"
|
|
icon="arrow_back"
|
|
aria-label="Back"
|
|
@click="router.push('/settings')"
|
|
/>
|
|
<div class="text-h5 text-weight-bold text-primary q-ml-sm">Security</div>
|
|
</div>
|
|
|
|
<!-- passkeys -->
|
|
<q-list class="sattle-card q-mb-md" bordered separator>
|
|
<q-item-label header class="text-primary text-weight-bold">Passkeys</q-item-label>
|
|
|
|
<q-item v-if="supported === null">
|
|
<q-item-section class="text-grey-5">Checking passkey support…</q-item-section>
|
|
</q-item>
|
|
|
|
<q-item v-else-if="supported === false">
|
|
<q-item-section>
|
|
<q-item-label class="text-grey-3">Passkeys aren't available here</q-item-label>
|
|
<q-item-label caption class="text-grey-5" style="white-space: normal">
|
|
This browser has no passkey authenticator (like Touch ID, Windows Hello or Android
|
|
biometrics) with the encryption support sattle needs. Your password unlock keeps working
|
|
- nothing to do.
|
|
</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
|
|
<template v-else>
|
|
<q-item v-if="wallet.state !== 'unlocked'">
|
|
<q-item-section class="text-grey-5">
|
|
Unlock your wallet first - adding a passkey needs the wallet's key in memory.
|
|
</q-item-section>
|
|
</q-item>
|
|
|
|
<template v-else>
|
|
<q-item v-if="!slots.length">
|
|
<q-item-section class="text-grey-5">
|
|
No passkeys yet. A passkey lets you unlock this wallet with your device's screen lock
|
|
instead of typing the password.
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-item v-for="slot in slots" :key="slot.credentialId">
|
|
<q-item-section>
|
|
<q-item-label class="text-grey-3">{{ slot.name || 'Passkey' }}</q-item-label>
|
|
<q-item-label caption class="text-grey-5">
|
|
Added {{ new Date(slot.createdAt).toLocaleDateString() }}
|
|
</q-item-label>
|
|
</q-item-section>
|
|
<q-item-section side>
|
|
<q-btn flat dense no-caps color="negative" label="Remove" @click="askRemove(slot)" />
|
|
</q-item-section>
|
|
</q-item>
|
|
<div class="q-pa-md">
|
|
<q-btn
|
|
unelevated
|
|
color="primary"
|
|
text-color="dark"
|
|
icon="fingerprint"
|
|
label="Add a passkey"
|
|
class="full-width"
|
|
:loading="registerBusy"
|
|
@click="askRegister"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
</q-list>
|
|
|
|
<!-- biometric unlock (native app only - Android WebView has no WebAuthn
|
|
platform authenticator for us, so this is the native biometric path) -->
|
|
<q-list v-if="biometricNative" class="sattle-card q-mb-md" bordered>
|
|
<q-item-label header class="text-primary text-weight-bold"> Biometric unlock </q-item-label>
|
|
|
|
<q-item v-if="wallet.state !== 'unlocked'">
|
|
<q-item-section class="text-grey-5">
|
|
Unlock your wallet first - enabling biometric unlock needs the wallet's key in memory.
|
|
</q-item-section>
|
|
</q-item>
|
|
|
|
<template v-else>
|
|
<q-item>
|
|
<q-item-section>
|
|
<q-item-label class="text-grey-3">
|
|
{{ biometricEnrolled ? 'Biometric unlock is on' : 'Biometric unlock is off' }}
|
|
</q-item-label>
|
|
<q-item-label caption class="text-grey-5" style="white-space: normal">
|
|
Unlock this wallet with your device's screen lock instead of the password. The wallet
|
|
key stays wrapped on this device; the biometric prompt gates reading it.
|
|
</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
<div class="q-pa-md">
|
|
<q-btn
|
|
v-if="!biometricEnrolled"
|
|
unelevated
|
|
color="primary"
|
|
text-color="dark"
|
|
icon="fingerprint"
|
|
label="Enable biometric unlock"
|
|
class="full-width"
|
|
:loading="biometricBusy"
|
|
@click="doEnableBiometric"
|
|
/>
|
|
<q-btn
|
|
v-else
|
|
outline
|
|
no-caps
|
|
color="negative"
|
|
label="Disable biometric unlock"
|
|
class="full-width"
|
|
:loading="biometricBusy"
|
|
@click="doDisableBiometric"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</q-list>
|
|
|
|
<!-- auto-lock -->
|
|
<q-list class="sattle-card q-mb-md" bordered>
|
|
<q-item-label header class="text-primary text-weight-bold">Auto-lock</q-item-label>
|
|
<q-item>
|
|
<q-item-section>
|
|
<q-item-label class="text-grey-3"> Locks after 5 minutes without activity </q-item-label>
|
|
<q-item-label caption class="text-grey-5" style="white-space: normal">
|
|
Applies when your wallet is password-protected. You get a 30-second warning with a "stay
|
|
unlocked" option before it locks. The duration isn't configurable yet.
|
|
</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
|
|
<q-banner v-if="banner" dense class="bg-negative text-white rounded-borders q-mb-md">
|
|
{{ banner }}
|
|
</q-banner>
|
|
|
|
<!-- add-passkey dialog: name it, then the authenticator ceremony runs -->
|
|
<q-dialog v-model="registering">
|
|
<q-card class="sattle-card q-pa-lg">
|
|
<div class="text-h6 text-primary q-mb-sm">Add a passkey</div>
|
|
<div class="text-body2 text-grey-4 q-mb-md">
|
|
Your device will ask for its screen lock. The passkey only ever unlocks this wallet on
|
|
this device - it never leaves it.
|
|
</div>
|
|
<q-input
|
|
v-model="registerName"
|
|
dark
|
|
outlined
|
|
color="primary"
|
|
label="Name (optional)"
|
|
placeholder="e.g. laptop"
|
|
autocomplete="off"
|
|
class="q-mb-md"
|
|
/>
|
|
<div class="row q-gutter-sm justify-end">
|
|
<q-btn v-close-popup flat no-caps color="grey-5" label="Cancel" />
|
|
<q-btn
|
|
unelevated
|
|
no-caps
|
|
color="primary"
|
|
text-color="dark"
|
|
label="Create passkey"
|
|
:loading="registerBusy"
|
|
@click="doRegister"
|
|
/>
|
|
</div>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
<!-- remove confirmation -->
|
|
<q-dialog v-model="confirmingRemove">
|
|
<q-card class="sattle-card q-pa-lg">
|
|
<div class="text-h6 text-primary q-mb-sm">Remove passkey</div>
|
|
<div class="text-body2 text-grey-4 q-mb-md">
|
|
Remove <strong>{{ removeTarget?.name || 'this passkey' }}</strong
|
|
>? It will no longer unlock this wallet. You can remove it from your device's passkey list
|
|
separately - the wallet can't do that for you.
|
|
</div>
|
|
<div class="row q-gutter-sm justify-end">
|
|
<q-btn v-close-popup flat no-caps color="grey-5" label="Cancel" />
|
|
<q-btn
|
|
unelevated
|
|
no-caps
|
|
color="negative"
|
|
text-color="white"
|
|
label="Remove"
|
|
@click="doRemove"
|
|
/>
|
|
</div>
|
|
</q-card>
|
|
</q-dialog>
|
|
</q-page>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { onMounted, ref } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
import { useQuasar } from 'quasar';
|
|
|
|
import {
|
|
disableBiometricUnlock,
|
|
enableBiometricUnlock,
|
|
isBiometricUnlockEnrolled,
|
|
} from '@/capabilities/biometricUnlock';
|
|
import { isNative } from '@/capabilities/platform';
|
|
import type { PasskeySlot } from '@/lnurlcash/passkeys';
|
|
import {
|
|
passkeySupported,
|
|
readPasskeySlots,
|
|
registerPasskey,
|
|
removePasskey,
|
|
} from '@/lnurlcash/passkeys';
|
|
import { useWalletStore } from '@/stores/wallet';
|
|
|
|
const router = useRouter();
|
|
const $q = useQuasar();
|
|
const wallet = useWalletStore();
|
|
|
|
const toast = (type: 'positive' | 'negative', message: string): void => {
|
|
if (typeof $q.notify === 'function') {
|
|
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
|
}
|
|
};
|
|
|
|
const banner = ref('');
|
|
|
|
// null while the async probe runs - the support answer decides which of
|
|
// the three states (checking / unsupported / manage) renders
|
|
const supported = ref<boolean | null>(null);
|
|
const slots = ref<PasskeySlot[]>([]);
|
|
|
|
onMounted(async () => {
|
|
supported.value = await passkeySupported();
|
|
slots.value = readPasskeySlots();
|
|
});
|
|
|
|
// ---- biometric unlock (native only - see capabilities/biometricUnlock.ts) ----
|
|
const biometricNative = isNative();
|
|
const biometricEnrolled = ref(biometricNative && isBiometricUnlockEnrolled());
|
|
const biometricBusy = ref(false);
|
|
|
|
const doEnableBiometric = async () => {
|
|
biometricBusy.value = true;
|
|
banner.value = '';
|
|
try {
|
|
await enableBiometricUnlock(wallet.requireWalletMaterial());
|
|
biometricEnrolled.value = true;
|
|
toast('positive', 'Biometric unlock enabled.');
|
|
} catch (err) {
|
|
banner.value = err instanceof Error ? err.message : 'Could not enable biometric unlock.';
|
|
} finally {
|
|
biometricBusy.value = false;
|
|
}
|
|
};
|
|
|
|
const doDisableBiometric = async () => {
|
|
biometricBusy.value = true;
|
|
banner.value = '';
|
|
try {
|
|
await disableBiometricUnlock();
|
|
biometricEnrolled.value = false;
|
|
toast('positive', 'Biometric unlock disabled.');
|
|
} catch (err) {
|
|
banner.value = err instanceof Error ? err.message : 'Could not disable biometric unlock.';
|
|
} finally {
|
|
biometricBusy.value = false;
|
|
}
|
|
};
|
|
|
|
// ---- register ----
|
|
const registering = ref(false);
|
|
const registerName = ref('');
|
|
const registerBusy = ref(false);
|
|
|
|
const askRegister = () => {
|
|
banner.value = '';
|
|
registerName.value = '';
|
|
registering.value = true;
|
|
};
|
|
|
|
const doRegister = async () => {
|
|
registerBusy.value = true;
|
|
banner.value = '';
|
|
try {
|
|
const name = registerName.value.trim();
|
|
await registerPasskey(wallet.requireWalletMaterial(), name ? { name } : {});
|
|
slots.value = readPasskeySlots();
|
|
registering.value = false;
|
|
toast('positive', 'Passkey added.');
|
|
} catch (err) {
|
|
banner.value = err instanceof Error ? err.message : 'Could not add that passkey.';
|
|
registering.value = false;
|
|
} finally {
|
|
registerBusy.value = false;
|
|
}
|
|
};
|
|
|
|
// ---- remove ----
|
|
const confirmingRemove = ref(false);
|
|
const removeTarget = ref<PasskeySlot | null>(null);
|
|
|
|
const askRemove = (slot: PasskeySlot) => {
|
|
banner.value = '';
|
|
removeTarget.value = slot;
|
|
confirmingRemove.value = true;
|
|
};
|
|
|
|
const doRemove = async () => {
|
|
confirmingRemove.value = false;
|
|
if (!removeTarget.value) return;
|
|
await removePasskey(removeTarget.value.credentialId);
|
|
slots.value = readPasskeySlots();
|
|
toast('positive', 'Passkey removed.');
|
|
};
|
|
</script>
|