get vault working
This commit is contained in:
parent
eae4c2b882
commit
dce5762f63
|
@ -8,10 +8,8 @@ export default {
|
||||||
|
|
||||||
const k = await models.vault.findUnique({
|
const k = await models.vault.findUnique({
|
||||||
where: {
|
where: {
|
||||||
userId_key_ownerId_ownerType: {
|
key,
|
||||||
key,
|
userId: me.id
|
||||||
userId: me.id
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return k
|
return k
|
||||||
|
@ -19,7 +17,7 @@ export default {
|
||||||
getVaultEntries: async (parent, { keysFilter }, { me, models }, info) => {
|
getVaultEntries: async (parent, { keysFilter }, { me, models }, info) => {
|
||||||
if (!me) throw new GqlAuthenticationError()
|
if (!me) throw new GqlAuthenticationError()
|
||||||
|
|
||||||
const entries = await models.vault.findMany({
|
const entries = await models.vaultEntry.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: me.id,
|
userId: me.id,
|
||||||
key: keysFilter?.length
|
key: keysFilter?.length
|
||||||
|
@ -54,12 +52,13 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
console.log(entry)
|
||||||
txs.push(models.vaultEntry.update({
|
txs.push(models.vaultEntry.update({
|
||||||
where: { userId_key: { userId: me.id, key: entry.key } },
|
where: { userId_key: { userId: me.id, key: entry.key } },
|
||||||
data: { value: entry.value }
|
data: { value: entry.value, iv: entry.iv }
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
await models.prisma.$transaction(txs)
|
await models.$transaction(txs)
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
clearVault: async (parent, args, { me, models }) => {
|
clearVault: async (parent, args, { me, models }) => {
|
||||||
|
@ -70,7 +69,7 @@ export default {
|
||||||
data: { vaultKeyHash: '' }
|
data: { vaultKeyHash: '' }
|
||||||
}))
|
}))
|
||||||
txs.push(models.vaultEntry.deleteMany({ where: { userId: me.id } }))
|
txs.push(models.vaultEntry.deleteMany({ where: { userId: me.id } }))
|
||||||
await models.prisma.$transaction(txs)
|
await models.$transaction(txs)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,9 +31,7 @@ function injectResolvers (resolvers) {
|
||||||
const resolverName = generateResolverName(walletDef.walletField)
|
const resolverName = generateResolverName(walletDef.walletField)
|
||||||
console.log(resolverName)
|
console.log(resolverName)
|
||||||
resolvers.Mutation[resolverName] = async (parent, { settings, validateLightning, vaultEntries, ...data }, { me, models }) => {
|
resolvers.Mutation[resolverName] = async (parent, { settings, validateLightning, vaultEntries, ...data }, { me, models }) => {
|
||||||
// allow transformation of the data on validation (this is optional ... won't do anything if not implemented)
|
const validData = await validateWallet(walletDef, { ...data, ...settings, vaultEntries }, { serverSide: true })
|
||||||
// TODO: our validation should be improved
|
|
||||||
const validData = await validateWallet(walletDef, { ...data, ...settings, vaultEntries })
|
|
||||||
if (validData) {
|
if (validData) {
|
||||||
Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] })
|
Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] })
|
||||||
Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] })
|
Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] })
|
||||||
|
@ -700,22 +698,27 @@ async function upsertWallet (
|
||||||
data: {
|
data: {
|
||||||
enabled,
|
enabled,
|
||||||
priority,
|
priority,
|
||||||
[wallet.field]: {
|
// client only wallets has no walletData
|
||||||
update: {
|
...(Object.keys(walletData).length > 0
|
||||||
where: { walletId: Number(id) },
|
? {
|
||||||
data: walletData
|
[wallet.field]: {
|
||||||
}
|
update: {
|
||||||
},
|
where: { walletId: Number(id) },
|
||||||
|
data: walletData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
vaultEntries: {
|
vaultEntries: {
|
||||||
deleteMany: difference(oldVaultEntries, vaultEntries, 'key').map(({ key }) => ({
|
deleteMany: difference(oldVaultEntries, vaultEntries, 'key').map(({ key }) => ({
|
||||||
userId: me.id, key
|
userId: me.id, key
|
||||||
})),
|
})),
|
||||||
create: difference(vaultEntries, oldVaultEntries, 'key').map(({ key, value }) => ({
|
create: difference(vaultEntries, oldVaultEntries, 'key').map(({ key, iv, value }) => ({
|
||||||
key, value, userId: me.id
|
key, iv, value, userId: me.id
|
||||||
})),
|
})),
|
||||||
update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, value }) => ({
|
update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, iv, value }) => ({
|
||||||
where: { userId_key: { userId: me.id, key } },
|
where: { userId_key: { userId: me.id, key } },
|
||||||
data: { value }
|
data: { value, iv }
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -735,9 +738,8 @@ async function upsertWallet (
|
||||||
priority,
|
priority,
|
||||||
userId: me.id,
|
userId: me.id,
|
||||||
type: wallet.type,
|
type: wallet.type,
|
||||||
[wallet.field]: {
|
// client only wallets has no walletData
|
||||||
create: walletData
|
...(Object.keys(walletData).length > 0 ? { [wallet.field]: { create: walletData } } : {}),
|
||||||
},
|
|
||||||
vaultEntries: {
|
vaultEntries: {
|
||||||
createMany: {
|
createMany: {
|
||||||
data: vaultEntries.map(({ key, value }) => ({ key, value, userId: me.id }))
|
data: vaultEntries.map(({ key, value }) => ({ key, value, userId: me.id }))
|
||||||
|
|
|
@ -4,6 +4,7 @@ export default gql`
|
||||||
type VaultEntry {
|
type VaultEntry {
|
||||||
id: ID!
|
id: ID!
|
||||||
key: String!
|
key: String!
|
||||||
|
iv: String!
|
||||||
value: String!
|
value: String!
|
||||||
createdAt: Date!
|
createdAt: Date!
|
||||||
updatedAt: Date!
|
updatedAt: Date!
|
||||||
|
@ -11,6 +12,7 @@ export default gql`
|
||||||
|
|
||||||
input VaultEntryInput {
|
input VaultEntryInput {
|
||||||
key: String!
|
key: String!
|
||||||
|
iv: String!
|
||||||
value: String!
|
value: String!
|
||||||
walletId: ID
|
walletId: ID
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,11 +15,11 @@ import { useApolloClient } from '@apollo/client'
|
||||||
export default function DeviceSync () {
|
export default function DeviceSync () {
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
const apollo = useApolloClient()
|
const apollo = useApolloClient()
|
||||||
const [value, setVaultKey, clearVault, disconnectVault] = useVaultConfigurator()
|
const { key, setVaultKey, clearVault } = useVaultConfigurator()
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
|
|
||||||
const enabled = !!me?.privates?.vaultKeyHash
|
const enabled = !!me?.privates?.vaultKeyHash
|
||||||
const connected = !!value?.key
|
const connected = !!key
|
||||||
|
|
||||||
const manage = useCallback(async () => {
|
const manage = useCallback(async () => {
|
||||||
if (enabled && connected) {
|
if (enabled && connected) {
|
||||||
|
@ -27,7 +27,7 @@ export default function DeviceSync () {
|
||||||
<div>
|
<div>
|
||||||
<h2>Device sync is enabled!</h2>
|
<h2>Device sync is enabled!</h2>
|
||||||
<p>
|
<p>
|
||||||
Sensitive data (like wallet credentials) is now securely synced between all connected devices.
|
Sensitive data (like wallet credentials) are now securely synced between all connected devices.
|
||||||
</p>
|
</p>
|
||||||
<p className='text-muted text-sm'>
|
<p className='text-muted text-sm'>
|
||||||
Disconnect to prevent this device from syncing data or to reset your passphrase.
|
Disconnect to prevent this device from syncing data or to reset your passphrase.
|
||||||
|
@ -38,7 +38,7 @@ export default function DeviceSync () {
|
||||||
<Button
|
<Button
|
||||||
variant='primary'
|
variant='primary'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
disconnectVault()
|
clearVault()
|
||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
>disconnect
|
>disconnect
|
||||||
|
@ -52,7 +52,7 @@ export default function DeviceSync () {
|
||||||
<ConnectForm onClose={onClose} onConnect={onConnect} enabled={enabled} />
|
<ConnectForm onClose={onClose} onConnect={onConnect} enabled={enabled} />
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}, [enabled, connected, value])
|
}, [enabled, connected, key])
|
||||||
|
|
||||||
const reset = useCallback(async () => {
|
const reset = useCallback(async () => {
|
||||||
const schema = yup.object().shape({
|
const schema = yup.object().shape({
|
||||||
|
|
|
@ -37,8 +37,9 @@ import Clipboard from '@/svgs/clipboard-line.svg'
|
||||||
import QrIcon from '@/svgs/qr-code-line.svg'
|
import QrIcon from '@/svgs/qr-code-line.svg'
|
||||||
import QrScanIcon from '@/svgs/qr-scan-line.svg'
|
import QrScanIcon from '@/svgs/qr-scan-line.svg'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import QRCode from 'qrcode.react'
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
import { QrScanner } from '@yudiel/react-qr-scanner'
|
import { Scanner } from '@yudiel/react-qr-scanner'
|
||||||
|
import { qrImageSettings } from './qr'
|
||||||
|
|
||||||
export class SessionRequiredError extends Error {
|
export class SessionRequiredError extends Error {
|
||||||
constructor () {
|
constructor () {
|
||||||
|
@ -1069,7 +1070,7 @@ function Client (Component) {
|
||||||
// where the initial value is not available on first render.
|
// where the initial value is not available on first render.
|
||||||
// Example: value is stored in localStorage which is fetched
|
// Example: value is stored in localStorage which is fetched
|
||||||
// after first render using an useEffect hook.
|
// after first render using an useEffect hook.
|
||||||
const [,, helpers] = useField(props)
|
const [,, helpers] = props.noForm ? [{}, {}, {}] : useField(props)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initialValue && helpers.setValue(initialValue)
|
initialValue && helpers.setValue(initialValue)
|
||||||
|
@ -1102,9 +1103,11 @@ function QrPassword ({ value }) {
|
||||||
|
|
||||||
const showQr = useCallback(() => {
|
const showQr = useCallback(() => {
|
||||||
showModal(close => (
|
showModal(close => (
|
||||||
<div className={styles.qr}>
|
<div>
|
||||||
<p>You can import this passphrase into another device by scanning this QR code</p>
|
<p className='line-height-md text-muted'>Import this passphrase into another device by navigating to device sync settings and scanning this QR code</p>
|
||||||
<QRCode value={value} renderAs='svg' />
|
<div className='d-block p-3 mx-auto' style={{ background: 'white', maxWidth: '300px' }}>
|
||||||
|
<QRCodeSVG className='h-auto mw-100' value={value} size={300} imageSettings={qrImageSettings} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
}, [toaster, value, showModal])
|
}, [toaster, value, showModal])
|
||||||
|
@ -1121,10 +1124,9 @@ function QrPassword ({ value }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PasswordScanner ({ onDecode }) {
|
function PasswordScanner ({ onScan }) {
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const ref = useRef(false)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputGroup.Text
|
<InputGroup.Text
|
||||||
|
@ -1132,20 +1134,24 @@ function PasswordScanner ({ onDecode }) {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
showModal(onClose => {
|
showModal(onClose => {
|
||||||
return (
|
return (
|
||||||
<QrScanner
|
<Scanner
|
||||||
onDecode={(decoded) => {
|
formats={['qr_code']}
|
||||||
onDecode(decoded)
|
onScan={([{ rawValue: result }]) => {
|
||||||
|
onScan(result)
|
||||||
// avoid accidentally calling onClose multiple times
|
onClose()
|
||||||
if (ref?.current) return
|
}}
|
||||||
ref.current = true
|
styles={{
|
||||||
|
video: {
|
||||||
onClose({ back: 1 })
|
aspectRatio: '1 / 1'
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onError={(error) => {
|
onError={(error) => {
|
||||||
if (error instanceof DOMException) return
|
if (error instanceof DOMException) {
|
||||||
toaster.danger('qr scan error:', error.message || error.toString?.())
|
console.log(error)
|
||||||
onClose({ back: 1 })
|
} else {
|
||||||
|
toaster.danger('qr scan: ' + error?.message || error?.toString?.())
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
@ -1159,9 +1165,9 @@ function PasswordScanner ({ onDecode }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PasswordInput ({ newPass, qr, copy, readOnly, append, ...props }) {
|
export function PasswordInput ({ newPass, qr, copy, readOnly, append, value, ...props }) {
|
||||||
const [showPass, setShowPass] = useState(false)
|
const [showPass, setShowPass] = useState(false)
|
||||||
const [field] = useField(props)
|
const [field, helpers] = props.noForm ? [{ value }, {}, {}] : useField(props)
|
||||||
|
|
||||||
const Append = useMemo(() => {
|
const Append = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
|
@ -1173,12 +1179,7 @@ export function PasswordInput ({ newPass, qr, copy, readOnly, append, ...props }
|
||||||
{qr && (readOnly
|
{qr && (readOnly
|
||||||
? <QrPassword value={field?.value} />
|
? <QrPassword value={field?.value} />
|
||||||
: <PasswordScanner
|
: <PasswordScanner
|
||||||
onDecode={decoded => {
|
onScan={v => helpers.setValue(v)}
|
||||||
// Formik helpers don't seem to work in another modal.
|
|
||||||
// I assume it's because we unmount the Formik component
|
|
||||||
// when replace it with another modal.
|
|
||||||
window.localStorage.setItem('qr:passphrase', decoded)
|
|
||||||
}}
|
|
||||||
/>)}
|
/>)}
|
||||||
{append}
|
{append}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -5,6 +5,15 @@ import { useEffect } from 'react'
|
||||||
import { useWallet } from '@/wallets/index'
|
import { useWallet } from '@/wallets/index'
|
||||||
import Bolt11Info from './bolt11-info'
|
import Bolt11Info from './bolt11-info'
|
||||||
|
|
||||||
|
export const qrImageSettings = {
|
||||||
|
src: 'data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 256 256\'%3E%3Cpath fill-rule=\'evenodd\' d=\'m46.7 96.4 37.858 53.837-71.787 62.934L117.5 155.4l-40.075-52.854 49.412-59.492Zm156.35 41.546-49.416-58.509-34.909 116.771 44.25-67.358 58.509 59.25L241.4 47.725Z\'/%3E%3C/svg%3E',
|
||||||
|
x: undefined,
|
||||||
|
y: undefined,
|
||||||
|
height: 60,
|
||||||
|
width: 60,
|
||||||
|
excavate: true
|
||||||
|
}
|
||||||
|
|
||||||
export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) {
|
export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) {
|
||||||
const qrValue = asIs ? value : 'lightning:' + value.toUpperCase()
|
const qrValue = asIs ? value : 'lightning:' + value.toUpperCase()
|
||||||
const wallet = useWallet()
|
const wallet = useWallet()
|
||||||
|
@ -26,14 +35,7 @@ export default function Qr ({ asIs, value, useWallet: automated, statusVariant,
|
||||||
<>
|
<>
|
||||||
<a className='d-block p-3 mx-auto' style={{ background: 'white', maxWidth: '300px' }} href={qrValue}>
|
<a className='d-block p-3 mx-auto' style={{ background: 'white', maxWidth: '300px' }} href={qrValue}>
|
||||||
<QRCodeSVG
|
<QRCodeSVG
|
||||||
className='h-auto mw-100' value={qrValue} size={300} imageSettings={{
|
className='h-auto mw-100' value={qrValue} size={300} imageSettings={qrImageSettings}
|
||||||
src: 'data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 256 256\'%3E%3Cpath fill-rule=\'evenodd\' d=\'m46.7 96.4 37.858 53.837-71.787 62.934L117.5 155.4l-40.075-52.854 49.412-59.492Zm156.35 41.546-49.416-58.509-34.909 116.771 44.25-67.358 58.509 59.25L241.4 47.725Z\'/%3E%3C/svg%3E',
|
|
||||||
x: undefined,
|
|
||||||
y: undefined,
|
|
||||||
height: 60,
|
|
||||||
width: 60,
|
|
||||||
excavate: true
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
{description && <div className='mt-1 text-center text-muted'>{description}</div>}
|
{description && <div className='mt-1 text-center text-muted'>{description}</div>}
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { E_VAULT_KEY_EXISTS } from '@/lib/error'
|
import { E_VAULT_KEY_EXISTS } from '@/lib/error'
|
||||||
import { CLEAR_VAULT, GET_VAULT_ENTRIES, UPDATE_VAULT_KEY } from '@/fragments/vault'
|
import { CLEAR_VAULT, GET_VAULT_ENTRIES, UPDATE_VAULT_KEY } from '@/fragments/vault'
|
||||||
import { toHex } from '@/lib/hex'
|
import { toHex } from '@/lib/hex'
|
||||||
import { decryptData, encryptData } from './use-vault'
|
import { decryptValue, encryptValue } from './use-vault'
|
||||||
|
|
||||||
const useImperativeQuery = (query) => {
|
const useImperativeQuery = (query) => {
|
||||||
const { refetch } = useQuery(query, { skip: true })
|
const { refetch } = useQuery(query, { skip: true })
|
||||||
|
@ -21,7 +21,7 @@ const useImperativeQuery = (query) => {
|
||||||
export function useVaultConfigurator () {
|
export function useVaultConfigurator () {
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const idbConfig = useMemo(() => ({ dbName: getDbName(me?.id, 'vault'), storeName: 'vault' }), [me?.id])
|
const idbConfig = useMemo(() => ({ dbName: getDbName(me?.id, 'vault'), storeName: 'vault', options: {} }), [me?.id])
|
||||||
const { set, get, remove } = useIndexedDB(idbConfig)
|
const { set, get, remove } = useIndexedDB(idbConfig)
|
||||||
const [updateVaultKey] = useMutation(UPDATE_VAULT_KEY)
|
const [updateVaultKey] = useMutation(UPDATE_VAULT_KEY)
|
||||||
const getVaultEntries = useImperativeQuery(GET_VAULT_ENTRIES)
|
const getVaultEntries = useImperativeQuery(GET_VAULT_ENTRIES)
|
||||||
|
@ -47,7 +47,7 @@ export function useVaultConfigurator () {
|
||||||
// toaster?.danger('error loading vault configuration ' + e.message)
|
// toaster?.danger('error loading vault configuration ' + e.message)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}, [me?.privates?.vaultKeyHash, keyHash, get, remove])
|
}, [me?.privates?.vaultKeyHash, keyHash, get, remove, setKey])
|
||||||
|
|
||||||
// clear vault: remove everything and reset the key
|
// clear vault: remove everything and reset the key
|
||||||
const [clearVault] = useMutation(CLEAR_VAULT, {
|
const [clearVault] = useMutation(CLEAR_VAULT, {
|
||||||
|
@ -62,6 +62,12 @@ export function useVaultConfigurator () {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const disconnectVault = useCallback(async () => {
|
||||||
|
await remove('key')
|
||||||
|
setKey(null)
|
||||||
|
setKeyHash(null)
|
||||||
|
}, [remove, setKey, setKeyHash])
|
||||||
|
|
||||||
// initialize the vault and set a vault key
|
// initialize the vault and set a vault key
|
||||||
const setVaultKey = useCallback(async (passphrase) => {
|
const setVaultKey = useCallback(async (passphrase) => {
|
||||||
try {
|
try {
|
||||||
|
@ -69,10 +75,12 @@ export function useVaultConfigurator () {
|
||||||
const vaultKey = await deriveKey(me.id, passphrase)
|
const vaultKey = await deriveKey(me.id, passphrase)
|
||||||
const { data } = await getVaultEntries()
|
const { data } = await getVaultEntries()
|
||||||
|
|
||||||
|
// TODO: push any local configurations to the server so long as they don't conflict
|
||||||
|
// delete any locally stored configurations after vault key is set
|
||||||
const entries = []
|
const entries = []
|
||||||
for (const entry of data.getVaultEntries) {
|
for (const { key, iv, value } of data.getVaultEntries) {
|
||||||
entry.value = await decryptData(oldKeyValue.key, entry.value)
|
const plainValue = await decryptValue(oldKeyValue.key, { iv, value })
|
||||||
entries.push({ key: entry.key, value: await encryptData(vaultKey.key, entry.value) })
|
entries.push({ key, ...await encryptValue(vaultKey.key, plainValue) })
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateVaultKey({
|
await updateVaultKey({
|
||||||
|
@ -89,11 +97,11 @@ export function useVaultConfigurator () {
|
||||||
setKeyHash(vaultKey.hash)
|
setKeyHash(vaultKey.hash)
|
||||||
await set('key', vaultKey)
|
await set('key', vaultKey)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toaster.danger('error setting vault key ' + e.message)
|
toaster.danger(e.message)
|
||||||
}
|
}
|
||||||
}, [getVaultEntries, updateVaultKey, set, get, remove])
|
}, [getVaultEntries, updateVaultKey, set, get, remove])
|
||||||
|
|
||||||
return [key, setVaultKey, clearVault]
|
return { key, setVaultKey, clearVault, disconnectVault }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -7,12 +7,12 @@ export default function useVault () {
|
||||||
|
|
||||||
const encrypt = useCallback(async (value) => {
|
const encrypt = useCallback(async (value) => {
|
||||||
if (!key) throw new Error('no vault key set')
|
if (!key) throw new Error('no vault key set')
|
||||||
return await encryptData(key.key, value)
|
return await encryptValue(key.key, value)
|
||||||
}, [key])
|
}, [key])
|
||||||
|
|
||||||
const decrypt = useCallback(async (value) => {
|
const decrypt = useCallback(async ({ iv, value }) => {
|
||||||
if (!key) throw new Error('no vault key set')
|
if (!key) throw new Error('no vault key set')
|
||||||
return await decryptData(key.key, value)
|
return await decryptValue(key.key, { iv, value })
|
||||||
}, [key])
|
}, [key])
|
||||||
|
|
||||||
return { encrypt, decrypt, isActive: !!key }
|
return { encrypt, decrypt, isActive: !!key }
|
||||||
|
@ -21,14 +21,15 @@ export default function useVault () {
|
||||||
/**
|
/**
|
||||||
* Encrypt data using AES-GCM
|
* Encrypt data using AES-GCM
|
||||||
* @param {CryptoKey} sharedKey - the key to use for encryption
|
* @param {CryptoKey} sharedKey - the key to use for encryption
|
||||||
* @param {Object} data - the data to encrypt
|
* @param {Object} value - the value to encrypt
|
||||||
* @returns {Promise<string>} a string representing the encrypted data, can be passed to decryptData to get the original data back
|
* @returns {Promise<Object>} an object with iv and value properties, can be passed to decryptValue to get the original data back
|
||||||
*/
|
*/
|
||||||
export async function encryptData (sharedKey, data) {
|
export async function encryptValue (sharedKey, value) {
|
||||||
// random IVs are _really_ important in GCM: reusing the IV once can lead to catastrophic failure
|
// random IVs are _really_ important in GCM: reusing the IV once can lead to catastrophic failure
|
||||||
// see https://crypto.stackexchange.com/questions/26790/how-bad-it-is-using-the-same-iv-twice-with-aes-gcm
|
// see https://crypto.stackexchange.com/questions/26790/how-bad-it-is-using-the-same-iv-twice-with-aes-gcm
|
||||||
|
// 12 bytes (96 bits) is the recommended IV size for AES-GCM
|
||||||
const iv = window.crypto.getRandomValues(new Uint8Array(12))
|
const iv = window.crypto.getRandomValues(new Uint8Array(12))
|
||||||
const encoded = new TextEncoder().encode(JSON.stringify(data))
|
const encoded = new TextEncoder().encode(JSON.stringify(value))
|
||||||
const encrypted = await window.crypto.subtle.encrypt(
|
const encrypted = await window.crypto.subtle.encrypt(
|
||||||
{
|
{
|
||||||
name: 'AES-GCM',
|
name: 'AES-GCM',
|
||||||
|
@ -37,27 +38,26 @@ export async function encryptData (sharedKey, data) {
|
||||||
sharedKey,
|
sharedKey,
|
||||||
encoded
|
encoded
|
||||||
)
|
)
|
||||||
return JSON.stringify({
|
return {
|
||||||
iv: toHex(iv.buffer),
|
iv: toHex(iv.buffer),
|
||||||
data: toHex(encrypted)
|
value: toHex(encrypted)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt data using AES-GCM
|
* Decrypt data using AES-GCM
|
||||||
* @param {CryptoKey} sharedKey - the key to use for decryption
|
* @param {CryptoKey} sharedKey - the key to use for decryption
|
||||||
* @param {string} encryptedData - the encrypted data as returned by encryptData
|
* @param {Object} encryptedValue - the encrypted value as returned by encryptValue
|
||||||
* @returns {Promise<Object>} the original unencrypted data
|
* @returns {Promise<Object>} the original unencrypted data
|
||||||
*/
|
*/
|
||||||
export async function decryptData (sharedKey, encryptedData) {
|
export async function decryptValue (sharedKey, { iv, value }) {
|
||||||
const { iv, data } = JSON.parse(encryptedData)
|
|
||||||
const decrypted = await window.crypto.subtle.decrypt(
|
const decrypted = await window.crypto.subtle.decrypt(
|
||||||
{
|
{
|
||||||
name: 'AES-GCM',
|
name: 'AES-GCM',
|
||||||
iv: fromHex(iv)
|
iv: fromHex(iv)
|
||||||
},
|
},
|
||||||
sharedKey,
|
sharedKey,
|
||||||
fromHex(data)
|
fromHex(value)
|
||||||
)
|
)
|
||||||
const decoded = new TextDecoder().decode(decrypted)
|
const decoded = new TextDecoder().decode(decrypted)
|
||||||
return JSON.parse(decoded)
|
return JSON.parse(decoded)
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { gql } from '@apollo/client'
|
import { gql } from '@apollo/client'
|
||||||
|
|
||||||
export const VAULT_FIELDS = gql`
|
export const VAULT_ENTRY_FIELDS = gql`
|
||||||
fragment VaultFields on Vault {
|
fragment VaultEntryFields on VaultEntry {
|
||||||
id
|
id
|
||||||
key
|
key
|
||||||
|
iv
|
||||||
value
|
value
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
|
@ -11,21 +12,21 @@ export const VAULT_FIELDS = gql`
|
||||||
`
|
`
|
||||||
|
|
||||||
export const GET_VAULT_ENTRY = gql`
|
export const GET_VAULT_ENTRY = gql`
|
||||||
${VAULT_FIELDS}
|
${VAULT_ENTRY_FIELDS}
|
||||||
query GetVaultEntry(
|
query GetVaultEntry(
|
||||||
$key: String!
|
$key: String!
|
||||||
) {
|
) {
|
||||||
getVaultEntry(key: $key) {
|
getVaultEntry(key: $key) {
|
||||||
...VaultFields
|
...VaultEntryFields
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
export const GET_VAULT_ENTRIES = gql`
|
export const GET_VAULT_ENTRIES = gql`
|
||||||
${VAULT_FIELDS}
|
${VAULT_ENTRY_FIELDS}
|
||||||
query GetVaultEntries {
|
query GetVaultEntries {
|
||||||
getVaultEntries {
|
getVaultEntries {
|
||||||
...VaultFields
|
...VaultEntryFields
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { gql } from '@apollo/client'
|
import { gql } from '@apollo/client'
|
||||||
import { ITEM_FULL_FIELDS } from './items'
|
import { ITEM_FULL_FIELDS } from './items'
|
||||||
|
import { VAULT_ENTRY_FIELDS } from './vault'
|
||||||
|
|
||||||
export const INVOICE_FIELDS = gql`
|
export const INVOICE_FIELDS = gql`
|
||||||
fragment InvoiceFields on Invoice {
|
fragment InvoiceFields on Invoice {
|
||||||
|
@ -108,6 +109,7 @@ mutation removeWallet($id: ID!) {
|
||||||
`
|
`
|
||||||
// XXX [WALLET] this needs to be updated if another server wallet is added
|
// XXX [WALLET] this needs to be updated if another server wallet is added
|
||||||
export const WALLET_FIELDS = gql`
|
export const WALLET_FIELDS = gql`
|
||||||
|
${VAULT_ENTRY_FIELDS}
|
||||||
fragment WalletFields on Wallet {
|
fragment WalletFields on Wallet {
|
||||||
id
|
id
|
||||||
priority
|
priority
|
||||||
|
@ -115,8 +117,7 @@ export const WALLET_FIELDS = gql`
|
||||||
updatedAt
|
updatedAt
|
||||||
enabled
|
enabled
|
||||||
vaultEntries {
|
vaultEntries {
|
||||||
key
|
...VaultEntryFields
|
||||||
value
|
|
||||||
}
|
}
|
||||||
wallet {
|
wallet {
|
||||||
__typename
|
__typename
|
||||||
|
|
|
@ -193,6 +193,12 @@ export const autowithdrawSchemaMembers = object({
|
||||||
autoWithdrawMaxFeeTotal: intValidator.required('required').min(0, 'must be at least 0').max(1_000, 'must not exceed 1000').transform(Number)
|
autoWithdrawMaxFeeTotal: intValidator.required('required').min(0, 'must be at least 0').max(1_000, 'must not exceed 1000').transform(Number)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const vaultEntrySchema = key => object({
|
||||||
|
key: string().required('required').matches(key, `expected ${key}`),
|
||||||
|
iv: string().required('required').hex().length(24, 'must be 24 characters long'),
|
||||||
|
value: string().required('required').hex().min(2).max(1024 * 10)
|
||||||
|
})
|
||||||
|
|
||||||
export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) =>
|
export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) =>
|
||||||
object({
|
object({
|
||||||
addr: lightningAddressValidator.required('required'),
|
addr: lightningAddressValidator.required('required'),
|
||||||
|
|
27
lib/yup.js
27
lib/yup.js
|
@ -1,7 +1,8 @@
|
||||||
import { addMethod, string, mixed } from 'yup'
|
import { addMethod, string, mixed, array } from 'yup'
|
||||||
import { parseNwcUrl } from './url'
|
import { parseNwcUrl } from './url'
|
||||||
import { NOSTR_PUBKEY_HEX } from './nostr'
|
import { NOSTR_PUBKEY_HEX } from './nostr'
|
||||||
import { ensureB64, HEX_REGEX } from './format'
|
import { ensureB64, HEX_REGEX } from './format'
|
||||||
|
export * from 'yup'
|
||||||
|
|
||||||
function orFunc (schemas, msg) {
|
function orFunc (schemas, msg) {
|
||||||
return this.test({
|
return this.test({
|
||||||
|
@ -164,4 +165,26 @@ addMethod(string, 'nwcUrl', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
export * from 'yup'
|
addMethod(array, 'equalto', function equals (
|
||||||
|
schemas,
|
||||||
|
message
|
||||||
|
) {
|
||||||
|
return this.test({
|
||||||
|
name: 'equalto',
|
||||||
|
message: message || `${this.path} has invalid values`,
|
||||||
|
test: function (items) {
|
||||||
|
if (items.length !== schemas.length) {
|
||||||
|
return this.createError({ message: `Expected ${this.path} to be ${schemas.length} items, but got ${items.length}` })
|
||||||
|
}
|
||||||
|
const remainingSchemas = [...schemas]
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const index = remainingSchemas.findIndex(schema => schema.isValidSync(items[i], { strict: true }))
|
||||||
|
if (index === -1) {
|
||||||
|
return this.createError({ message: `${this.path}[${i}] has invalid value` })
|
||||||
|
}
|
||||||
|
remainingSchemas.splice(index, 1)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
@ -78,6 +78,11 @@ export function SettingsHeader () {
|
||||||
<Nav.Link eventKey='mutes'>muted stackers</Nav.Link>
|
<Nav.Link eventKey='mutes'>muted stackers</Nav.Link>
|
||||||
</Link>
|
</Link>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
|
<Nav.Item>
|
||||||
|
<Link href='/settings/passphrase' passHref legacyBehavior>
|
||||||
|
<Nav.Link eventKey='passphrase'>device sync</Nav.Link>
|
||||||
|
</Link>
|
||||||
|
</Nav.Item>
|
||||||
</Nav>
|
</Nav>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||||
|
import Layout from '@/components/layout'
|
||||||
|
import { SettingsHeader } from '../index'
|
||||||
|
import { useVaultConfigurator } from '@/components/vault/use-vault-configurator'
|
||||||
|
import { useMe } from '@/components/me'
|
||||||
|
import { Button, InputGroup } from 'react-bootstrap'
|
||||||
|
import bip39Words from '@/lib/bip39-words'
|
||||||
|
import { Form, PasswordInput, SubmitButton } from '@/components/form'
|
||||||
|
import { deviceSyncSchema } from '@/lib/validate'
|
||||||
|
import RefreshIcon from '@/svgs/refresh-line.svg'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useToast } from '@/components/toast'
|
||||||
|
|
||||||
|
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
||||||
|
|
||||||
|
export default function DeviceSync ({ ssrData }) {
|
||||||
|
const { me } = useMe()
|
||||||
|
const { key, setVaultKey, clearVault, disconnectVault } = useVaultConfigurator()
|
||||||
|
const [passphrase, setPassphrase] = useState()
|
||||||
|
|
||||||
|
const setSeedPassphrase = useCallback(async (passphrase) => {
|
||||||
|
await setVaultKey(passphrase)
|
||||||
|
setPassphrase(passphrase)
|
||||||
|
}, [setVaultKey])
|
||||||
|
|
||||||
|
const enabled = !!me?.privates?.vaultKeyHash
|
||||||
|
const connected = !!key
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className='pb-3 w-100 mt-2'>
|
||||||
|
<SettingsHeader />
|
||||||
|
<div className='mt-3' style={{ maxWidth: '600px' }}>
|
||||||
|
{
|
||||||
|
(connected && passphrase && <Connect passphrase={passphrase} />) ||
|
||||||
|
(connected && <Connected disconnectVault={disconnectVault} />) ||
|
||||||
|
(enabled && <Enabled setVaultKey={setVaultKey} clearVault={clearVault} />) ||
|
||||||
|
<Setup setSeedPassphrase={setSeedPassphrase} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Connect ({ passphrase }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Connect other devices</h2>
|
||||||
|
<p className='line-height-md'>
|
||||||
|
On your other devices, navigate to device sync settings and enter this exact passphrase.
|
||||||
|
</p>
|
||||||
|
<p className='line-height-md'>
|
||||||
|
Once you leave this page, this passphrase cannot be shown again. Connect all the devices you plan to use or write this passphrase down somewhere safe.
|
||||||
|
</p>
|
||||||
|
<PasswordInput
|
||||||
|
label='passphrase'
|
||||||
|
name='passphrase'
|
||||||
|
placeholder=''
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
as='textarea'
|
||||||
|
value={passphrase}
|
||||||
|
noForm
|
||||||
|
rows={3}
|
||||||
|
readOnly
|
||||||
|
copy
|
||||||
|
qr
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Connected ({ disconnectVault }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Device sync is enabled!</h2>
|
||||||
|
<p>
|
||||||
|
Sensitive data on this device is now securely synced between all connected devices.
|
||||||
|
</p>
|
||||||
|
<p className='text-muted text-sm'>
|
||||||
|
Disconnect to prevent this device from syncing data or to reset your passphrase.
|
||||||
|
</p>
|
||||||
|
<div className='d-flex justify-content-between'>
|
||||||
|
<div className='d-flex align-items-center ms-auto gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='primary'
|
||||||
|
onClick={disconnectVault}
|
||||||
|
>disconnect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Enabled ({ setVaultKey, clearVault }) {
|
||||||
|
const toaster = useToast()
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Device sync is enabled</h2>
|
||||||
|
<p className='line-height-md'>
|
||||||
|
This device is not connected. Enter or scan your passphrase to connect. If you've lost your passphrase you may reset it.
|
||||||
|
</p>
|
||||||
|
<Form
|
||||||
|
schema={deviceSyncSchema}
|
||||||
|
initial={{ passphrase: '' }}
|
||||||
|
enableReinitialize
|
||||||
|
onSubmit={async ({ passphrase }) => {
|
||||||
|
try {
|
||||||
|
await setVaultKey(passphrase)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
toaster.danger('error setting vault key')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
label='passphrase'
|
||||||
|
name='passphrase'
|
||||||
|
placeholder=''
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
as='textarea'
|
||||||
|
rows={3}
|
||||||
|
qr
|
||||||
|
/>
|
||||||
|
<div className='mt-3'>
|
||||||
|
<div className='d-flex justify-content-between align-items-center'>
|
||||||
|
<Button variant='danger' onClick={clearVault}>reset</Button>
|
||||||
|
<SubmitButton variant='primary'>enable</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatePassphrase = (n = 12) => {
|
||||||
|
const rand = new Uint32Array(n)
|
||||||
|
window.crypto.getRandomValues(rand)
|
||||||
|
return Array.from(rand).map(i => bip39Words[i % bip39Words.length]).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function Setup ({ setSeedPassphrase }) {
|
||||||
|
const [passphrase, setPassphrase] = useState()
|
||||||
|
const toaster = useToast()
|
||||||
|
const newPassphrase = useCallback(() => {
|
||||||
|
setPassphrase(() => generatePassphrase(12))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPassphrase(() => generatePassphrase(12))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Enable device sync</h2>
|
||||||
|
<p>
|
||||||
|
Enable secure sync of sensitive data (like wallet credentials) between your devices.
|
||||||
|
</p>
|
||||||
|
<p className='text-muted text-sm line-height-md'>
|
||||||
|
After enabled, your passphrase can be used to connect other devices.
|
||||||
|
</p>
|
||||||
|
<Form
|
||||||
|
schema={deviceSyncSchema}
|
||||||
|
initial={{ passphrase }}
|
||||||
|
enableReinitialize
|
||||||
|
onSubmit={async ({ passphrase }) => {
|
||||||
|
try {
|
||||||
|
await setSeedPassphrase(passphrase)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
toaster.danger('error setting passphrase')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
label='passphrase'
|
||||||
|
name='passphrase'
|
||||||
|
placeholder=''
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
as='textarea'
|
||||||
|
rows={3}
|
||||||
|
readOnly
|
||||||
|
append={
|
||||||
|
<InputGroup.Text style={{ cursor: 'pointer', userSelect: 'none' }} onClick={newPassphrase}>
|
||||||
|
<RefreshIcon width={16} height={16} />
|
||||||
|
</InputGroup.Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className='mt-3'>
|
||||||
|
<div className='d-flex justify-content-between'>
|
||||||
|
<div className='d-flex align-items-center ms-auto gap-2'>
|
||||||
|
<SubmitButton variant='primary'>enable</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -52,7 +52,7 @@ export default function WalletSettings () {
|
||||||
|
|
||||||
const validate = useCallback(async (data) => {
|
const validate = useCallback(async (data) => {
|
||||||
try {
|
try {
|
||||||
await validateWallet(wallet.def, data, { abortEarly: false, topLevel: false })
|
await validateWallet(wallet.def, data, { yupOptions: { abortEarly: false }, topLevel: false })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ValidationError) {
|
if (error instanceof ValidationError) {
|
||||||
return error.inner.reduce((acc, error) => {
|
return error.inner.reduce((acc, error) => {
|
||||||
|
|
|
@ -23,7 +23,7 @@ export function useWalletConfigurator (wallet) {
|
||||||
if (clientOnly && isActive) {
|
if (clientOnly && isActive) {
|
||||||
for (const [key, value] of Object.entries(clientOnly)) {
|
for (const [key, value] of Object.entries(clientOnly)) {
|
||||||
if (value) {
|
if (value) {
|
||||||
vaultEntries.push({ key, value: encrypt(value) })
|
vaultEntries.push({ key, ...await encrypt(value) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,10 +41,11 @@ function useLocalWallets () {
|
||||||
const walletDefsOnly = walletDefs.map(w => ({ def: w, config: {} }))
|
const walletDefsOnly = walletDefs.map(w => ({ def: w, config: {} }))
|
||||||
|
|
||||||
export function WalletsProvider ({ children }) {
|
export function WalletsProvider ({ children }) {
|
||||||
const { decrypt } = useVault()
|
const { isActive, decrypt } = useVault()
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
const { wallets: localWallets, reloadLocalWallets } = useLocalWallets()
|
const { wallets: localWallets, reloadLocalWallets } = useLocalWallets()
|
||||||
const [setWalletPriority] = useMutation(SET_WALLET_PRIORITY)
|
const [setWalletPriority] = useMutation(SET_WALLET_PRIORITY)
|
||||||
|
const [serverWallets, setServerWallets] = useState([])
|
||||||
|
|
||||||
const { data, refetch } = useQuery(WALLETS,
|
const { data, refetch } = useQuery(WALLETS,
|
||||||
SSR ? {} : { nextFetchPolicy: 'cache-and-network' })
|
SSR ? {} : { nextFetchPolicy: 'cache-and-network' })
|
||||||
|
@ -56,23 +57,37 @@ export function WalletsProvider ({ children }) {
|
||||||
}
|
}
|
||||||
}, [me?.privates?.walletsUpdatedAt, me?.privates?.vaultKeyHash, refetch])
|
}, [me?.privates?.walletsUpdatedAt, me?.privates?.vaultKeyHash, refetch])
|
||||||
|
|
||||||
const wallets = useMemo(() => {
|
useEffect(() => {
|
||||||
// form wallets into a list of { config, def }
|
async function loadWallets () {
|
||||||
const wallets = data?.wallets?.map(w => {
|
if (!data?.wallets) return
|
||||||
const def = getWalletByType(w.type)
|
// form wallets into a list of { config, def }
|
||||||
const { vaultEntries, ...config } = w
|
const wallets = []
|
||||||
for (const { key, value } of vaultEntries) {
|
for (const w of data.wallets) {
|
||||||
config[key] = decrypt(value)
|
const def = getWalletByType(w.type)
|
||||||
|
const { vaultEntries, ...config } = w
|
||||||
|
if (isActive) {
|
||||||
|
for (const { key, iv, value } of vaultEntries) {
|
||||||
|
try {
|
||||||
|
config[key] = await decrypt({ iv, value })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('error decrypting vault entry', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the specific wallet config on the server is stored in wallet.wallet
|
||||||
|
// on the client, it's stored unnested
|
||||||
|
wallets.push({ config: { ...config, ...w.wallet }, def })
|
||||||
}
|
}
|
||||||
|
setServerWallets(wallets)
|
||||||
|
}
|
||||||
|
loadWallets()
|
||||||
|
}, [data?.wallets, decrypt, isActive])
|
||||||
|
|
||||||
// the specific wallet config on the server is stored in wallet.wallet
|
// merge wallets on name like: { ...unconfigured, ...localConfig, ...serverConfig }
|
||||||
// on the client, it's stored unnested
|
const wallets = useMemo(() => {
|
||||||
return { config: { ...config, ...w.wallet }, def }
|
|
||||||
}) ?? []
|
|
||||||
|
|
||||||
// merge wallets on name like: { ...unconfigured, ...localConfig, ...serverConfig }
|
|
||||||
const merged = {}
|
const merged = {}
|
||||||
for (const wallet of [...walletDefsOnly, ...localWallets, ...wallets]) {
|
for (const wallet of [...walletDefsOnly, ...localWallets, ...serverWallets]) {
|
||||||
merged[wallet.def.name] = {
|
merged[wallet.def.name] = {
|
||||||
def: wallet.def,
|
def: wallet.def,
|
||||||
config: {
|
config: {
|
||||||
|
@ -91,7 +106,7 @@ export function WalletsProvider ({ children }) {
|
||||||
return Object.values(merged)
|
return Object.values(merged)
|
||||||
.sort(walletPrioritySort)
|
.sort(walletPrioritySort)
|
||||||
.map(w => ({ ...w, status: w.config?.enabled ? Status.Enabled : Status.Disabled }))
|
.map(w => ({ ...w, status: w.config?.enabled ? Status.Enabled : Status.Disabled }))
|
||||||
}, [data?.wallets, localWallets])
|
}, [serverWallets, localWallets])
|
||||||
|
|
||||||
const setPriorities = useCallback(async (priorities) => {
|
const setPriorities = useCallback(async (priorities) => {
|
||||||
for (const { wallet, priority } of priorities) {
|
for (const { wallet, priority } of priorities) {
|
||||||
|
|
|
@ -7,21 +7,22 @@
|
||||||
- a regular expression that must match
|
- a regular expression that must match
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { autowithdrawSchemaMembers } from '@/lib/validate'
|
import { autowithdrawSchemaMembers, vaultEntrySchema } from '@/lib/validate'
|
||||||
import * as Yup from '@/lib/yup'
|
import * as Yup from '@/lib/yup'
|
||||||
import { canReceive } from './common'
|
import { canReceive } from './common'
|
||||||
|
|
||||||
export default async function validateWallet (walletDef, data, options = { abortEarly: true, topLevel: true }) {
|
export default async function validateWallet (walletDef, data,
|
||||||
let schema = composeWalletSchema(walletDef)
|
{ yupOptions = { abortEarly: true }, topLevel = true, serverSide = false } = {}) {
|
||||||
|
let schema = composeWalletSchema(walletDef, serverSide)
|
||||||
|
|
||||||
if (canReceive({ def: walletDef, config: data })) {
|
if (canReceive({ def: walletDef, config: data })) {
|
||||||
schema = schema.concat(autowithdrawSchemaMembers)
|
schema = schema.concat(autowithdrawSchemaMembers)
|
||||||
}
|
}
|
||||||
|
|
||||||
await schema.validate(data, options)
|
await schema.validate(data, yupOptions)
|
||||||
|
|
||||||
const casted = schema.cast(data, { assert: false })
|
const casted = schema.cast(data, { assert: false })
|
||||||
if (options.topLevel && walletDef.validate) {
|
if (topLevel && walletDef.validate) {
|
||||||
await walletDef.validate(casted)
|
await walletDef.validate(casted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,29 +58,40 @@ function createFieldSchema (name, validate) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function composeWalletSchema (walletDef) {
|
function composeWalletSchema (walletDef, serverSide) {
|
||||||
const { fields } = walletDef
|
const { fields } = walletDef
|
||||||
|
|
||||||
|
const vaultEntrySchemas = []
|
||||||
const schemaShape = fields.reduce((acc, field) => {
|
const schemaShape = fields.reduce((acc, field) => {
|
||||||
const { name, validate, optional, requiredWithout } = field
|
const { name, validate, optional, clientOnly, requiredWithout } = field
|
||||||
|
|
||||||
acc[name] = createFieldSchema(name, validate)
|
if (clientOnly && serverSide) {
|
||||||
|
// For server-side validation, accumulate clientOnly fields as vaultEntries
|
||||||
|
vaultEntrySchemas.push(vaultEntrySchema(name))
|
||||||
|
} else {
|
||||||
|
acc[name] = createFieldSchema(name, validate)
|
||||||
|
|
||||||
if (!optional) {
|
if (!optional) {
|
||||||
acc[name] = acc[name].required('Required')
|
acc[name] = acc[name].required('Required')
|
||||||
} else if (requiredWithout) {
|
} else if (requiredWithout) {
|
||||||
acc[name] = acc[name].when([requiredWithout], ([pairSetting], schema) => {
|
acc[name] = acc[name].when([requiredWithout], ([pairSetting], schema) => {
|
||||||
if (!pairSetting) return schema.required(`required if ${requiredWithout} not set`)
|
if (!pairSetting) return schema.required(`required if ${requiredWithout} not set`)
|
||||||
return Yup.mixed().or([schema.test({
|
return Yup.mixed().or([schema.test({
|
||||||
test: value => value !== pairSetting,
|
test: value => value !== pairSetting,
|
||||||
message: `${name} cannot be the same as ${requiredWithout}`
|
message: `${name} cannot be the same as ${requiredWithout}`
|
||||||
}), Yup.mixed().notRequired()])
|
}), Yup.mixed().notRequired()])
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
|
|
||||||
|
// Finalize the vaultEntries schema if it exists
|
||||||
|
if (vaultEntrySchemas.length > 0) {
|
||||||
|
schemaShape.vaultEntries = Yup.array().equalto(vaultEntrySchemas)
|
||||||
|
}
|
||||||
|
|
||||||
// we use Object.keys(schemaShape).reverse() to avoid cyclic dependencies in Yup schema
|
// we use Object.keys(schemaShape).reverse() to avoid cyclic dependencies in Yup schema
|
||||||
// see https://github.com/jquense/yup/issues/176#issuecomment-367352042
|
// see https://github.com/jquense/yup/issues/176#issuecomment-367352042
|
||||||
const composedSchema = Yup.object().shape(schemaShape, Object.keys(schemaShape).reverse()).concat(Yup.object({
|
const composedSchema = Yup.object().shape(schemaShape, Object.keys(schemaShape).reverse()).concat(Yup.object({
|
||||||
|
|
|
@ -3,7 +3,7 @@ export const walletType = 'WEBLN'
|
||||||
export const walletField = 'walletWebLN'
|
export const walletField = 'walletWebLN'
|
||||||
|
|
||||||
export const validate = ({ enabled }) => {
|
export const validate = ({ enabled }) => {
|
||||||
if (enabled && typeof window?.webln === 'undefined') {
|
if (enabled && typeof window !== 'undefined' && !window?.webln) {
|
||||||
throw new Error('no WebLN provider found')
|
throw new Error('no WebLN provider found')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue