aboutsummaryrefslogtreecommitdiff
path: root/extension
diff options
context:
space:
mode:
Diffstat (limited to 'extension')
-rw-r--r--extension/package-lock.json33
-rw-r--r--extension/package.json4
-rw-r--r--extension/src/assets/modals.scss4
-rw-r--r--extension/src/components/ConfirmPrompt.vue67
-rw-r--r--extension/src/components/PasswordPrompt.vue110
-rw-r--r--extension/src/entries/background/main.ts6
-rw-r--r--extension/src/entries/contentScript/renderContent.js6
-rw-r--r--extension/src/entries/options/App.vue23
-rw-r--r--extension/src/entries/options/components/Account.vue34
-rw-r--r--extension/src/entries/options/components/Identities.vue23
-rw-r--r--extension/src/entries/options/components/Pki.vue237
-rw-r--r--extension/src/entries/options/components/Privacy.vue19
-rw-r--r--extension/src/entries/options/components/SiteSettings.vue6
-rw-r--r--extension/src/entries/options/components/Totp.vue234
-rw-r--r--extension/src/entries/options/main.js7
-rw-r--r--extension/src/entries/popup/Components/IdentitySelection.vue16
-rw-r--r--extension/src/entries/popup/Components/Login.vue64
-rw-r--r--extension/src/entries/popup/Components/OtpLogin.vue51
-rw-r--r--extension/src/entries/popup/Components/PageContent.vue2
-rw-r--r--extension/src/entries/popup/Components/PassLogin.vue89
-rw-r--r--extension/src/entries/popup/main.js4
-rw-r--r--extension/src/entries/store/features.ts25
-rw-r--r--extension/src/entries/store/identity.ts18
-rw-r--r--extension/src/entries/store/index.ts9
-rw-r--r--extension/src/entries/store/mfaconfig.ts67
-rw-r--r--extension/src/features/auth-api.ts106
-rw-r--r--extension/src/features/framework/index.ts6
-rw-r--r--extension/src/features/identity-api.ts46
-rw-r--r--extension/src/features/index.ts7
-rw-r--r--extension/src/features/mfa-api.ts87
-rw-r--r--extension/src/features/pki-api.ts (renamed from extension/src/features/account-api.ts)54
-rw-r--r--extension/src/features/types.ts9
32 files changed, 1338 insertions, 135 deletions
diff --git a/extension/package-lock.json b/extension/package-lock.json
index dec73f0..bf558a0 100644
--- a/extension/package-lock.json
+++ b/extension/package-lock.json
@@ -26,12 +26,14 @@
"jose": "^5.0.x",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.4",
+ "otpauth": "^9.2.1",
"pinia": "^2.1.7",
"sass": "^1.56.1",
"serialize-error": "^11.0.0",
"universal-cookie": "^7.0.x",
"vite-plugin-web-extension": "^4.1.1",
"vue": "^3.2.47",
+ "vue3-otp-input": "^0.4.1",
"webextension-polyfill": "^0.10.0"
},
"devDependencies": {
@@ -5186,6 +5188,14 @@
"node": ">=0.6.0"
}
},
+ "node_modules/jssha": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.1.tgz",
+ "integrity": "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
@@ -5932,6 +5942,17 @@
"node": ">= 0.4.0"
}
},
+ "node_modules/otpauth": {
+ "version": "9.2.1",
+ "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.2.1.tgz",
+ "integrity": "sha512-/MRvcm63pzK20NCsIOe8Btun42/yWNylPbUo/h5dMpSRJpoAJstWodEUjm4zUDeT1+Vbqif2E8IcP4trl1U4gQ==",
+ "dependencies": {
+ "jssha": "~3.3.1"
+ },
+ "funding": {
+ "url": "https://github.com/hectorm/otpauth?sponsor=1"
+ }
+ },
"node_modules/p-cancelable": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz",
@@ -8382,6 +8403,18 @@
"typescript": "*"
}
},
+ "node_modules/vue3-otp-input": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/vue3-otp-input/-/vue3-otp-input-0.4.1.tgz",
+ "integrity": "sha512-wVl9i3DcWlO0C7fBI9V+RIP3crm/1tY72fuhvb3YM2JfbLoYofB96aPl5AgFhA0Cse5bQEMYtIvOeiqW3rfbAw==",
+ "engines": {
+ "node": ">=16.0.0",
+ "npm": ">=8.0.0"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.*"
+ }
+ },
"node_modules/watchpack": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
diff --git a/extension/package.json b/extension/package.json
index 2ba409a..806d54f 100644
--- a/extension/package.json
+++ b/extension/package.json
@@ -52,13 +52,15 @@
"base32-encode": "^2.0.0",
"jose": "^5.0.x",
"lodash-es": "^4.17.21",
+ "nanoid": "^5.0.4",
+ "otpauth": "^9.2.1",
"pinia": "^2.1.7",
"sass": "^1.56.1",
"serialize-error": "^11.0.0",
- "nanoid": "^5.0.4",
"universal-cookie": "^7.0.x",
"vite-plugin-web-extension": "^4.1.1",
"vue": "^3.2.47",
+ "vue3-otp-input": "^0.4.1",
"webextension-polyfill": "^0.10.0"
}
}
diff --git a/extension/src/assets/modals.scss b/extension/src/assets/modals.scss
index 254b8e1..b201073 100644
--- a/extension/src/assets/modals.scss
+++ b/extension/src/assets/modals.scss
@@ -3,8 +3,8 @@
@apply fixed z-50 flex w-full px-6;
.modal-content-container {
- @apply w-full max-w-md p-5 m-auto rounded-md shadow-2xl mt-44;
- @apply bg-white border border-transparent dark:bg-dark-600 dark:border-primary-500 dark:text-white;
+ @apply w-full max-w-md p-5 m-auto rounded shadow-2xl mt-44;
+ @apply bg-white border border-transparent dark:bg-dark-600 dark:border-dark-200 dark:text-white;
.modal-title {
@apply text-xl font-bold;
diff --git a/extension/src/components/ConfirmPrompt.vue b/extension/src/components/ConfirmPrompt.vue
new file mode 100644
index 0000000..fa8601b
--- /dev/null
+++ b/extension/src/components/ConfirmPrompt.vue
@@ -0,0 +1,67 @@
+
+<template>
+ <div id="confirm-prompt">
+ <Dialog class="modal-entry" :style="style" :open="isRevealed" @close="cancel" >
+ <div class="modal-content-container">
+ <DialogPanel>
+ <DialogTitle class="modal-title">
+ {{ message.title ?? 'Confirm' }}
+ </DialogTitle>
+
+ <DialogDescription class="modal-description">
+ {{ message.text }}
+ </DialogDescription>
+
+ <p class="modal-text-secondary">
+ {{ message.subtext }}
+ </p>
+
+ <div class="modal-button-container">
+ <button class="rounded btn sm primary" @click="confirm">
+ Confirm
+ </button>
+ <button class="rounded btn sm" @click="cancel">
+ Close
+ </button>
+ </div>
+ </DialogPanel>
+ </div>
+ </Dialog>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { defaultTo } from 'lodash'
+import { computed, ref } from 'vue'
+
+import {
+ Dialog,
+ DialogPanel,
+ DialogTitle,
+ DialogDescription,
+} from '@headlessui/vue'
+
+import { onClickOutside } from '@vueuse/core'
+import { useConfirm, useEnvSize } from '@vnuge/vnlib.browser'
+
+const { headerHeight } = useEnvSize()
+//Use component side of confirm
+const { isRevealed, confirm, cancel, onReveal } = useConfirm()
+
+const dialog = ref(null)
+const message = ref({})
+
+//Cancel prompt when user clicks outside of dialog, only when its open
+onClickOutside(dialog, () => isRevealed.value ? cancel() : null)
+
+//Set message on reveal
+onReveal(m => message.value = defaultTo(m, {}));
+
+const style = computed(() => {
+ return {
+ 'height': `calc(100vh - ${headerHeight.value}px)`,
+ 'top': `${headerHeight.value}px`
+ }
+})
+
+</script> \ No newline at end of file
diff --git a/extension/src/components/PasswordPrompt.vue b/extension/src/components/PasswordPrompt.vue
new file mode 100644
index 0000000..ae29358
--- /dev/null
+++ b/extension/src/components/PasswordPrompt.vue
@@ -0,0 +1,110 @@
+<template>
+ <div id="password-prompt">
+ <Dialog
+ class="modal-entry"
+ :style="style"
+ :open="isRevealed"
+ @close="close"
+ >
+ <div ref="dialog" class="modal-content-container" >
+ <DialogPanel>
+ <DialogTitle class="modal-title">
+ Enter your password
+ </DialogTitle>
+
+ <DialogDescription class="modal-description">
+ Please re-enter your password to continue.
+ </DialogDescription>
+
+ <form id="password-form" @submit.prevent="formSubmitted" :disabled="waiting">
+ <fieldset>
+ <div class="input-container">
+ <input v-model="v$.password.$model" type="password" class="rounded input primary" placeholder="Password" @input="onInput">
+ </div>
+ </fieldset>
+ </form>
+
+ <div class="modal-button-container">
+ <button class="rounded btn sm primary" form="password-form">
+ Submit
+ </button>
+ <button class="rounded btn sm" @click="close" >
+ Close
+ </button>
+ </div>
+ </DialogPanel>
+ </div>
+ </Dialog>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { onClickOutside } from '@vueuse/core'
+import useVuelidate from '@vuelidate/core'
+import { reactive, ref, computed } from 'vue'
+import { helpers, required, maxLength } from '@vuelidate/validators'
+import { useWait, useMessage, usePassConfirm, useEnvSize, useVuelidateWrapper } from '@vnuge/vnlib.browser'
+import { Dialog, DialogPanel, DialogTitle, DialogDescription } from '@headlessui/vue'
+
+const { headerHeight } = useEnvSize()
+
+//Use component side of pw prompt
+const { isRevealed, confirm, cancel } = usePassConfirm()
+
+const { waiting } = useWait()
+const { onInput } = useMessage()
+
+//Dialog html ref
+const dialog = ref(null)
+
+const pwState = reactive({ password: '' })
+
+const rules = {
+ password: {
+ required: helpers.withMessage('Please enter your password', required),
+ maxLength: helpers.withMessage('Password must be less than 100 characters', maxLength(100))
+ }
+}
+
+const v$ = useVuelidate(rules, pwState, { $lazy: true })
+
+//Wrap validator so we an display error message on validation, defaults to the form toaster
+const { validate } = useVuelidateWrapper(v$);
+
+const style = computed(() => {
+ return {
+ 'height': `calc(100vh - ${headerHeight.value}px)`,
+ 'top': `${headerHeight.value}px`
+ }
+})
+
+const formSubmitted = async function () {
+ //Calls validate on the vuelidate instance
+ if (!await validate()) {
+ return
+ }
+
+ //Store pw copy
+ const password = v$.value.password.$model;
+
+ //Clear the password form
+ v$.value.password.$model = '';
+ v$.value.$reset();
+
+ //Pass the password to the confirm function
+ confirm({ password });
+}
+
+const close = function () {
+ // Clear the password form
+ v$.value.password.$model = '';
+ v$.value.$reset();
+
+ //Close prompt
+ cancel(null);
+}
+
+//Cancel prompt when user clicks outside of dialog, only when its open
+onClickOutside(dialog, () => isRevealed.value ? cancel() : null)
+
+</script> \ No newline at end of file
diff --git a/extension/src/entries/background/main.ts b/extension/src/entries/background/main.ts
index 1d74f26..85e358a 100644
--- a/extension/src/entries/background/main.ts
+++ b/extension/src/entries/background/main.ts
@@ -23,7 +23,8 @@ import {
useAppSettings,
usePkiApi,
useEventTagFilterApi,
- useInjectAllowList
+ useInjectAllowList,
+ useMfaConfigApi,
} from "../../features";
import { useBackgroundFeatures } from "../../features/framework";
@@ -40,5 +41,6 @@ register([
useLocalPki,
usePkiApi,
useEventTagFilterApi,
- useInjectAllowList
+ useInjectAllowList,
+ useMfaConfigApi
]) \ No newline at end of file
diff --git a/extension/src/entries/contentScript/renderContent.js b/extension/src/entries/contentScript/renderContent.js
index 293bdd5..ca45c4f 100644
--- a/extension/src/entries/contentScript/renderContent.js
+++ b/extension/src/entries/contentScript/renderContent.js
@@ -20,7 +20,11 @@ export default async function renderContent(
cssPaths,
render = (_appRoot) => {}
) {
+
+ //insert a div into the top of the body
const appContainer = document.createElement("div");
+ document.body.insertBefore(appContainer, document.body.firstChild);
+
const shadowRoot = appContainer.attachShadow({ mode: 'closed' });
const appRoot = document.createElement("div");
@@ -40,7 +44,5 @@ export default async function renderContent(
}
shadowRoot.appendChild(appRoot);
- document.body.appendChild(appContainer);
-
render(appRoot, shadowRoot);
} \ No newline at end of file
diff --git a/extension/src/entries/options/App.vue b/extension/src/entries/options/App.vue
index dd71209..d1da48e 100644
--- a/extension/src/entries/options/App.vue
+++ b/extension/src/entries/options/App.vue
@@ -1,5 +1,9 @@
<template>
<main id="injected-root">
+
+ <!-- Global password/confirm promps -->
+ <ConfirmPrompt />
+ <PasswordPrompt />
<notifications class="toaster" group="form" position="top-right" />
@@ -17,6 +21,11 @@
</Tab>
<Tab v-slot="{ selected }">
<button class="tab-title" :class="{ selected }">
+ Account
+ </button>
+ </Tab>
+ <Tab v-slot="{ selected }">
+ <button class="tab-title" :class="{ selected }">
Privacy
</button>
</Tab>
@@ -56,6 +65,9 @@
<Identities :all-keys="allKeys" @edit-key="editKey"/>
</TabPanel>
<TabPanel>
+ <Account/>
+ </TabPanel>
+ <TabPanel>
<Privacy/>
</TabPanel>
<TabPanel>
@@ -115,6 +127,9 @@ import SiteSettings from './components/SiteSettings.vue';
import Identities from './components/Identities.vue';
import Privacy from "./components/Privacy.vue";
import { useStore } from "../store";
+import Account from "./components/Account.vue";
+import ConfirmPrompt from "../../components/ConfirmPrompt.vue";
+import PasswordPrompt from "../../components/PasswordPrompt.vue";
//Configure the notifier to use the notification library
@@ -128,7 +143,7 @@ const keyBuffer = ref<NostrPubKey>({} as NostrPubKey)
const editKey = (key: NostrPubKey) =>{
//Goto hidden tab
- selectedTab.value = 3
+ selectedTab.value = 4
//Set selected key
keyBuffer.value = { ...key }
}
@@ -165,7 +180,7 @@ watchEffect(() => darkMode.value ? document.body.classList.add('dark') : documen
</script>
-<style lang="scss" scoped>
+<style lang="scss">
main {
font-family: Avenir, Helvetica, Arial, sans-serif;
@@ -198,4 +213,8 @@ main {
}
}
+.text-color-background{
+ @apply text-gray-400 dark:text-gray-500;
+}
+
</style> \ No newline at end of file
diff --git a/extension/src/entries/options/components/Account.vue b/extension/src/entries/options/components/Account.vue
new file mode 100644
index 0000000..574dc51
--- /dev/null
+++ b/extension/src/entries/options/components/Account.vue
@@ -0,0 +1,34 @@
+<template>
+ <div id="account-settings-template" class="mt-4">
+ <div class="flex flex-col w-full max-w-lg gap-4 mx-auto">
+ <div class="">
+ <h3 class="text-center">Account Settings</h3>
+ <div class="">
+
+ </div>
+ </div>
+ <div class="">
+ <div class="w-full font-bold border-b border-gray-200 dark:border-dark-500">
+ Multi Factor
+ </div>
+ <div class="mt-4">
+ <Totp />
+ </div>
+ <div class="mt-8 ">
+ <Pki />
+ </div>
+ </div>
+ <div class="mt-6">
+ <h4>Password</h4>
+ <div class="">
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import Pki from './Pki.vue';
+import Totp from './Totp.vue';
+
+</script> \ No newline at end of file
diff --git a/extension/src/entries/options/components/Identities.vue b/extension/src/entries/options/components/Identities.vue
index 0e3e79d..b7765be 100644
--- a/extension/src/entries/options/components/Identities.vue
+++ b/extension/src/entries/options/components/Identities.vue
@@ -38,6 +38,13 @@
</PopoverPanel>
</Popover>
</div>
+ <div class="">
+ <div class="">
+ <button class="rounded btn sm" @click="store.refreshIdentities()">
+ <fa-icon icon="refresh" class="" />
+ </button>
+ </div>
+ </div>
</div>
<div v-for="key in allKeys" :key="key.Id" class="mt-2 mb-3">
<div class="" :class="{'selected': isSelected(key)}" @click.self="selectKey(key)">
@@ -90,7 +97,7 @@ import {
PopoverPanel,
PopoverOverlay
} from '@headlessui/vue'
-import { apiCall, configureNotifier } from '@vnuge/vnlib.browser';
+import { apiCall, configureNotifier, useConfirm } from '@vnuge/vnlib.browser';
import { NostrPubKey } from '../../../features';
import { notify } from "@kyvg/vue3-notification";
import { get, useClipboard } from '@vueuse/core';
@@ -106,7 +113,7 @@ const downloadAnchor = ref<HTMLAnchorElement>()
const store = useStore()
const { selectedKey, allKeys } = storeToRefs(store)
const { copy } = useClipboard()
-
+const { reveal } = useConfirm()
const isSelected = (me : NostrPubKey) => isEqual(me, selectedKey.value)
const editKey = (key : NostrPubKey) => emit('edit-key', key);
@@ -134,7 +141,17 @@ const prettyPrintDate = (key : NostrPubKey) => {
const onDeleteKey = async (key : NostrPubKey) => {
- if(!confirm(`Are you sure you want to delete ${key.UserName}?`)){
+ const { isCanceled } = await reveal({
+ title: 'Are you sure?',
+ text: `You are about to perminantly delete your identity ${key.UserName}.`,
+ subtext: 'This is a perminant action and cannot be undone.',
+ })
+
+ if(isCanceled){
+ return;
+ }
+
+ if(!confirm(`Are you REALLY sure you want to delete ${key.UserName}?`)){
return;
}
diff --git a/extension/src/entries/options/components/Pki.vue b/extension/src/entries/options/components/Pki.vue
new file mode 100644
index 0000000..ff64840
--- /dev/null
+++ b/extension/src/entries/options/components/Pki.vue
@@ -0,0 +1,237 @@
+<template>
+ <div id="pki-settings" class="container">
+ <div class="panel-content">
+
+ <div class="flex flex-row flex-wrap justify-between">
+ <div class="text-sm font-bold">OTP Auth Keys</div>
+ <div class="">
+ <div v-if="pkiEnabled" class="button-group">
+ <button class="btn xs" @click.prevent="setIsOpen(true)">
+ <fa-icon icon="plus" />
+ <span class="pl-2">Add Key</span>
+ </button>
+ <button class="btn red xs" @click.prevent="onDisable">
+ <fa-icon icon="minus-circle" />
+ <span class="pl-2">Disable</span>
+ </button>
+ </div>
+ <div v-else class="">
+ <button class="btn xs" @click.prevent="setIsOpen(true)">
+ <fa-icon icon="plus" />
+ <span class="pl-2">Add Key</span>
+ </button>
+ </div>
+ </div>
+
+ <div v-if="store.pkiServerKeys.length > 0" class="w-full mt-2">
+ <table class="min-w-full text-sm divide-y-2 divide-gray-200 dark:divide-dark-500">
+ <thead class="text-left">
+ <tr>
+ <th class="p-2 font-medium whitespace-nowrap dark:text-white">
+ KeyID
+ </th>
+ <th class="p-2 font-medium whitespace-nowrap dark:text-white">
+ Algorithm
+ </th>
+ <th class="p-2 font-medium whitespace-nowrap dark:text-white">
+ Curve
+ </th>
+ <th class="p-2"></th>
+ </tr>
+ </thead>
+
+ <tbody class="divide-y divide-gray-200 dark:divide-dark-500">
+ <tr v-for="key in store.pkiServerKeys">
+ <td class="p-2 t font-medium truncate max-w-[8rem] whitespace-nowrap dark:text-white">
+ {{ key.kid }}
+ </td>
+ <td class="p-2 text-gray-700 whitespace-nowrap dark:text-gray-200">
+ {{ key.alg }}
+ </td>
+ <td class="p-2 text-gray-700 whitespace-nowrap dark:text-gray-200">
+ {{ key.crv }}
+ </td>
+ <td class="p-2 text-right whitespace-nowrap">
+ <button class="rounded btn red xs borderless" @click="onRemoveKey(key)">
+ <span class="hidden sm:inline">Remove</span>
+ <fa-icon icon="trash-can" class="inline sm:hidden" />
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+
+ <p v-else class="p-1 pt-3 text-sm text-color-background">
+ PKI authentication is a method of authenticating your user account with signed messages and a shared
+ public key. This method implementation
+ uses client signed Json Web Tokens to authenticate user generated outside this website as a One Time
+ Password (OTP). This allows for you to
+ use your favorite hardware or software tools, to generate said OTPs to authenticate your user.
+ </p>
+ </div>
+ </div>
+ </div>
+ <Dialog :open="isOpen" @close="setIsOpen" class="relative z-30">
+ <div class="fixed inset-0 bg-black/30" aria-hidden="true" />
+
+ <div class="fixed inset-0 flex justify-center">
+ <DialogPanel class="w-full max-w-lg p-4 m-auto mt-24 bg-white rounded dark:bg-dark-700 dark:text-gray-300">
+ <h4>Configure your authentication key</h4>
+ <p class="mt-2 text-sm">
+ Please paste your authenticator's public key as a Json Web Key (JWK) object. Your JWK must include a kid
+ (key id) and a kty (key type) field.
+ </p>
+ <div class="p-2 mt-3">
+ <textarea class="w-full p-1 text-sm border dark:bg-dark-700 ring-0 dark:border-dark-400" rows="10"
+ v-model="keyData" />
+ </div>
+ <div class="flex justify-end gap-2 mt-4">
+ <button class="rounded btn sm primary" @click.prevent="onSubmitKeys">Submit</button>
+ <button class="rounded btn sm" @click.prevent="setIsOpen(false)">Cancel</button>
+ </div>
+ </DialogPanel>
+ </div>
+ </Dialog>
+</template>
+
+<script setup lang="ts">
+import { includes, isEmpty } from 'lodash'
+import { apiCall, useConfirm, useSession, debugLog, useFormToaster, MfaMethod } from '@vnuge/vnlib.browser'
+import { computed, ref, watch } from 'vue'
+import { Dialog, DialogPanel } from '@headlessui/vue'
+import { } from 'pinia'
+import { useStore } from '../../store'
+import { PkiPubKey } from '../../../features'
+
+const store = useStore()
+const { reveal } = useConfirm()
+const { isLocalAccount } = useSession()
+const { error, success } = useFormToaster()
+
+const pkiEnabled = computed(() => isLocalAccount.value && includes(store.mfaEnabledMethods, "pki" as MfaMethod))
+
+const isOpen = ref(false)
+const keyData = ref('')
+const pemFormat = ref(false)
+const explicitCurve = ref("")
+
+watch(isOpen, () => {
+ keyData.value = ''
+ pemFormat.value = false
+ explicitCurve.value = ""
+ //Reload status
+ store.mfaRefresh()
+})
+
+const setIsOpen = (value: boolean) => isOpen.value = value
+
+const onRemoveKey = async (single: PkiPubKey) => {
+ const { isCanceled } = await reveal({
+ title: 'Are you sure?',
+ text: `This will remove key ${single.kid} from your account.`
+ })
+ if (isCanceled) {
+ return;
+ }
+
+ //Delete pki
+ await apiCall(async () => {
+
+ //TODO: require password or some upgrade to disable
+ await store.pkiRemoveKey(single);
+
+ success({
+ title: 'Key was removed successfully.',
+ })
+
+ //Refresh the status
+ store.mfaRefresh()
+ });
+}
+
+const onDisable = async () => {
+ const { isCanceled } = await reveal({
+ title: 'Are you sure?',
+ text: 'This will disable PKI authentication for your account.'
+ })
+ if (isCanceled) {
+ return;
+ }
+
+ //Delete pki
+ await apiCall(async ({ toaster }) => {
+
+ //Disable pki
+ //TODO: require password or some upgrade to disable
+ const { success } = await store.disable();
+
+ if (success) {
+ toaster.general.success({
+ title: 'Success',
+ text: 'PKI authentication has been disabled.'
+ })
+ }
+ else {
+ toaster.general.error({
+ title: 'Error',
+ text: 'PKI authentication could not be disabled.'
+ })
+ }
+
+ //Refresh the status
+ store.mfaRefresh()
+ });
+}
+
+const onSubmitKeys = async () => {
+
+ if (window.crypto.subtle == null) {
+ error({ title: "Your browser does not support PKI authentication." })
+ return;
+ }
+
+ //Validate key data
+ if (isEmpty(keyData.value)) {
+ error({ title: "Please enter key data" })
+ return;
+ }
+
+ let jwk: PkiPubKey;
+ try {
+ //Try to parse as jwk
+ jwk = JSON.parse(keyData.value)
+ if (isEmpty(jwk.use)
+ || isEmpty(jwk.kty)
+ || isEmpty(jwk.alg)
+ || isEmpty(jwk.kid)
+ || isEmpty(jwk.x)
+ || isEmpty(jwk.y)) {
+ throw new Error("Invalid JWK");
+ }
+ }
+ catch (e) {
+ //Write error to debug log
+ debugLog(e)
+ error({ title: "The key is not a valid Json Web Key (JWK)" })
+ return;
+ }
+
+ //Send to server
+ await apiCall(async () => {
+
+ //init/update the key
+ //TODO: require password or some upgrade to disable
+ await store.pkiAddKey(jwk);
+
+ success({
+ title: 'Successfully update your PKI keys.',
+ })
+
+ setIsOpen(false)
+ })
+}
+
+</script>
+
+<style></style>
diff --git a/extension/src/entries/options/components/Privacy.vue b/extension/src/entries/options/components/Privacy.vue
index d54f679..46261b3 100644
--- a/extension/src/entries/options/components/Privacy.vue
+++ b/extension/src/entries/options/components/Privacy.vue
@@ -1,18 +1,19 @@
<template>
- <div class="flex flex-col w-full mt-4 sm:px-2">
- <div class="flex flex-row gap-1">
- <div class="text-2xl">
- Tracking protection
- </div>
- <div class="mt-auto" :class="[isOriginProtectionOn ? 'text-primary-600' : 'text-red-500']">
- {{ isOriginProtectionOn ? 'active' : 'inactive' }}
+ <div class="flex flex-col w-full max-w-md mx-auto mt-4 sm:px-2">
+ <div class="flex flex-row gap-1 mx-auto">
+ <div class="mb-auto mr-1" >
+ <div class="w-2 h-2 rounded-full" :class="[isOriginProtectionOn ? 'bg-primary-600' : 'bg-red-500']">
+ </div>
</div>
+ <h3 class="text-2xl">
+ Tracking protection
+ </h3>
</div>
<div class="">
<div class="p-2">
<div class="my-1">
- <form @submit.prevent="allowOrigin()">
- <input class="w-full max-w-xs input primary" type="text" v-model="newOrigin" placeholder="Add new origin"/>
+ <form class="flex flex-row w-full" @submit.prevent="allowOrigin()">
+ <input class="flex-1 input primary" type="text" v-model="newOrigin" placeholder="Add new origin"/>
<button type="submit" class="ml-1 btn xs" >
<fa-icon icon="plus" />
</button>
diff --git a/extension/src/entries/options/components/SiteSettings.vue b/extension/src/entries/options/components/SiteSettings.vue
index b31bb9c..17f41c4 100644
--- a/extension/src/entries/options/components/SiteSettings.vue
+++ b/extension/src/entries/options/components/SiteSettings.vue
@@ -128,9 +128,9 @@ import { useStore } from '../../store';
import { storeToRefs } from 'pinia';
import useVuelidate from '@vuelidate/core'
-const { waiting } = useWait();
const store = useStore()
-const { settings, isOriginProtectionOn } = storeToRefs(store)
+const { settings } = storeToRefs(store)
+const { waiting } = useWait();
const { apply, data, buffer, modified, update } = useDataBuffer(settings.value, async sb =>{
const newConfig = await store.saveSiteConfig(sb.buffer)
@@ -142,7 +142,7 @@ const { apply, data, buffer, modified, update } = useDataBuffer(settings.value,
watch(settings, v => apply(v))
const originProtection = computed({
- get: () => isOriginProtectionOn.value,
+ get: () => store.isOriginProtectionOn,
set: v => store.setOriginProtection(v)
})
diff --git a/extension/src/entries/options/components/Totp.vue b/extension/src/entries/options/components/Totp.vue
new file mode 100644
index 0000000..ed836b7
--- /dev/null
+++ b/extension/src/entries/options/components/Totp.vue
@@ -0,0 +1,234 @@
+<template>
+ <div id="totp-settings">
+
+ <div v-if="showTotpCode" class="w-full py-2 text-center">
+ <h5 class="text-center" />
+ <p class="py-2">
+ Scan the QR code with your TOTP authenticator app.
+ </p>
+
+ <div class="flex">
+ <VueQrcode class="m-auto" :value="qrCode" />
+ </div>
+
+ <p class="py-2">
+ Your secret, if your application requires it.
+ </p>
+
+ <p
+ class="flex flex-row flex-wrap justify-center p-2 bg-gray-200 border border-gray-300 dark:bg-dark-800 dark:border-dark-500">
+ <span v-for="code in secretSegments" :key="code" class="px-2 font-mono tracking-wider">
+ {{ code }}
+ </span>
+ </p>
+
+ <p class="py-2 text-color-background">
+ Please enter your code from your authenticator app to continue.
+ </p>
+
+ <div class="m-auto w-min">
+ <VOtpInput class="otp-input" input-type="letter-numeric" separator="" value="" :is-disabled="showSubmitButton"
+ input-classes="primary input rounded" :num-inputs="6" @on-change="onInput" @on-complete="VerifyTotp" />
+ </div>
+
+ <div v-if="showSubmitButton" class="flex flex-row justify-end my-2">
+ <button class="btn primary" @click.prevent="CloseQrWindow">
+ Complete
+ </button>
+ </div>
+ </div>
+
+ <div v-else class="flex flex-row flex-wrap justify-between">
+ <div class="text-sm font-bold">TOTP Authenticator App</div>
+
+ <div v-if="totpEnabled" class="button-group">
+ <button class="btn xs" @click.prevent="regenTotp">
+ <fa-icon icon="sync" />
+ <span class="pl-2">Regenerate</span>
+ </button>
+ <button class="btn red xs" @click.prevent="disable">
+ <fa-icon icon="minus-circle" />
+ <span class="pl-2">Disable</span>
+ </button>
+ </div>
+
+ <div v-else>
+ <button class="btn xs" @click.prevent="configTotp">
+ <fa-icon icon="plus" />
+ <span class="pl-2">Setup</span>
+ </button>
+ </div>
+ <p v-if="!totpEnabled" class="p-1 pt-3 text-sm text-color-background">
+ TOTP is a time based one time password. You can use it as a form of Multi Factor Authentication when
+ using another device such as a smart phone or TOTP hardware device. You can use TOTP with your smart
+ phone
+ using apps like Google Authenticator, Authy, or Duo. Read more on
+ <a class="link" href="https://en.wikipedia.org/wiki/Time-based_one-time_password" target="_blank">
+ Wikipedia.
+ </a>
+ </p>
+
+ <p v-else class="w-full p-1 pt-1 text-sm text-color-background">
+ TOTP is enabled for your account.
+ </p>
+ </div>
+
+ </div>
+</template>
+
+<script setup lang="ts">
+import { isNil, chunk, defaultTo, includes, map, join } from 'lodash'
+import { TOTP } from 'otpauth'
+import base32Encode from 'base32-encode'
+import VueQrcode from '@chenfengyuan/vue-qrcode'
+import VOtpInput from "vue3-otp-input";
+import { computed, ref } from 'vue'
+import {
+ useSession,
+ useMessage,
+ useConfirm,
+ usePassConfirm,
+ useFormToaster,
+ MfaMethod
+} from '@vnuge/vnlib.browser'
+import { storeToRefs } from 'pinia';
+import { useStore } from '../../store';
+import { Mutable } from '@vueuse/core';
+import { TotpUpdateMessage } from '../../../features/types';
+
+type TotpConfig = Mutable<TotpUpdateMessage>
+
+const store = useStore()
+const { userName } = storeToRefs(store);
+
+const { KeyStore } = useSession()
+const { reveal } = useConfirm()
+const { elevatedApiCall } = usePassConfirm()
+const { onInput, setMessage } = useMessage()
+
+const totpEnabled = computed(() => includes(store.mfaEnabledMethods, MfaMethod.TOTP))
+
+const totpMessage = ref<TotpConfig>()
+const showSubmitButton = ref(false)
+const toaster = useFormToaster()
+
+const showTotpCode = computed(() => !isNil(totpMessage.value?.secret))
+
+const secretSegments = computed<string[]>(() => {
+ //Chunk the secret into 6 character segments
+ const chunks = chunk(totpMessage.value?.secret, 6)
+ //Join the chunks into their chunk arrays
+ return map(chunks, chunk => join(chunk, ''))
+})
+
+const qrCode = computed(() => {
+ if (isNil(totpMessage.value?.secret)) {
+ return ''
+ }
+
+ const m = totpMessage.value!;
+
+ // Build the totp qr codeurl
+ const params = new URLSearchParams()
+ params.append('secret', m.secret)
+ params.append('issuer', m.issuer)
+ params.append('algorithm', m.algorithm)
+ params.append('digits', defaultTo(m.digits, 6).toString())
+ params.append('period', defaultTo(m.period, 30).toString())
+ const url = `otpauth://totp/${m.issuer}:${userName.value}?${params.toString()}`
+ return url
+})
+
+const ProcessAddOrUpdate = async () => {
+ await elevatedApiCall(async ({ password }) => {
+
+ // Init or update the totp method and get the encrypted totp message
+ const totp = await store.mfaUpsertMethod(MfaMethod.TOTP, password) as TotpConfig
+
+ // Decrypt the totp secret
+ const secretBuf = await KeyStore.decryptDataAsync(totp.secret)
+
+ // Encode the secret to base32
+ totp.secret = base32Encode(secretBuf, 'RFC3548', { padding: false })
+
+ totpMessage.value = totp
+ })
+}
+
+const configTotp = async () => {
+ const { isCanceled } = await reveal({
+ title: 'Enable TOTP multi factor?',
+ text: 'Are you sure you understand TOTP multi factor and wish to enable it?',
+ })
+
+ if (!isCanceled) {
+ ProcessAddOrUpdate()
+ }
+}
+
+const regenTotp = async () => {
+ // If totp is enabled, show a prompt to regenerate totp
+ if (!totpEnabled.value) {
+ return
+ }
+
+ const { isCanceled } = await reveal({
+ title: 'Are you sure?',
+ text: 'If you continue your previous TOTP authenticator and recovery codes will no longer be valid.'
+ })
+
+ if (!isCanceled) {
+ ProcessAddOrUpdate()
+ }
+}
+
+const disable = async () => {
+ // Show a confrimation prompt
+ const { isCanceled } = await reveal({
+ title: 'Disable TOTP',
+ text: 'Are you sure you want to disable TOTP? You may re-enable TOTP later.'
+ })
+
+ if (isCanceled) {
+ return
+ }
+
+ await elevatedApiCall(async ({ password }) => {
+ // Disable the totp method
+ await store.mfaDisableMethod(MfaMethod.TOTP, password)
+ })
+}
+
+const VerifyTotp = async (code: string) => {
+ // Create a new TOTP instance from the current message
+ const totp = new TOTP(totpMessage.value)
+
+ // validate the code
+ const valid = totp.validate({ token: code, window: 4 })
+
+ if (valid) {
+ showSubmitButton.value = true
+ toaster.success({
+ title: 'Success',
+ text: 'Your TOTP code is valid and your account is now verified.'
+ })
+ } else {
+ setMessage('Your TOTP code is not valid.')
+ }
+}
+
+const CloseQrWindow = () => {
+ showSubmitButton.value = false
+ totpMessage.value = undefined
+
+ //Fresh methods
+ store.mfaRefresh()
+}
+
+</script>
+
+<style>
+#totp-settings .otp-input input {
+ @apply w-12 text-center text-lg mx-1 focus:border-primary-500;
+}
+</style>
diff --git a/extension/src/entries/options/main.js b/extension/src/entries/options/main.js
index 827c426..7747735 100644
--- a/extension/src/entries/options/main.js
+++ b/extension/src/entries/options/main.js
@@ -22,12 +22,12 @@ import Notifications from "@kyvg/vue3-notification";
/* FONT AWESOME CONFIG */
import { library } from '@fortawesome/fontawesome-svg-core'
-import { faChevronLeft, faChevronRight, faCopy, faDownload, faEdit, faExternalLinkAlt, faLock, faLockOpen, faMoon, faPlus, faSun, faTrash } from '@fortawesome/free-solid-svg-icons'
+import { faChevronLeft, faChevronRight, faCopy, faDownload, faEdit, faExternalLinkAlt, faLock, faLockOpen, faMinusCircle, faMoon, faPlus, faRefresh, faSun, faTrash, faTrashCan } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { createPinia } from "pinia";
-import { identityPlugin, originPlugin, useBackgroundPiniaPlugin } from "../store";
+import { identityPlugin, mfaConfigPlugin, originPlugin, useBackgroundPiniaPlugin } from "../store";
-library.add(faCopy, faEdit, faChevronLeft, faMoon, faSun, faLock, faLockOpen, faExternalLinkAlt, faTrash, faDownload, faChevronRight, faPlus)
+library.add(faCopy, faEdit, faChevronLeft, faMoon, faSun, faLock, faLockOpen, faExternalLinkAlt, faTrash, faDownload, faChevronRight, faPlus, faRefresh, faTrashCan, faMinusCircle)
//Create the background feature wiring
const bgPlugins = useBackgroundPiniaPlugin('options')
@@ -36,6 +36,7 @@ const pinia = createPinia()
.use(bgPlugins) //Add the background pinia plugin
.use(identityPlugin) //Add the identity plugin
.use(originPlugin) //Add the origin plugin
+ .use(mfaConfigPlugin) //Add the mfa config plugin
createApp(App)
.use(Notifications)
diff --git a/extension/src/entries/popup/Components/IdentitySelection.vue b/extension/src/entries/popup/Components/IdentitySelection.vue
index 99d8e34..eb08fb1 100644
--- a/extension/src/entries/popup/Components/IdentitySelection.vue
+++ b/extension/src/entries/popup/Components/IdentitySelection.vue
@@ -1,18 +1,22 @@
<template>
<div class="text-left">
- <div class="w-full">
- <div class="">
+ <div class="flex flex-row w-full gap-1">
+ <div class="flex-1">
<select class="w-full input"
- :disabled="waiting"
- :value="selected?.Id"
- @change.prevent="onSelected"
+ :disabled="waiting"
+ :value="selected?.Id"
+ @change.prevent="onSelected"
>
<option disabled value="">Select an identity</option>
<option v-for="key in allKeys" :value="key.Id">{{ key.UserName }}</option>
</select>
</div>
+ <div class="my-auto">
+ <button class="btn sm borderless" @click="store.refreshIdentities()">
+ <fa-icon icon="refresh" class="" />
+ </button>
+ </div>
</div>
-
</div>
</template>
diff --git a/extension/src/entries/popup/Components/Login.vue b/extension/src/entries/popup/Components/Login.vue
index 93c0178..8b7b807 100644
--- a/extension/src/entries/popup/Components/Login.vue
+++ b/extension/src/entries/popup/Components/Login.vue
@@ -1,44 +1,46 @@
<template>
<div id="login-template" class="py-4">
- <form class="" @submit.prevent="onSubmit">
- <fieldset class="px-4 input-container">
- <label class="">Please enter your authentication token</label>
- <textarea class="w-full input" v-model="token" rows="5">
- </textarea>
- </fieldset>
- <div class="flex justify-end mt-2">
- <div class="px-3">
- <button class="w-24 rounded btn sm primary">
- <fa-icon v-if="waiting" icon="spinner" class="animate-spin" />
- <span v-else>Submit</span>
- </button>
- </div>
- </div>
- </form>
+ <TabGroup @change="onChange">
+ <TabList as="div" class="flex flex-row mx-auto mb-3 font-bold w-fit">
+ <Tab
+ class="p-0.5 mx-1 border-b"
+ :class="[ isActive(0) ? 'border-gray-400' : 'border-transparent']"
+ >
+ OTP
+ </Tab>
+ <Tab
+ class="p-0.5 mx-1 border-b"
+ :class="[ isActive(1) ? 'border-gray-400' : 'border-transparent']"
+ >
+ User/Pass
+ </Tab>
+ </TabList>
+ <TabPanels>
+ <TabPanel>
+ <OtpLogin />
+ </TabPanel>
+ <TabPanel>
+ <PassLogin />
+ </TabPanel>
+ </TabPanels>
+ </TabGroup>
</div>
</template>
<script setup lang="ts">
-import { apiCall, useWait } from "@vnuge/vnlib.browser";
-import { ref } from "vue";
-import { useStore } from "../../store";
+import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue'
+import PassLogin from "./PassLogin.vue";
+import OtpLogin from "./OtpLogin.vue";
+import { shallowRef } from 'vue';
-const { login } = useStore()
-const { waiting } = useWait()
+const activeTab = shallowRef(0)
-const token = ref('')
-
-const onSubmit = async () => {
- await apiCall(async ({ toaster }) => {
- await login(token.value)
- toaster.form.success({
- 'title': 'Login successful',
- 'text': 'Successfully logged into your profile'
- })
- })
-
+const onChange = (index: any) => {
+ activeTab.value = index
}
+const isActive = (index: any) => activeTab.value === index
+
</script>
<style lang="scss">
diff --git a/extension/src/entries/popup/Components/OtpLogin.vue b/extension/src/entries/popup/Components/OtpLogin.vue
new file mode 100644
index 0000000..a2b8ac7
--- /dev/null
+++ b/extension/src/entries/popup/Components/OtpLogin.vue
@@ -0,0 +1,51 @@
+<template>
+ <form class="" @submit.prevent="onSubmit">
+ <fieldset class="px-4 input-container">
+ <label class="">Please enter your authentication token</label>
+ <textarea class="w-full input" v-model="token" rows="5" />
+ </fieldset>
+ <div class="flex justify-end mt-2">
+ <div class="px-3">
+ <button class="w-24 rounded btn sm primary">
+ <fa-icon v-if="waiting" icon="spinner" class="animate-spin" />
+ <span v-else>Submit</span>
+ </button>
+ </div>
+ </div>
+ </form>
+</template>
+
+<script setup lang="ts">
+import { apiCall, useWait } from "@vnuge/vnlib.browser";
+import { ref } from "vue";
+import { useStore } from "../../store";
+
+const { login } = useStore()
+const { waiting } = useWait()
+
+const token = ref('')
+
+const onSubmit = async () => {
+ await apiCall(async ({ toaster }) => {
+ try{
+ await login(token.value)
+
+ toaster.form.success({
+ 'title': 'Login successful',
+ 'text': 'Successfully logged into your profile'
+ })
+ }
+ catch(e:any){
+ if('response' in e){
+ throw e;
+ }
+
+ toaster.form.error({
+ title: 'Failed to login',
+ text: e.message
+ })
+ }
+ })
+}
+
+</script> \ No newline at end of file
diff --git a/extension/src/entries/popup/Components/PageContent.vue b/extension/src/entries/popup/Components/PageContent.vue
index e4fcb49..8a48840 100644
--- a/extension/src/entries/popup/Components/PageContent.vue
+++ b/extension/src/entries/popup/Components/PageContent.vue
@@ -57,7 +57,7 @@
{{ pubKey ?? 'No key selected' }}
</div>
<div class="my-auto ml-auto cursor-pointer" :class="{'text-primary-500': copied }">
- <fa-icon class="mr-1" icon="copy" @click="copy(pubKey)"/>
+ <fa-icon class="mr-1" icon="copy" @click="copy(pubKey!)"/>
</div>
</div>
</div>
diff --git a/extension/src/entries/popup/Components/PassLogin.vue b/extension/src/entries/popup/Components/PassLogin.vue
new file mode 100644
index 0000000..29aeeb6
--- /dev/null
+++ b/extension/src/entries/popup/Components/PassLogin.vue
@@ -0,0 +1,89 @@
+<template>
+ <div v-if="showTotp" class="">
+ <form id="totp-login" class="" @submit.prevent="">
+ <fieldset class="px-4 input-container">
+ <div class="text-center">
+ <label class="text-sm text-center">Enter your totp code</label>
+ <div class="m-auto mt-3 w-min">
+ <VOtpInput
+ class="otp-input"
+ input-type="letter-numeric"
+ separator=""
+ value=""
+ input-classes="primary input rounded"
+ :num-inputs="6"
+ @on-complete="onSubmitTotp"
+ />
+ </div>
+ </div>
+ </fieldset>
+ </form>
+ </div>
+ <div v-else>
+ <form class="" @submit.prevent="onSubmit()">
+ <fieldset class="px-4 input-container">
+ <div class="">
+ <label class="">Username</label>
+ <input type="text" name="username" class="w-full input" v-model="username" />
+ </div>
+ <div class="mt-1">
+ <label class="">Password</label>
+ <input type="password" name="password" class="w-full input" v-model="password" />
+ </div>
+ </fieldset>
+ <div class="flex justify-end mt-2">
+ <div class="px-3">
+ <button class="w-24 rounded btn sm primary">
+ <fa-icon v-if="waiting" icon="spinner" class="animate-spin" />
+ <span v-else>Submit</span>
+ </button>
+ </div>
+ </div>
+ </form>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { useStore } from '../../store';
+import { computed, shallowRef } from 'vue';
+import { apiCall, useWait } from "@vnuge/vnlib.browser";
+import { isEmpty, toNumber } from 'lodash';
+import VOtpInput from "vue3-otp-input";
+
+const { waiting } = useWait()
+const store = useStore();
+
+const showTotp = computed(() => store.mfaStatus?.type === 'totp')
+
+const username = shallowRef('');
+const password = shallowRef('');
+
+const onSubmit = () => {
+
+ //Invoke user-pass login
+ apiCall(async ({ toaster }) => {
+
+ //Validate
+ if(isEmpty(username.value) || isEmpty(password.value)) {
+ toaster.form.error({
+ title:'Please enter your username and password'
+ })
+ return
+ }
+
+ await store.login(username.value, password.value)
+ });
+};
+
+const onSubmitTotp = (code: string) => {
+ //Invoke totp login
+ apiCall(() => store.plugins.user.submitMfa({ code: toNumber(code) }));
+};
+
+</script>
+
+<style lang="scss">
+ #totp-login .otp-input input {
+ @apply w-10 p-0.5 rounded text-center text-lg mx-1 focus:border-primary-500;
+}
+</style> \ No newline at end of file
diff --git a/extension/src/entries/popup/main.js b/extension/src/entries/popup/main.js
index 8b8a3d9..c8e9ef8 100644
--- a/extension/src/entries/popup/main.js
+++ b/extension/src/entries/popup/main.js
@@ -24,10 +24,10 @@ import "./local.scss"
/* FONT AWESOME CONFIG */
import { library } from '@fortawesome/fontawesome-svg-core'
-import { faArrowRightFromBracket, faCopy, faEdit, faGear, faMinus, faMoon, faPlus, faSpinner, faSun } from '@fortawesome/free-solid-svg-icons'
+import { faArrowRightFromBracket, faCopy, faEdit, faGear, faMinus, faMoon, faPlus, faRefresh, faSpinner, faSun } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
-library.add(faSpinner, faEdit, faGear, faCopy, faArrowRightFromBracket, faPlus, faMinus, faSun, faMoon)
+library.add(faSpinner, faEdit, faGear, faCopy, faArrowRightFromBracket, faPlus, faMinus, faSun, faMoon, faRefresh)
const bgPlugin = useBackgroundPiniaPlugin('popup')
diff --git a/extension/src/entries/store/features.ts b/extension/src/entries/store/features.ts
index c619b0e..9f9a4db 100644
--- a/extension/src/entries/store/features.ts
+++ b/extension/src/entries/store/features.ts
@@ -1,7 +1,22 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'pinia'
import { } from 'lodash'
import { PiniaPluginContext } from 'pinia'
+import type { IMfaFlowContinuiation } from '@vnuge/vnlib.browser'
import {
useAuthApi,
@@ -14,7 +29,8 @@ import {
useForegoundFeatures,
useEventTagFilterApi,
useInjectAllowList,
- onWatchableChange
+ onWatchableChange,
+ useMfaConfigApi
} from "../../features"
import { ChannelContext } from '../../messaging'
@@ -25,6 +41,7 @@ export type BgPluginState<T> = { plugins: BgPlugins } & T
declare module 'pinia' {
export interface PiniaCustomProperties {
plugins: BgPlugins
+ mfaStatus: Partial<IMfaFlowContinuiation> | null
}
}
@@ -41,7 +58,8 @@ const usePlugins = (context: ChannelContext) => {
localPki: use(useLocalPki),
pki: use(usePkiApi),
tagFilter: use(useEventTagFilterApi),
- allowedOrigins: use(useInjectAllowList)
+ allowedOrigins: use(useInjectAllowList),
+ mfaConfig: use(useMfaConfigApi)
}
}
@@ -56,9 +74,10 @@ export const useBackgroundPiniaPlugin = (context: ChannelContext) => {
//watch for status changes
onWatchableChange(user, async () => {
//Get status update and set the values
- const { loggedIn, userName } = await user.getStatus();
+ const { loggedIn, userName, mfaStatus } = await user.getStatus();
store.loggedIn = loggedIn;
store.userName = userName;
+ store.mfaStatus = mfaStatus
}, { immediate: true })
diff --git a/extension/src/entries/store/identity.ts b/extension/src/entries/store/identity.ts
index ef1941e..5bbc67a 100644
--- a/extension/src/entries/store/identity.ts
+++ b/extension/src/entries/store/identity.ts
@@ -1,3 +1,17 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'pinia'
import { } from 'lodash'
@@ -13,6 +27,7 @@ declare module 'pinia' {
createIdentity(id: Partial<NostrPubKey>): Promise<NostrPubKey>;
updateIdentity(id: NostrPubKey): Promise<NostrPubKey>;
selectKey(key: NostrPubKey): Promise<void>;
+ refreshIdentities(): Promise<void>;
}
}
@@ -40,6 +55,7 @@ export const identityPlugin = ({ store }: PiniaPluginContext) => {
selectKey: identity.selectKey,
deleteIdentity: identity.deleteIdentity,
createIdentity: identity.createIdentity,
- updateIdentity: identity.updateIdentity
+ updateIdentity: identity.updateIdentity,
+ refreshIdentities: identity.refreshKeys
}
} \ No newline at end of file
diff --git a/extension/src/entries/store/index.ts b/extension/src/entries/store/index.ts
index 07fce6d..e3eef2f 100644
--- a/extension/src/entries/store/index.ts
+++ b/extension/src/entries/store/index.ts
@@ -23,6 +23,7 @@ export type * from './types'
export * from './allowedOrigins'
export * from './features'
export * from './identity'
+export * from './mfaconfig'
export const useStore = defineStore({
id: 'main',
@@ -34,8 +35,8 @@ export const useStore = defineStore({
}),
actions: {
- async login (token: string) {
- await this.plugins.user.login(token);
+ async login (usernameOrToken: string, password?: string) {
+ await this.plugins.user.login(usernameOrToken, password);
},
async logout () {
@@ -49,10 +50,6 @@ export const useStore = defineStore({
async toggleDarkMode(){
await this.plugins.settings.setDarkMode(this.darkMode === false)
},
-
- checkIsCurrentOriginAllowed() {
-
- }
},
getters:{
diff --git a/extension/src/entries/store/mfaconfig.ts b/extension/src/entries/store/mfaconfig.ts
new file mode 100644
index 0000000..6a5116d
--- /dev/null
+++ b/extension/src/entries/store/mfaconfig.ts
@@ -0,0 +1,67 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import 'pinia'
+import { } from 'lodash'
+import { PiniaPluginContext } from 'pinia'
+import { shallowRef } from 'vue';
+import { MfaUpdateResult, PkiPubKey, onWatchableChange } from '../../features';
+import { MfaMethod } from '@vnuge/vnlib.browser';
+
+declare module 'pinia' {
+ export interface PiniaCustomProperties {
+ readonly mfaEnabledMethods: Array<MfaMethod>;
+ readonly pkiServerKeys: Array<PkiPubKey>;
+ mfaUpsertMethod(method: MfaMethod, password: string): Promise<MfaUpdateResult>;
+ mfaDisableMethod(method: MfaMethod, password: string): Promise<void>;
+ mfaRefresh(): void;
+ pkiAddKey(key: PkiPubKey): Promise<void>;
+ pkiRemoveKey(key: PkiPubKey): Promise<void>;
+ }
+}
+
+export const mfaConfigPlugin = ({ store }: PiniaPluginContext) => {
+
+ const mfaEnabledMethods = shallowRef<MfaMethod[]>()
+ const pkiServerKeys = shallowRef<PkiPubKey[]>()
+ const { mfaConfig, pki } = store.plugins
+
+ onWatchableChange(mfaConfig, async () => {
+ //store enabled methods
+ mfaEnabledMethods.value = await mfaConfig.getMfaMethods()
+ }, { immediate: true })
+
+ onWatchableChange(pki, async () => {
+ //store pki keys
+ pkiServerKeys.value = await pki.getAllKeys()
+ }, { immediate: true })
+
+ return {
+ mfaEnabledMethods,
+ pkiServerKeys,
+ mfaUpsertMethod: (method: MfaMethod, password: string) => {
+ return mfaConfig.enableOrUpdate(method, password)
+ },
+ mfaDisableMethod: async (method: MfaMethod, password: string) => {
+ await mfaConfig.disableMethod(method, password)
+ },
+ mfaRefresh: () => {
+ mfaConfig.refresh()
+ pki.refresh()
+ },
+ pkiAddKey: pki.addOrUpdate,
+ pkiRemoveKey: pki.removeKey
+ }
+}
diff --git a/extension/src/features/auth-api.ts b/extension/src/features/auth-api.ts
index f47d505..fbc9420 100644
--- a/extension/src/features/auth-api.ts
+++ b/extension/src/features/auth-api.ts
@@ -1,4 +1,4 @@
-// Copyright (C) 2023 Vaughn Nugent
+// Copyright (C) 2024 Vaughn Nugent
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
@@ -14,15 +14,17 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { AxiosInstance } from "axios";
-import { get } from "@vueuse/core";
-import { computed } from "vue";
-import { delay } from "lodash";
-import { usePkiAuth, useSession, useUser } from "@vnuge/vnlib.browser";
+import { get, useTimeoutFn, set } from "@vueuse/core";
+import { computed, shallowRef } from "vue";
+import { clone, defer, delay } from "lodash";
+import { IMfaFlowContinuiation, totpMfaProcessor, useMfaLogin, usePkiAuth, useSession, useUser,
+ type IMfaSubmission, type IMfaMessage, type WebMessage
+} from "@vnuge/vnlib.browser";
+import { type FeatureApi, type BgRuntime, type IFeatureExport, exportForegroundApi, popupAndOptionsOnly, popupOnly } from "./framework";
+import { waitForChangeFn } from "./util";
import type { ClientStatus } from "./types";
import type { AppSettings } from "./settings";
import type { JsonObject } from "type-fest";
-import { type FeatureApi, type BgRuntime, type IFeatureExport, exportForegroundApi, popupAndOptionsOnly, popupOnly } from "./framework";
-import { waitForChangeFn } from "./util";
export interface ProectedHandler<T extends JsonObject> {
@@ -38,11 +40,12 @@ export interface ApiMessageHandler<T extends JsonObject> {
}
export interface UserApi extends FeatureApi {
- login: (token: string) => Promise<boolean>
+ login(username: string, password?: string): Promise<boolean>
logout: () => Promise<void>
getProfile: () => Promise<any>
getStatus: () => Promise<ClientStatus>
waitForChange: () => Promise<void>
+ submitMfa: (submission: IMfaSubmission) => Promise<boolean>
}
export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => {
@@ -55,7 +58,8 @@ export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => {
const currentPkiPath = computed(() => `${currentConfig.value.accountBasePath}/pki`)
//Use pki login controls
- const { login } = usePkiAuth(currentPkiPath as any)
+ const pkiAuth = usePkiAuth(currentPkiPath as any)
+ const { login } = useMfaLogin([ totpMfaProcessor() ])
//We can send post messages to the server heartbeat endpoint to get status
const runHeartbeat = async () => {
@@ -76,15 +80,73 @@ export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => {
}
}
+ const mfaUpgrade = (() => {
+
+ const store = shallowRef<IMfaFlowContinuiation | null>(null)
+ const message = computed<IMfaMessage | null>(() =>{
+ if(!store.value){
+ return null
+ }
+ //clone the continuation to send to the popup
+ const cl = clone<Partial<IMfaFlowContinuiation>>(store.value)
+ //Remove the submit method from the continuation
+ delete cl.submit;
+ return cl as IMfaMessage
+ })
+
+ const { start, stop } = useTimeoutFn(() => set(store, null), 360 * 1000)
+
+ return{
+ setContiuation(cont: IMfaFlowContinuiation){
+ //Store continuation for later
+ set(store, cont)
+ //Restart cleanup timer
+ start()
+ },
+ continuation: message,
+ async submit(submission: IMfaSubmission){
+ const cont = get(store)
+ if(!cont){
+ throw new Error('MFA login expired')
+ }
+ const response = await cont.submit(submission)
+ response.getResultOrThrow()
+
+ //Stop timer
+ stop()
+ //clear the continuation
+ defer(() => set(store, null))
+ }
+ }
+ })()
+
//Configure interval to run every 5 minutes to update the status
setInterval(runHeartbeat, 60 * 1000);
delay(runHeartbeat, 1000) //Delay 1 second to allow the extension to load
return {
- waitForChange: waitForChangeFn([currentConfig, loggedIn, userName]),
- login: popupOnly(async (token: string): Promise<boolean> => {
- //Perform login
- await login(token)
+ waitForChange: waitForChangeFn([currentConfig, loggedIn, userName, mfaUpgrade.continuation]),
+ login: popupOnly(async (usernameOrToken: string, password?: string): Promise<boolean> => {
+
+ if(password){
+ const result = await login(usernameOrToken, password)
+ if ('getResultOrThrow' in result){
+ (result as WebMessage).getResultOrThrow()
+ }
+
+ if((result as IMfaFlowContinuiation).submit){
+ //Capture continuation, store for submission for later, and set the continuation
+ mfaUpgrade.setContiuation(result as IMfaFlowContinuiation);
+ return true;
+ }
+
+ //Otherwise normal login
+ }
+ else{
+ //Perform login
+ await pkiAuth.login(usernameOrToken)
+ }
+
//load profile
getProfile()
return true;
@@ -95,6 +157,19 @@ export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => {
//Cleanup after logout
clearLoginState()
}),
+ submitMfa: popupOnly(async (submission: IMfaSubmission): Promise<boolean> => {
+ const cont = get(mfaUpgrade.continuation)
+ if(!cont || cont.expired){
+ return false;
+ }
+
+ //Submit the continuation
+ await mfaUpgrade.submit(submission);
+
+ //load profile
+ getProfile()
+ return true;
+ }),
getProfile: popupAndOptionsOnly(getProfile),
async getStatus (){
return {
@@ -102,6 +177,8 @@ export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => {
loggedIn: get(loggedIn),
//username
userName: get(userName),
+ //mfa status
+ mfaStatus: get(mfaUpgrade.continuation)
} as ClientStatus
},
}
@@ -111,7 +188,8 @@ export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => {
'logout',
'getProfile',
'getStatus',
- 'waitForChange'
+ 'waitForChange',
+ 'submitMfa',
]),
}
} \ No newline at end of file
diff --git a/extension/src/features/framework/index.ts b/extension/src/features/framework/index.ts
index 44ae031..2d9cad5 100644
--- a/extension/src/features/framework/index.ts
+++ b/extension/src/features/framework/index.ts
@@ -34,7 +34,11 @@ export type FeatureApi = {
export type SendMessageHandler = <T extends JsonObject | JsonObject[]>(action: string, data: any) => Promise<T>
export type VarArgsFunction<T> = (...args: any[]) => T
export type FeatureConstructor<TState, T extends FeatureApi> = () => IFeatureExport<TState, T>
-export type DummyApiExport<T extends FeatureApi> = Array<keyof T>
+
+export type DummyApiExport<T extends FeatureApi> = {
+ [K in keyof T]: T[K] extends Function ? K : never
+}[keyof T][]
+
export interface IFeatureExport<TState, TFeature extends FeatureApi> {
/**
diff --git a/extension/src/features/identity-api.ts b/extension/src/features/identity-api.ts
index a8ac4e6..d909162 100644
--- a/extension/src/features/identity-api.ts
+++ b/extension/src/features/identity-api.ts
@@ -24,10 +24,10 @@ import {
exportForegroundApi
} from "./framework";
import { AppSettings } from "./settings";
-import { shallowRef, watch } from "vue";
+import { shallowRef } from "vue";
import { useSession } from "@vnuge/vnlib.browser";
-import { set, useToggle } from "@vueuse/core";
-import { defer, isArray } from "lodash";
+import { set, useToggle, watchDebounced } from "@vueuse/core";
+import { isArray } from "lodash";
import { waitForChange, waitForChangeFn } from "./util";
export interface IdentityApi extends FeatureApi, Watchable {
@@ -37,6 +37,7 @@ export interface IdentityApi extends FeatureApi, Watchable {
getAllKeys: () => Promise<NostrPubKey[]>;
getPublicKey: () => Promise<NostrPubKey | undefined>;
selectKey: (key: NostrPubKey) => Promise<void>;
+ refreshKeys: () => Promise<void>;
}
export const useIdentityApi = (): IFeatureExport<AppSettings, IdentityApi> => {
@@ -50,27 +51,23 @@ export const useIdentityApi = (): IFeatureExport<AppSettings, IdentityApi> => {
const allKeys = shallowRef<NostrPubKey[]>([]);
const [ onKeyUpdateTriggered , triggerKeyUpdate ] = useToggle()
- const keyLoadWatchLoop = async () => {
- while(true){
- //Load keys from server if logged in
- if(loggedIn.value){
- const [...keys] = await execRequest(Endpoints.GetKeys);
- allKeys.value = isArray(keys) ? keys : [];
- }
- else{
- //Clear all keys when logged out
- allKeys.value = [];
- }
-
- //Wait for changes to trigger a new key-load
- await waitForChange([loggedIn, onKeyUpdateTriggered])
+ watchDebounced([onKeyUpdateTriggered, loggedIn], async () => {
+ //Load keys from server if logged in
+ if (loggedIn.value) {
+ const [...keys] = await execRequest(Endpoints.GetKeys);
+ allKeys.value = isArray(keys) ? keys : [];
}
- }
+ else {
+ //Clear all keys when logged out
+ allKeys.value = [];
- defer(keyLoadWatchLoop)
+ //Clear the selected key if the user becomes logged out
+ selectedKey.value = undefined;
+ }
- //Clear the selected key if the user logs out
- watch(loggedIn, (li) => li ? null : selectedKey.value = undefined)
+ //Wait for changes to trigger a new key-load
+ await waitForChange([ loggedIn, onKeyUpdateTriggered ])
+ }, { debounce: 100 })
return {
//Identity is only available in options context
@@ -98,6 +95,10 @@ export const useIdentityApi = (): IFeatureExport<AppSettings, IdentityApi> => {
getPublicKey: (): Promise<NostrPubKey | undefined> => {
return Promise.resolve(selectedKey.value);
},
+ refreshKeys: () => {
+ triggerKeyUpdate()
+ return Promise.resolve()
+ },
waitForChange: waitForChangeFn([selectedKey, loggedIn, allKeys])
}
},
@@ -108,7 +109,8 @@ export const useIdentityApi = (): IFeatureExport<AppSettings, IdentityApi> => {
'getAllKeys',
'getPublicKey',
'selectKey',
- 'waitForChange'
+ 'waitForChange',
+ 'refreshKeys'
])
}
} \ No newline at end of file
diff --git a/extension/src/features/index.ts b/extension/src/features/index.ts
index c146cef..f05de9b 100644
--- a/extension/src/features/index.ts
+++ b/extension/src/features/index.ts
@@ -17,13 +17,13 @@
export type { NostrPubKey, LoginMessage } from './types'
export type * from './framework'
export type { PluginConfig } from './settings'
-export type { PkiPubKey, EcKeyParams, LocalPkiApi as PkiApi } from './account-api'
+export type { PkiPubKey, EcKeyParams, LocalPkiApi as PkiApi } from './pki-api'
export type { NostrApi } from './nostr-api'
export type { UserApi } from './auth-api'
export type { IdentityApi } from './identity-api'
export { useBackgroundFeatures, useForegoundFeatures } from './framework'
-export { useLocalPki, usePkiApi } from './account-api'
+export { useLocalPki, usePkiApi } from './pki-api'
export { useAuthApi } from './auth-api'
export { useIdentityApi } from './identity-api'
export { useNostrApi } from './nostr-api'
@@ -31,4 +31,5 @@ export { useSettingsApi, useAppSettings } from './settings'
export { useHistoryApi } from './history'
export { useEventTagFilterApi } from './tagfilter-api'
export { useInjectAllowList } from './nip07allow-api'
-export { onWatchableChange } from './util' \ No newline at end of file
+export { onWatchableChange } from './util'
+export { useMfaConfigApi, type MfaUpdateResult } from './mfa-api' \ No newline at end of file
diff --git a/extension/src/features/mfa-api.ts b/extension/src/features/mfa-api.ts
new file mode 100644
index 0000000..fc6d51a
--- /dev/null
+++ b/extension/src/features/mfa-api.ts
@@ -0,0 +1,87 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { get, set, useToggle, watchDebounced } from "@vueuse/core";
+import { computed, shallowRef } from "vue";
+import { } from "lodash";
+import { useSession, useMfaConfig, MfaMethod } from "@vnuge/vnlib.browser";
+import type { TotpUpdateMessage, Watchable } from "./types";
+import type { AppSettings } from "./settings";
+import { type FeatureApi, type BgRuntime, type IFeatureExport, exportForegroundApi, optionsOnly } from "./framework";
+import { waitForChangeFn } from "./util";
+
+export type MfaUpdateResult = TotpUpdateMessage
+
+export interface MfaConfigApi extends FeatureApi, Watchable {
+ getMfaMethods: () => Promise<MfaMethod[]>
+ enableOrUpdate: (method: MfaMethod, password: string) => Promise<MfaUpdateResult>
+ disableMethod: (method: MfaMethod, password: string) => Promise<void>
+ refresh: () => Promise<void>
+}
+
+export const useMfaConfigApi = (): IFeatureExport<AppSettings, MfaConfigApi> => {
+
+ return {
+ background: ({ state }: BgRuntime<AppSettings>): MfaConfigApi => {
+ const { loggedIn } = useSession();
+ const { currentConfig } = state
+
+ const [onRefresh, refresh] = useToggle()
+
+ const mfaPath = computed(() => `${currentConfig.value.accountBasePath}/mfa`)
+ const mfaConfig = useMfaConfig(mfaPath)
+ const mfaEnabledMethods = shallowRef<MfaMethod[]>([])
+
+ //Update enabled methods
+ watchDebounced([currentConfig, loggedIn, onRefresh], async () => {
+ if(!loggedIn.value){
+ set(mfaEnabledMethods, [])
+ return
+ }
+ const methods = await mfaConfig.getMethods()
+ set(mfaEnabledMethods, methods)
+ }, { debounce: 100 })
+
+ return {
+ waitForChange: waitForChangeFn([currentConfig, loggedIn, mfaEnabledMethods]),
+
+ getMfaMethods: optionsOnly(() => {
+ return Promise.resolve(get(mfaEnabledMethods))
+ }),
+ enableOrUpdate: optionsOnly(async (method: MfaMethod, password: string) => {
+ //Exec request to update mfa method
+ const result = await mfaConfig.initOrUpdateMethod<MfaUpdateResult>(method, password)
+ refresh()
+ return result.getResultOrThrow()
+ }),
+ disableMethod: optionsOnly(async (method: MfaMethod, password: string) => {
+ await mfaConfig.disableMethod(method, password)
+ refresh()
+ }),
+ refresh() {
+ refresh()
+ return Promise.resolve()
+ }
+ }
+ },
+ foreground: exportForegroundApi<MfaConfigApi>([
+ 'waitForChange',
+ 'getMfaMethods',
+ 'enableOrUpdate',
+ 'disableMethod',
+ 'refresh'
+ ]),
+ }
+} \ No newline at end of file
diff --git a/extension/src/features/account-api.ts b/extension/src/features/pki-api.ts
index 96948c4..41fbd48 100644
--- a/extension/src/features/account-api.ts
+++ b/extension/src/features/pki-api.ts
@@ -13,15 +13,17 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-import { useMfaConfig, usePkiConfig, type PkiPublicKey } from "@vnuge/vnlib.browser";
+import { usePkiConfig, type PkiPublicKey, useSession } from "@vnuge/vnlib.browser";
import { ArrayToHexString, Base64ToUint8Array } from "@vnuge/vnlib.browser/dist/binhelpers";
import { JsonObject } from "type-fest";
-import { computed, watch } from "vue";
+import { computed, shallowRef } from "vue";
import { JWK, SignJWT, importJWK } from "jose";
import { clone } from "lodash";
-import { FeatureApi, BgRuntime, IFeatureExport, exportForegroundApi, optionsOnly, popupAndOptionsOnly } from "./framework";
+import { FeatureApi, BgRuntime, IFeatureExport, exportForegroundApi, optionsOnly } from "./framework";
import { AppSettings } from "./settings";
-import { set, toRefs } from "@vueuse/core";
+import { get, set, toRefs, useToggle, watchDebounced } from "@vueuse/core";
+import { Watchable } from "./types";
+import { waitForChangeFn } from "./util";
export interface EcKeyParams extends JsonObject {
@@ -39,43 +41,59 @@ export interface PkiPubKey extends JsonObject, PkiPublicKey {
readonly userName: string
}
-export interface PkiApi extends FeatureApi{
+export interface PkiApi extends FeatureApi, Watchable{
getAllKeys(): Promise<PkiPubKey[]>
+ addOrUpdate(key: PkiPubKey): Promise<void>
removeKey(kid: PkiPubKey): Promise<void>
- isEnabled(): Promise<boolean>
+ refresh(): Promise<void>
}
export const usePkiApi = (): IFeatureExport<AppSettings, PkiApi> => {
return{
background: ({ state } : BgRuntime<AppSettings>):PkiApi =>{
+ const { loggedIn } = useSession()
const accountPath = computed(() => state.currentConfig.value.accountBasePath)
- const mfaEndpoint = computed(() => `${accountPath.value}/mfa`)
const pkiEndpoint = computed(() => `${accountPath.value}/pki`)
-
+ const [ onRefresh, refresh ] = useToggle()
//Compute config
- const mfaConfig = useMfaConfig(mfaEndpoint);
- const pkiConfig = usePkiConfig(pkiEndpoint, mfaConfig);
+
+ const pkiConfig = usePkiConfig(pkiEndpoint);
+ const keys = shallowRef<PkiPubKey[]>([])
//Refresh the config when the endpoint changes
- watch(mfaEndpoint, () => pkiConfig.refresh());
+ watchDebounced([pkiEndpoint, loggedIn, onRefresh], async () => {
+ if(!loggedIn.value){
+ set(keys, [])
+ return
+ }
+
+ const res = await pkiConfig.getAllKeys()
+ set(keys, res as PkiPubKey[])
+ }, {debounce: 100});
return{
- getAllKeys: optionsOnly(async () => {
- const res = await pkiConfig.getAllKeys();
- return res as PkiPubKey[]
+ waitForChange: waitForChangeFn([loggedIn, keys]),
+ getAllKeys: optionsOnly(() => {
+ return Promise.resolve(get(keys))
}),
removeKey: optionsOnly(async (key: PkiPubKey) => {
await pkiConfig.removeKey(key.kid)
}),
- isEnabled: popupAndOptionsOnly(async () => {
- return pkiConfig.enabled.value
- })
+ addOrUpdate: optionsOnly(async (key: PkiPubKey) => {
+ await pkiConfig.addOrUpdate(key)
+ }),
+ refresh() {
+ refresh()
+ return Promise.resolve()
+ }
}
},
foreground: exportForegroundApi<PkiApi>([
+ 'waitForChange',
'getAllKeys',
+ 'addOrUpdate',
'removeKey',
- 'isEnabled'
+ 'refresh'
])
}
}
diff --git a/extension/src/features/types.ts b/extension/src/features/types.ts
index 856b95a..92cf6cf 100644
--- a/extension/src/features/types.ts
+++ b/extension/src/features/types.ts
@@ -63,6 +63,7 @@ export interface LoginMessage extends JsonObject {
export interface ClientStatus extends JsonObject {
readonly loggedIn: boolean;
readonly userName: string | null;
+ readonly mfaStatus: object | null;
}
export enum NostrRelayFlags {
@@ -96,4 +97,12 @@ export interface Watchable{
* must be called again to listen for the next change.
*/
waitForChange(): Promise<void>;
+}
+
+export interface TotpUpdateMessage extends JsonObject {
+ readonly issuer: string
+ readonly digits: number
+ readonly period: number
+ readonly algorithm: string
+ readonly secret: string
} \ No newline at end of file