diff options
Diffstat (limited to 'extension/src/entries/options')
-rw-r--r-- | extension/src/entries/options/App.vue | 199 | ||||
-rw-r--r-- | extension/src/entries/options/components/Identities.vue | 188 | ||||
-rw-r--r-- | extension/src/entries/options/components/Privacy.vue | 9 | ||||
-rw-r--r-- | extension/src/entries/options/components/SiteSettings.vue | 235 | ||||
-rw-r--r-- | extension/src/entries/options/index.html | 21 | ||||
-rw-r--r-- | extension/src/entries/options/main.js | 33 |
6 files changed, 685 insertions, 0 deletions
diff --git a/extension/src/entries/options/App.vue b/extension/src/entries/options/App.vue new file mode 100644 index 0000000..d44a4ff --- /dev/null +++ b/extension/src/entries/options/App.vue @@ -0,0 +1,199 @@ +<template> + <main id="injected-root"> + + <notifications class="toaster" group="form" position="top-right" /> + + <div class="container flex w-full p-4 mx-auto mt-8 text-gray-800 dark:text-gray-200"> + <div class="w-full max-w-4xl mx-auto"> + <div class=""> + <h3>Nostr Vault</h3> + </div> + <TabGroup :selected-index="selectedTab" @change="id => selectedTab = id" > + <TabList class="flex gap-3 pb-2 border-b border-gray-300 dark:border-dark-500"> + <Tab v-slot="{ selected }"> + <button class="border-b-2" :class="[selected ? 'border-primary-500' : 'border-transparent']"> + Identities + </button> + </Tab> + <Tab v-slot="{ selected }"> + <button class="border-b-2" :class="[selected ? 'border-primary-500' : 'border-transparent']"> + Privacy + </button> + </Tab> + <Tab v-slot="{ selected }"> + <button class="border-b-2" :class="[selected ? 'border-primary-500' : 'border-transparent']"> + Settings + </button> + </Tab> + <Tab> + <!-- Hidden for editing --> + </Tab> + <div class="m-auto"> + <div class=""> + <!-- Add spinner --> + + </div> + </div> + <div class="hidden my-auto text-sm font-semibold sm:block"> + <div v-if="userName"> + {{ userName }} + </div> + <div v-else> + <div> + Sign In + </div> + </div> + </div> + <div class="ml-auto sm:ml-0"> + <button class="rounded btn xs" @click="toggleDark()" > + <fa-icon v-if="darkMode" icon="sun"/> + <fa-icon v-else icon="moon" /> + </button> + </div> + </TabList> + <TabPanels> + <TabPanel class="mt-4"> + <Identities :all-keys="allKeys" @edit-key="editKey" @update-all="reloadKeys"/> + </TabPanel> + <TabPanel> + <Privacy/> + </TabPanel> + <TabPanel> + <SiteSettings/> + </TabPanel> + <TabPanel> + <div class="flex flex-col px-2 mt-4"> + <div class="absolute mx-auto"> + <h4>Edit Identity</h4> + </div> + <div class="ml-auto"> + <button class="rounded btn sm" @click.self="doneEditing"> + <fa-icon class="mr-2" icon="chevron-left"/> + Back + </button> + </div> + <div class="flex flex-col mx-auto mt-2"> + <div class="text-sm break-all"> + Internal Id : {{ keyBuffer?.Id }} + </div> + <div class="text-sm break-all"> + Public Key : {{ keyBuffer?.PublicKey }} + </div> + <div class="flex flex-col w-full max-w-md mx-auto mt-3"> + <div class=""> + <div class="text-sm">User Name</div> + <input class="w-full primary" type="text" v-model="keyBuffer.UserName"/> + </div> + <div class="gap-2 my-3 ml-auto"> + <button class="rounded btn sm primary" @click="onUpdate">Update</button> + </div> + </div> + </div> + </div> + </TabPanel> + </TabPanels> + </TabGroup> + </div> + </div> + </main> +</template> + +<script setup lang="ts"> +import { ref, watchEffect } from "vue"; +import { + TabGroup, + TabList, + Tab, + TabPanels, + TabPanel, +} from '@headlessui/vue' +import { configureNotifier } from '@vnuge/vnlib.browser'; +import { useManagment, useStatus, NostrPubKey } from '~/bg-api/options.ts'; +import { notify } from "@kyvg/vue3-notification"; +import { watchDebounced } from '@vueuse/core'; +import SiteSettings from './components/SiteSettings.vue'; +import Identities from './components/Identities.vue'; +import Privacy from "./components/Privacy.vue"; + +//Configure the notifier to use the notification library +configureNotifier({ notify, close: notify.close }) + +const { userName, darkMode } = useStatus() +const { getAllKeys, updateIdentity, getSiteConfig, saveSiteConfig } = useManagment() + +const selectedTab = ref(0) +const allKeys = ref([]) +const keyBuffer = ref(null) + +const editKey = (key: NostrPubKey) =>{ + //Goto hidden tab + selectedTab.value = 3 + //Set selected key + keyBuffer.value = { ...key } +} + +const doneEditing = () =>{ + //Goto hidden tab + selectedTab.value = 0 + //Set selected key + keyBuffer.value = null +} + +const onUpdate = async () =>{ + //Update identity + await updateIdentity(keyBuffer.value) + //Goto hidden tab + selectedTab.value = 0 + //Set selected key + keyBuffer.value = null +} + +const reloadKeys = async () =>{ + //Load all keys (identities) + const keys = await getAllKeys() + allKeys.value = keys; +} + +const toggleDark = async () => { + const config = await getSiteConfig(); + config.darkMode = !config.darkMode; + await saveSiteConfig(config); +} + +//Initial load +reloadKeys(); + +//If the tab changes to the identities tab, reload the keys +watchDebounced(selectedTab, id => id == 0 ? reloadKeys() : null, { debounce: 100 }) + +//Watch for dark mode changes and update the body class +watchEffect(() => darkMode.value ? document.body.classList.add('dark') : document.body.classList.remove('dark')); + +</script> + +<style lang="scss" scoped> + +main { + font-family: Avenir, Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.toaster{ + position: fixed; + top: 15px; + right: 0; + z-index: 9999; + max-width: 230px; +} + +.id-card{ + @apply flex md:flex-row flex-col gap-2 p-3 text-sm duration-75 ease-in-out border-2 rounded-lg shadow-md cursor-pointer; + @apply bg-white dark:bg-dark-700 border-gray-200 hover:border-gray-400 dark:border-dark-500 hover:dark:border-dark-200; + + &.selected{ + @apply border-primary-500 hover:border-primary-500; + } +} + +</style>
\ No newline at end of file diff --git a/extension/src/entries/options/components/Identities.vue b/extension/src/entries/options/components/Identities.vue new file mode 100644 index 0000000..c86a6ac --- /dev/null +++ b/extension/src/entries/options/components/Identities.vue @@ -0,0 +1,188 @@ +<template> + <div class="sm:px-3"> + <div class="flex justify-end gap-2"> + <div class=""> + <div class=""> + <button class="rounded btn sm" @click="onNip05Download"> + NIP-05 + <fa-icon icon="download" class="ml-1" /> + </button> + </div> + </div> + <div class="mb-2"> + <Popover class="relative" v-slot="{ open }"> + <PopoverButton class="rounded btn primary sm">Create</PopoverButton> + <PopoverOverlay v-if="open" class="fixed inset-0 bg-black opacity-30" /> + <PopoverPanel class="absolute z-10 mt-2 md:-left-12" v-slot="{ close }"> + <div class="p-4 bg-white border border-gray-200 rounded-md shadow-lg dark:border-dark-300 dark:bg-dark-700"> + <div class="text-sm w-72"> + <form @submit.prevent="e => onCreate(e, close)"> + Create new nostr identity + <div class="mt-2"> + <input class="w-full primary" type="text" name="username" placeholder="User Name"/> + </div> + <div class="mt-2"> + <input class="w-full primary" type="text" name="key" placeholder="Existing key?"/> + <div class="p-1.5 text-xs text-gray-600 dark:text-gray-300"> + Optional, hexadecimal private key (64 characters) + </div> + </div> + <div class="flex justify-end mt-2"> + <button class="rounded btn sm primary" type="submit">Create</button> + </div> + </form> + </div> + </div> + </PopoverPanel> + </Popover> + </div> + </div> + <div v-for="key in allKeys" :key="key" class="mt-2 mb-3"> + <div class="id-card" :class="{'selected': isSelected(key)}" @click.self="selectKey(key)"> + + <div class="flex flex-col min-w-0" @click="selectKey(key)"> + <div class="py-2"> + + <table class="w-full text-sm text-left border-collapse"> + <thead class=""> + <tr> + <th scope="col" class="p-2 font-medium">Nip 05</th> + <th scope="col" class="p-2 font-medium">Modified</th> + <th scope="col" class="p-2 font-medium"></th> + </tr> + </thead> + <tbody class="border-t border-gray-100 divide-y divide-gray-100 dark:border-dark-500 dark:divide-dark-500"> + <tr> + <th class="p-2 font-medium">{{ key.UserName }}</th> + <td class="p-2">{{ prettyPrintDate(key) }}</td> + <td class="flex justify-end p-2 ml-auto text-sm font-medium"> + <div class="ml-auto button-group"> + <button class="btn sm borderless" @click="copy(key.PublicKey)"> + <fa-icon icon="copy"/> + </button> + <button class="btn sm borderless" @click="editKey(key)"> + <fa-icon icon="edit"/> + </button> + <button class="btn sm red borderless" @click="onDeleteKey(key)"> + <fa-icon icon="trash" /> + </button> + </div> + </td> + </tr> + </tbody> + </table> + + </div> + <div class="py-2 overflow-hidden border-gray-500 border-y dark:border-dark-500 text-ellipsis"> + <span class="font-semibold">pub:</span> + <span class="ml-1">{{ key.PublicKey }}</span> + </div> + <div class="py-2"> + <strong>Id:</strong> {{ key.Id }} + </div> + </div> + </div> + </div> + <a class="hidden" ref="downloadAnchor"></a> + </div> +</template> + +<script setup lang="ts"> + +import { isEqual, map } from 'lodash' +import { ref, toRefs } from "vue"; +import { + Popover, + PopoverButton, + PopoverPanel +} from '@headlessui/vue' +import { apiCall, configureNotifier } from '@vnuge/vnlib.browser'; +import { useManagment, useStatus } from '~/bg-api/options.ts'; +import { notify } from "@kyvg/vue3-notification"; +import { useClipboard } from '@vueuse/core'; +import { NostrIdentiy } from '~/bg-api/bg-api'; +import { NostrPubKey } from '../../background/types'; + +const emit = defineEmits(['edit-key', 'update-all']) +const props = defineProps<{ + allKeys:NostrIdentiy[] +}>() + +const { allKeys } = toRefs(props) + +//Configre the notifier to use the toaster +configureNotifier({ notify, close: notify.close }) + +const downloadAnchor = ref<HTMLAnchorElement>() +const { selectedKey } = useStatus() +const { selectKey, createIdentity, deleteIdentity, getAllKeys } = useManagment() +const { copy } = useClipboard() + +const isSelected = (me : NostrIdentiy) => isEqual(me, selectedKey.value) + +const editKey = (key : NostrIdentiy) => emit('edit-key', key); + +const onCreate = async (e: Event, onClose : () => void) => { + + //get username input from event + const UserName = e.target['username']?.value as string + //try to get existing key field + const ExistingKey = e.target['key']?.value as string + + //Create new identity + await createIdentity({ UserName, ExistingKey }) + //Update keys + emit('update-all'); + onClose() +} + +const prettyPrintDate = (key : NostrIdentiy) => { + const d = new Date(key.LastModified) + return `${d.toLocaleDateString()} ${d.toLocaleTimeString()}` +} + +const onDeleteKey = async (key : NostrIdentiy) => { + + if(!confirm(`Are you sure you want to delete ${key.UserName}?`)){ + return; + } + + //Delete identity + await deleteIdentity(key) + + //Update keys + emit('update-all'); +} + +const onNip05Download = () => { + apiCall(async () => { + //Get all public keys from the server + const keys = await getAllKeys() as NostrPubKey[] + const nip05 = {} + //Map the keys to the NIP-05 format + map(keys, k => nip05[k.UserName] = k.PublicKey) + //create file blob + const blob = new Blob([JSON.stringify({ names:nip05 })], { type: 'application/json' }) + + //Download the file + downloadAnchor.value!.href = URL.createObjectURL(blob); + downloadAnchor.value?.setAttribute('download', 'nostr.json') + downloadAnchor.value?.click(); + + }) +} + +</script> + +<style scoped lang="scss"> + +.id-card{ + @apply flex md:flex-row flex-col gap-2 p-3 px-12 text-sm duration-75 ease-in-out border-2 rounded-lg shadow-md cursor-pointer w-fit mx-auto; + @apply bg-white dark:bg-dark-800 border-gray-200 hover:border-gray-400 dark:border-dark-500 hover:dark:border-dark-200; + + &.selected{ + @apply border-primary-500 hover:border-primary-500; + } +} + +</style>
\ No newline at end of file diff --git a/extension/src/entries/options/components/Privacy.vue b/extension/src/entries/options/components/Privacy.vue new file mode 100644 index 0000000..7d2ce4d --- /dev/null +++ b/extension/src/entries/options/components/Privacy.vue @@ -0,0 +1,9 @@ +<template> + <div class="flex flex-col w-full mt-4 sm:px-2"> + + </div> +</template> + +<script setup lang="ts"> + +</script>
\ No newline at end of file diff --git a/extension/src/entries/options/components/SiteSettings.vue b/extension/src/entries/options/components/SiteSettings.vue new file mode 100644 index 0000000..eafe8f3 --- /dev/null +++ b/extension/src/entries/options/components/SiteSettings.vue @@ -0,0 +1,235 @@ +<template> + <div class="flex flex-col w-full mt-4 sm:px-2"> + + <form @submit.prevent=""> + <div class="w-full max-w-md mx-auto"> + <h3 class="text-center"> + Extension settings + </h3> + <div class="my-6"> + <fieldset :disabled="waiting"> + <div class="w-full"> + <div class="flex flex-row justify-between"> + <label class="mr-2">Always on NIP-07</label> + <Switch + v-model="buffer.autoInject" + :class="buffer.autoInject ? 'bg-primary-500 dark:bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'" + class="relative inline-flex items-center h-6 ml-auto rounded-full w-11" + > + <span class="sr-only">NIP-07</span> + <span + :class="buffer.autoInject ? 'translate-x-6' : 'translate-x-1'" + class="inline-block w-4 h-4 transition transform bg-white rounded-full" + /> + </Switch> + </div> + </div> + <p class="mt-1 text-xs"> + Enable auto injection of <code>window.nostr</code> support to all websites. Sites may be able to + track you if you enable this feature. + </p> + </fieldset> + </div> + <h3 class="text-center"> + Server settings + </h3> + <p class="text-sm"> + You must be careful when editing these settings as you may loose connection to your vault + server if you input the wrong values. + </p> + <div class="flex justify-end mt-2"> + <div class="button-group"> + <button class="rounded btn sm" @click="toggleEdit()"> + <fa-icon v-if="editMode" icon="lock-open"/> + <fa-icon v-else icon="lock"/> + </button> + <a :href="data.apiUrl" target="_blank"> + <button type="button" class="rounded btn sm"> + <fa-icon icon="external-link-alt"/> + </button> + </a> + </div> + </div> + <fieldset :disabled="waiting || !editMode"> + <div class="pl-1 mt-2"> + <div class="flex flex-row w-full"> + <div> + <label class="mb-2">Stay logged in</label> + <Switch + v-model="v$.heartbeat.$model" + :class="v$.heartbeat.$model ? 'bg-primary-500 dark:bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'" + class="relative inline-flex items-center h-6 mx-auto rounded-full w-11" + > + <span class="sr-only">Stay logged in</span> + <span + :class="v$.heartbeat.$model ? 'translate-x-6' : 'translate-x-1'" + class="inline-block w-4 h-4 transition transform bg-white rounded-full" + /> + </Switch> + </div> + <div class="my-auto text-xs"> + Enables keepalive messages to regenerate credentials when they expire + </div> + </div> + </div> + <div class="mt-2"> + <label class="pl-1">BaseUrl</label> + <input class="w-full primary" v-model="v$.apiUrl.$model" :class="{'error': v$.apiUrl.$invalid }" /> + <p class="pl-1 mt-1 text-xs text-red-500"> + * The http path to the vault server (must start with http:// or https://) + </p> + </div> + <div class="mt-2"> + <label class="pl-1">Account endpoint</label> + <input class="w-full primary" v-model="v$.accountBasePath.$model" :class="{ 'error': v$.accountBasePath.$invalid }" /> + <p class="pl-1 mt-1 text-xs text-red-500"> + * This is the path to the account server endpoint (must start with /) + </p> + </div> + <div class="mt-2"> + <label class="pl-1">Nostr endpoint</label> + <input class="w-full primary" v-model="v$.nostrEndpoint.$model" :class="{ 'error': v$.nostrEndpoint.$invalid }" /> + <p class="pl-1 mt-1 text-xs text-red-500"> + * This is the path to the Nostr plugin endpoint path (must start with /) + </p> + </div> + </fieldset> + <div class="flex justify-end mt-2"> + <button :disabled="!modified || waiting" class="rounded btn sm" :class="{'primary':modified}" @click="onSave">Save</button> + </div> + </div> + </form> + </div> +</template> + +<script setup lang="ts"> +import { apiCall, useDataBuffer, useFormToaster, useVuelidateWrapper, useWait } from '@vnuge/vnlib.browser'; +import { computed, ref, watch } from 'vue'; +import { useManagment } from '~/bg-api/options.ts'; +import { useToggle, watchDebounced } from '@vueuse/core'; +import { maxLength, helpers, required } from '@vuelidate/validators' +import { clone, isNil } from 'lodash'; +import{ Switch } from '@headlessui/vue' +import useVuelidate from '@vuelidate/core' + +const { waiting } = useWait(); +const form = useFormToaster(); +const { getSiteConfig, saveSiteConfig } = useManagment(); + +const { apply, data, buffer, modified } = useDataBuffer({ + apiUrl: '', + accountBasePath: '', + nostrEndpoint:'', + heartbeat:false, + autoInject:true, +}) + +const url = (val : string) => /^https?:\/\/[a-zA-Z0-9\.\:\/-]+$/.test(val); +const path = (val : string) => /^\/[a-zA-Z0-9-_]+$/.test(val); + +const vRules = { + apiUrl: { + required:helpers.withMessage('Base url is required', required), + maxLength: helpers.withMessage('Base url must be less than 100 characters', maxLength(100)), + url: helpers.withMessage('You must input a valid url', url) + }, + accountBasePath: { + required:helpers.withMessage('Account path is required', required), + maxLength: maxLength(50), + alphaNum: helpers.withMessage('Account path is not a valid endpoint path that begins with /', path) + }, + nostrEndpoint:{ + required: helpers.withMessage('Nostr path is required', required), + maxLength: maxLength(50), + alphaNum: helpers.withMessage('Nostr path is not a valid endpoint path that begins with /', path) + }, + heartbeat: {}, + darkMode:{} +} + +//Configure validator and validate function +const v$ = useVuelidate(vRules, buffer) +const { validate } = useVuelidateWrapper(v$); + +const editMode = ref(false); +const toggleEdit = useToggle(editMode); + +const autoInject = computed(() => buffer.autoInject) + +const onSave = async () => { + + //Validate + const result = await validate(); + if(!result){ + return; + } + + //Test connection to the server + if(await testConnection() !== true){ + return; + } + + form.info({ + title: 'Reloading in 4 seconds', + text: 'Your configuration will be saved and the extension will reload in 4 seconds' + }) + + await new Promise(r => setTimeout(r, 4000)); + + publishConfig(); + + //disable dit + toggleEdit(); +} + +const publishConfig = async () =>{ + const c = clone(buffer); + await saveSiteConfig(c); + await loadConfig(); +} + +const testConnection = async () =>{ + return await apiCall(async ({axios, toaster}) =>{ + try{ + await axios.get(`${buffer.apiUrl}`); + toaster.general.success({ + title: 'Success', + text: 'Succcesfully connected to the vault server' + }); + return true; + } + catch(e){ + if(isNil(e.response?.status)){ + toaster.form.error({ + title: 'Network error', + text: `Please verify your vault server address` + }); + } + + toaster.form.error({ + title: 'Warning', + text: `Failed to connect to the vault server. Status code: ${e.response.status}` + }); + } + }) +} + +const loadConfig = async () => { + const config = await getSiteConfig(); + apply(config); + + //Watch for changes to autoinject value and publish changes when it does + watchDebounced(autoInject, publishConfig, { debounce: 500 }) +} + +//If edit mode is toggled off, reload config +watch(editMode, v => v ? null : loadConfig()); + + +loadConfig(); + +</script> + +<style lang="scss"> + +</style>
\ No newline at end of file diff --git a/extension/src/entries/options/index.html b/extension/src/entries/options/index.html new file mode 100644 index 0000000..72f2de7 --- /dev/null +++ b/extension/src/entries/options/index.html @@ -0,0 +1,21 @@ +<!DOCTYPE html> +<html lang="en" class="flex" style="min-height: 100vh; min-width: 100vw;"> + <head> + <meta charset="utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Nostr Vault</title> + <style> + body.dark{ + @apply bg-dark-900; + } + body{ + @apply bg-gray-50; + } + </style> + </head> + <body class="w-full"> + <div id="app"></div> + <script type="module" src="./main.js"></script> + </body> +</html> diff --git a/extension/src/entries/options/main.js b/extension/src/entries/options/main.js new file mode 100644 index 0000000..92a4868 --- /dev/null +++ b/extension/src/entries/options/main.js @@ -0,0 +1,33 @@ +// 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 { createApp } from "vue"; +import App from "./App.vue"; +import '@fontsource/noto-sans-masaram-gondi' +import "~/assets/tailwind.scss"; +import Notifications from "@kyvg/vue3-notification"; + +/* FONT AWESOME CONFIG */ +import { library } from '@fortawesome/fontawesome-svg-core' +import { faChevronLeft, faCopy, faDownload, faEdit, faExternalLinkAlt, faLock, faLockOpen, faMoon, faSun, faTrash } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' + +library.add(faCopy, faEdit, faChevronLeft, faMoon, faSun, faLock, faLockOpen, faExternalLinkAlt, faTrash, faDownload) + +createApp(App) + .use(Notifications) + .component('fa-icon', FontAwesomeIcon) + .mount("#app");
\ No newline at end of file |