diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 20b327a6..d3d2cea6 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -139,8 +139,7 @@ const typeDefs = gql` ): Boolean! # delete - removeWallet(id: ID!): Boolean - removeWalletProtocol(id: ID!): Boolean + deleteWallet(id: ID!): Boolean # crypto updateWalletEncryption(keyHash: String!, wallets: [WalletEncryptionUpdate!]!): Boolean @@ -149,7 +148,7 @@ const typeDefs = gql` disablePassphraseExport: Boolean # settings - setWalletSettings(settings: WalletSettingsInput!): Boolean + setWalletSettings(settings: WalletSettingsInput!): WalletSettings! setWalletPriorities(priorities: [WalletPriorityUpdate!]!): Boolean # logs diff --git a/components/multi-step-form.js b/components/multi-step-form.js new file mode 100644 index 00000000..c3ad2f5b --- /dev/null +++ b/components/multi-step-form.js @@ -0,0 +1,223 @@ +import { createContext, Fragment, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import classNames from 'classnames' +import { useRouter } from 'next/router' + +const MultiStepFormContext = createContext() + +export function MultiStepForm ({ children, initial, steps }) { + const [stepIndex, setStepIndex] = useState(0) + const [formState, setFormState] = useState({}) + const router = useRouter() + + useEffect(() => { + // initial state might not be available on first render so we sync changes + if (initial) setFormState(initial) + }, [initial]) + + useEffect(() => { + const idx = Math.max(0, steps.indexOf(router.query.step)) + setStepIndex(idx) + router.replace({ + pathname: router.pathname, + query: { type: router.query.type, step: steps[idx] } + }, null, { shallow: true }) + }, [router.query.step, steps]) + + const next = useCallback(() => { + const idx = Math.min(stepIndex + 1, steps.length - 1) + router.push( + { pathname: router.pathname, query: { type: router.query.type, step: steps[idx] } }, + null, + { shallow: true } + ) + }, [stepIndex, steps, router]) + + const prev = useCallback(() => router.back(), [router]) + + const updateFormState = useCallback((id, state) => { + setFormState(formState => { + return id ? { ...formState, [id]: state } : state + }) + }, []) + + const value = useMemo( + () => ({ stepIndex, steps, next, prev, formState, updateFormState }), + [stepIndex, steps, next, prev, formState, updateFormState]) + return ( + + + {children[stepIndex]} + + ) +} + +function Progress () { + const steps = useSteps() + const stepIndex = useStepIndex() + + const style = (index) => { + switch (index) { + case 0: return { marginLeft: '-5px', marginRight: '-13px' } + case 1: return { marginLeft: '-13px', marginRight: '-15px' } + default: return {} + } + } + + return ( +
+ { + steps.map((label, i) => { + const last = i === steps.length - 1 + return ( + + = i} /> + {!last && = i + 1} />} + + ) + }) + } +
+ ) +} +function ProgressNumber ({ number, label, active }) { + return ( +
+ +
+ {label} +
+
+ ) +} + +const NUMBER_SVG_WIDTH = 24 +const NUMBER_SVG_HEIGHT = 24 + +function NumberSVG ({ number, active }) { + const width = NUMBER_SVG_WIDTH + const height = NUMBER_SVG_HEIGHT + + const Wrapper = ({ children }) => ( +
+ {children} +
+ ) + + const Circle = () => { + const circleProps = { + fill: active ? 'var(--bs-info)' : 'var(--bs-body-bg)', + stroke: active ? 'var(--bs-info)' : 'var(--theme-grey)' + } + return ( + + + + ) + } + + const Number = () => { + const svgProps = { + xmlns: 'http://www.w3.org/2000/svg', + viewBox: '0 0 24 24', + // we scale the number down and render it in the center of the circle + width: 0.5 * width, + height: 0.5 * height, + style: { position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' } + } + const numberColor = active ? 'var(--bs-white)' : 'var(--theme-grey)' + // svgs are from https://remixicon.com/icon/number-1 etc. + switch (number) { + case 1: + return ( + + + + ) + case 2: + return ( + + + + ) + case 3: + return ( + + + + ) + default: + return null + } + } + + return ( + + + + + ) +} + +function ProgressLine ({ style, active }) { + const svgStyle = { display: 'block', position: 'relative', top: `${NUMBER_SVG_HEIGHT / 2}px` } + return ( +
+ + + +
+ ) +} + +function useSteps () { + const { steps } = useContext(MultiStepFormContext) + return steps +} + +export function useStepIndex () { + const { stepIndex } = useContext(MultiStepFormContext) + return stepIndex +} + +export function useMaxSteps () { + const steps = useSteps() + return steps.length +} + +export function useStep () { + const stepIndex = useStepIndex() + const steps = useSteps() + return steps[stepIndex] +} + +export function useNext () { + const { next } = useContext(MultiStepFormContext) + return next +} + +export function usePrev () { + const { prev } = useContext(MultiStepFormContext) + return prev +} + +export function useFormState (id) { + const { formState, updateFormState } = useContext(MultiStepFormContext) + const setFormState = useCallback(state => updateFormState(id, state), [id, updateFormState]) + return useMemo( + () => [ + id ? formState[id] : formState, + setFormState + ], [formState, id, setFormState]) +} diff --git a/fragments/users.js b/fragments/users.js index 9e42fbfb..a1fa8cf7 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -131,11 +131,6 @@ export const SET_SETTINGS = gql` } }` -export const DELETE_WALLET = gql` - mutation removeWallet { - removeWallet - }` - export const NAME_QUERY = gql` query nameAvailable($name: String!) { nameAvailable(name: $name) diff --git a/pages/wallets/[...slug].js b/pages/wallets/[type].js similarity index 74% rename from pages/wallets/[...slug].js rename to pages/wallets/[type].js index d453545f..9b49b7c9 100644 --- a/pages/wallets/[...slug].js +++ b/pages/wallets/[type].js @@ -1,6 +1,6 @@ import { getGetServerSideProps } from '@/api/ssrApollo' import { useData } from '@/components/use-data' -import { WalletForms as WalletFormsComponent } from '@/wallets/client/components' +import { WalletMultiStepForm } from '@/wallets/client/components' import { WALLET } from '@/wallets/client/fragments' import { useDecryptedWallet } from '@/wallets/client/hooks' import { unurlify } from '@/wallets/lib/util' @@ -8,19 +8,19 @@ import { useQuery } from '@apollo/client' import { useRouter } from 'next/router' const variablesFunc = params => { - const id = Number(params.slug[0]) - return !Number.isNaN(id) ? { id } : { name: unurlify(params.slug[0]) } + const id = Number(params.type) + return !Number.isNaN(id) ? { id } : { name: unurlify(params.type) } } export const getServerSideProps = getGetServerSideProps({ query: WALLET, variables: variablesFunc, authRequired: true }) -export default function WalletForms ({ ssrData }) { +export default function Wallet ({ ssrData }) { const router = useRouter() const variables = variablesFunc(router.query) // this will print the following warning in the console: // Warning: fragment with name WalletTemplateFields already exists. // graphql-tag enforces all fragment names across your application to be unique // this is not a problem because the warning is only meant to avoid overwriting fragments but we're reusing it - const { data, refetch } = useQuery(WALLET, { variables }) + const { data } = useQuery(WALLET, { variables }) const dat = useData(data, ssrData) const decryptedWallet = useDecryptedWallet(dat?.wallet) @@ -29,5 +29,5 @@ export default function WalletForms ({ ssrData }) { return null } - return + return } diff --git a/pages/wallets/index.js b/pages/wallets/index.js index c36bbaf6..98bf14e1 100644 --- a/pages/wallets/index.js +++ b/pages/wallets/index.js @@ -113,8 +113,6 @@ export default function Wallet () { use real bitcoin
wallet logs - - settings {showPassphrase && ( <> diff --git a/pages/wallets/settings.js b/pages/wallets/settings.js deleted file mode 100644 index 629e71a5..00000000 --- a/pages/wallets/settings.js +++ /dev/null @@ -1,185 +0,0 @@ -import { getGetServerSideProps } from '@/api/ssrApollo' -import { Checkbox, Form, Input, SubmitButton } from '@/components/form' -import Info from '@/components/info' -import { isNumber } from '@/lib/format' -import { WalletLayout, WalletLayoutHeader, WalletLayoutSubHeader } from '@/wallets/client/components' -import { useMutation, useQuery } from '@apollo/client' -import Link from 'next/link' -import { useCallback, useMemo } from 'react' -import { InputGroup } from 'react-bootstrap' -import styles from '@/styles/wallet.module.css' -import classNames from 'classnames' -import { useField } from 'formik' -import { SET_WALLET_SETTINGS, WALLET_SETTINGS } from '@/wallets/client/fragments' -import { walletSettingsSchema } from '@/lib/validate' -import { useToast } from '@/components/toast' -import CancelButton from '@/components/cancel-button' - -export const getServerSideProps = getGetServerSideProps({ query: WALLET_SETTINGS, authRequired: true }) - -export default function WalletSettings ({ ssrData }) { - const { data } = useQuery(WALLET_SETTINGS) - const [setSettings] = useMutation(SET_WALLET_SETTINGS) - const { walletSettings: settings } = useMemo(() => data ?? ssrData, [data, ssrData]) - const toaster = useToast() - - const initial = { - receiveCreditsBelowSats: settings?.receiveCreditsBelowSats, - sendCreditsBelowSats: settings?.sendCreditsBelowSats, - autoWithdrawThreshold: settings?.autoWithdrawThreshold ?? 10000, - autoWithdrawMaxFeePercent: settings?.autoWithdrawMaxFeePercent ?? 1, - autoWithdrawMaxFeeTotal: settings?.autoWithdrawMaxFeeTotal ?? 1, - proxyReceive: settings?.proxyReceive - } - - const onSubmit = useCallback(async (values) => { - try { - await setSettings({ - variables: { - settings: values - } - }) - toaster.success('saved settings') - } catch (err) { - console.error(err) - toaster.danger('failed to save settings') - } - }, [toaster]) - - return ( - -
- wallet settings - apply globally to all wallets -
- - - - -
- - save -
- -
-
- ) -} - -function CowboyCreditsSettings () { - return ( - <> - cowboy credits - sats} - type='number' - min={0} - /> - sats} - type='number' - min={0} - /> - - - ) -} - -function LightningAddressSettings () { - return ( - <> - @stacker.news lightning address - enhance privacy of my lightning address - -
    -
  • Enabling this setting hides details (ie node pubkey) of your attached wallets when anyone pays your SN lightning address or lnurl-pay
  • -
  • The lightning invoice will appear to have SN's node as the destination to preserve your wallet's privacy
  • -
  • This will incur in a 10% fee
  • -
  • Disable this setting to receive payments directly to your attached wallets (which will reveal their details to the payer)
  • -
  • Note: this privacy behavior is standard for internal zaps/payments on SN, and this setting only applies to external payments
  • -
-
-
- } - name='proxyReceive' - groupClassName='mb-0' - /> - - ) -} - -function AutowithdrawSettings () { - const [{ value: threshold }] = useField('autoWithdrawThreshold') - const sendThreshold = Math.max(Math.floor(threshold / 10), 1) - - return ( - <> - autowithdrawal - sats} - required - type='number' - min={0} - /> - - - ) -} - -function LightningNetworkFeesSettings () { - return ( - <> - lightning network fees -
- we'll use whichever setting is higher during{' '} - pathfinding - -
- %} - required - type='number' - min={0} - /> - sats} - required - type='number' - min={0} - /> - - ) -} - -function Separator ({ children, className }) { - return ( -
{children}
- ) -} diff --git a/styles/globals.scss b/styles/globals.scss index fd470920..05eaaab6 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -782,6 +782,7 @@ div[contenteditable]:focus, fill: var(--theme-navLink); } +.nav-link:hover svg, .nav-link.active svg { fill: var(--theme-navLinkActive); } diff --git a/styles/wallet.module.css b/styles/wallet.module.css index a54f5156..9464633b 100644 --- a/styles/wallet.module.css +++ b/styles/wallet.module.css @@ -106,6 +106,12 @@ flex-direction: column; } +@media (max-width: 768px) { + .form { + margin-top: 2rem; + } +} + .separator { display: flex; align-items: center; diff --git a/svgs/arrow-left-s-fill.svg b/svgs/arrow-left-s-fill.svg new file mode 100644 index 00000000..57a3648c --- /dev/null +++ b/svgs/arrow-left-s-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svgs/arrow-left-s-line.svg b/svgs/arrow-left-s-line.svg new file mode 100644 index 00000000..eb6c6cc1 --- /dev/null +++ b/svgs/arrow-left-s-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wallets/client/components/card.js b/wallets/client/components/card.js index be8678f7..efe17522 100644 --- a/wallets/client/components/card.js +++ b/wallets/client/components/card.js @@ -7,7 +7,7 @@ import Link from 'next/link' import RecvIcon from '@/svgs/arrow-left-down-line.svg' import SendIcon from '@/svgs/arrow-right-up-line.svg' import DragIcon from '@/svgs/draggable.svg' -import { useWalletImage, useWalletSupport, useWalletStatus, WalletStatus, useProtocolTemplates } from '@/wallets/client/hooks' +import { useWalletImage, useWalletSupport, useWalletStatus, WalletStatus } from '@/wallets/client/hooks' import { isWallet, urlify, walletDisplayName } from '@/wallets/lib/util' import { Draggable } from '@/wallets/client/components' @@ -55,15 +55,7 @@ export function WalletCard ({ wallet, draggable = false, index, ...props }) { } function WalletLink ({ wallet, children }) { - const support = useWalletSupport(wallet) - const protocols = useProtocolTemplates(wallet) - const firstSend = protocols.find(p => p.send) - const firstRecv = protocols.find(p => !p.send) - - let href = '/wallets' - href += isWallet(wallet) ? `/${wallet.id}` : `/${urlify(wallet.name)}` - href += support.send ? `/send/${urlify(firstSend.name)}` : `/receive/${urlify(firstRecv.name)}` - + const href = '/wallets' + (isWallet(wallet) ? `/${wallet.id}` : `/${urlify(wallet.name)}`) return {children} } diff --git a/wallets/client/components/form/button.js b/wallets/client/components/form/button.js new file mode 100644 index 00000000..215bb6fb --- /dev/null +++ b/wallets/client/components/form/button.js @@ -0,0 +1,20 @@ +import classNames from 'classnames' +import { Button } from 'react-bootstrap' +import ArrowLeft from '@/svgs/arrow-left-s-fill.svg' + +import { usePrev, useNext } from '@/components/multi-step-form' + +export function BackButton ({ className }) { + const prev = usePrev() + return ( + + ) +} + +export function SkipButton ({ className }) { + const next = useNext() + return +} diff --git a/wallets/client/components/form/hooks.js b/wallets/client/components/form/hooks.js new file mode 100644 index 00000000..19023f18 --- /dev/null +++ b/wallets/client/components/form/hooks.js @@ -0,0 +1,136 @@ +import { isTemplate, isWallet, protocolClientSchema, protocolFields, protocolFormId, walletLud16Domain } from '@/wallets/lib/util' +import { createContext, useContext, useEffect, useMemo, useCallback, useState } from 'react' +import { useWalletProtocolUpsert } from '@/wallets/client/hooks' +import { MultiStepForm, useFormState, useStep } from '@/components/multi-step-form' + +export const Step = { + SEND: 'send', + RECEIVE: 'receive', + SETTINGS: 'settings' +} + +const WalletMultiStepFormContext = createContext() + +export function WalletMultiStepFormContextProvider ({ wallet, initial, steps, children }) { + // save selected protocol, but useProtocol will always return the first protocol if no protocol is selected + const [protocol, setProtocol] = useState(null) + const value = useMemo(() => ({ wallet, protocol, setProtocol }), [wallet, protocol, setProtocol]) + return ( + + + {children} + + + ) +} + +export function useWallet () { + const { wallet } = useContext(WalletMultiStepFormContext) + return wallet +} + +export function useWalletProtocols () { + const step = useStep() + const wallet = useWallet() + + const protocolFilter = useCallback(p => step === Step.SEND ? p.send : !p.send, [step]) + + return useMemo(() => { + // all protocols are templates if wallet is a template + if (isTemplate(wallet)) { + return wallet.protocols.filter(protocolFilter) + } + // return template for every protocol that isn't configured + const configured = wallet.protocols.filter(protocolFilter) + const templates = wallet.template.protocols.filter(protocolFilter) + return templates.map(p => configured.find(c => c.name === p.name) ?? p) + }, [wallet, protocolFilter]) +} + +export function useProtocol () { + const { protocol, setProtocol } = useContext(WalletMultiStepFormContext) + const protocols = useWalletProtocols() + + useEffect(() => { + // when we move between send and receive, we need to make sure that we've selected a protocol + // that actually exists, so if the protocol is not found, we set it to the first protocol + if (!protocol || !protocols.find(p => p.id === protocol.id)) { + setProtocol(protocols[0]) + } + }, [protocol, protocols, setProtocol]) + + // make sure we always have a protocol, even on first render before useEffect runs + return useMemo(() => [protocol ?? protocols[0], setProtocol], [protocol, protocols, setProtocol]) +} + +function useProtocolFormState (protocol) { + const formId = protocolFormId(protocol) + const [formState, setFormState] = useFormState(formId) + const setProtocolFormState = useCallback( + ({ enabled, ...config }) => { + setFormState({ ...protocol, enabled, config }) + }, + [setFormState, protocol]) + return useMemo(() => [formState, setProtocolFormState], [formState, setProtocolFormState]) +} + +export function useProtocolForm (protocol) { + const [formState, setFormState] = useProtocolFormState(protocol) + const [complementaryFormState] = useProtocolFormState({ name: protocol.name, send: !protocol.send }) + const wallet = useWallet() + const lud16Domain = walletLud16Domain(wallet.name) + const fields = protocolFields(protocol) + const initial = fields.reduce((acc, field) => { + // we only fallback to the existing protocol config because formState was not initialized yet on first render + // after init, we use formState as the source of truth everywhere + let value = formState?.config?.[field.name] ?? protocol.config?.[field.name] + + if (!value && field.share) { + value = complementaryFormState?.config?.[field.name] + } + + if (field.name === 'address' && lud16Domain && value) { + value = value.split('@')[0] + } + + return { + ...acc, + [field.name]: value || '' + } + }, { enabled: formState?.enabled ?? protocol.enabled }) + + let schema = protocolClientSchema(protocol) + if (lud16Domain) { + schema = schema.transform(({ address, ...rest }) => { + return { + address: address ? `${address}@${lud16Domain}` : '', + ...rest + } + }) + } + + return useMemo(() => [{ fields, initial, schema }, setFormState], [fields, initial, schema, setFormState]) +} + +export function useSaveWallet () { + const wallet = useWallet() + const [formState] = useFormState() + const upsert = useWalletProtocolUpsert() + + const save = useCallback(async () => { + let walletId = isWallet(wallet) ? wallet.id : undefined + for (const protocol of Object.values(formState)) { + const { id } = await upsert( + { + ...wallet, + id: walletId, + __typename: walletId ? 'Wallet' : 'WalletTemplate' + }, + protocol, { ...protocol.config, enabled: protocol.enabled } + ) + walletId ??= id + } + }, [wallet, formState, upsert]) + + return save +} diff --git a/wallets/client/components/form/index.js b/wallets/client/components/form/index.js new file mode 100644 index 00000000..a5c02fbc --- /dev/null +++ b/wallets/client/components/form/index.js @@ -0,0 +1,231 @@ +import { useCallback, useMemo } from 'react' +import { InputGroup, Nav } from 'react-bootstrap' +import classNames from 'classnames' +import styles from '@/styles/wallet.module.css' +import navStyles from '@/styles/nav.module.css' +import { Checkbox, Form, Input, PasswordInput, SubmitButton } from '@/components/form' +import CancelButton from '@/components/cancel-button' +import Text from '@/components/text' +import Info from '@/components/info' +import { useFormState, useMaxSteps, useNext, useStepIndex } from '@/components/multi-step-form' +import { isTemplate, isWallet, protocolDisplayName, protocolFormId, protocolLogName, walletLud16Domain } from '@/wallets/lib/util' +import { WalletLayout, WalletLayoutHeader, WalletLayoutImageOrName, WalletLogs } from '@/wallets/client/components' +import { TemplateLogsProvider, useTestSendPayment, useWalletLogger, useTestCreateInvoice, useWalletSupport } from '@/wallets/client/hooks' +import ArrowRight from '@/svgs/arrow-right-s-fill.svg' + +import { WalletMultiStepFormContextProvider, Step, useWallet, useWalletProtocols, useProtocol, useProtocolForm } from './hooks' +import { Settings } from './settings' +import { BackButton, SkipButton } from './button' + +export function WalletMultiStepForm ({ wallet }) { + const initial = useMemo(() => wallet.protocols + .filter(p => !isTemplate(p)) + .reduce((acc, p) => { + const formId = protocolFormId(p) + return { + ...acc, + [formId]: p + } + }, {}), [wallet]) + + const support = useWalletSupport(wallet) + const steps = useMemo(() => + [ + support.send && Step.SEND, + support.receive && Step.RECEIVE, + Step.SETTINGS + ].filter(Boolean), + [support]) + + return ( + +
+ + + + + {steps.map(step => { + // WalletForm is aware of the current step via hooks + // and can thus render a different form for send vs. receive + if (step === Step.SEND) return + if (step === Step.RECEIVE) return + return + })} + +
+
+ ) +} + +function WalletForm () { + return ( + + + + + ) +} + +function WalletProtocolSelector () { + const protocols = useWalletProtocols() + const [protocol, selectProtocol] = useProtocol() + + return ( + + ) +} + +function WalletProtocolForm () { + const wallet = useWallet() + const [protocol] = useProtocol() + const next = useNext() + const testSendPayment = useTestSendPayment(protocol) + const testCreateInvoice = useTestCreateInvoice(protocol) + const logger = useWalletLogger(protocol) + const [{ fields, initial, schema }, setFormState] = useProtocolForm(protocol) + + // create a copy of values to avoid mutating the original + const onSubmit = useCallback(async ({ ...values }) => { + const lud16Domain = walletLud16Domain(wallet.name) + if (values.address && lud16Domain) { + values.address = `${values.address}@${lud16Domain}` + } + + const name = protocolLogName(protocol) + + if (isTemplate(protocol)) { + values.enabled = true + } + + if (values.enabled) { + try { + if (protocol.send) { + logger.info(`testing ${name} send ...`) + const additionalValues = await testSendPayment(values) + values = { ...values, ...additionalValues } + logger.ok(`${name} send ok`) + } else { + logger.info(`testing ${name} receive ...`) + await testCreateInvoice(values) + logger.ok(`${name} receive ok`) + } + } catch (err) { + logger.error(err.message) + throw err + } + } + + setFormState(values) + next() + }, [protocol, wallet, setFormState, testSendPayment, logger, next]) + + return ( + <> +
+ {fields.map(field => )} + {!isTemplate(protocol) && } + + + + + ) +} + +function WalletProtocolFormNavigator () { + const wallet = useWallet() + const stepIndex = useStepIndex() + const maxSteps = useMaxSteps() + const [formState] = useFormState() + + // was something already configured or was something configured just now? + const configExists = (isWallet(wallet) && wallet.protocols.length > 0) || Object.keys(formState).length > 0 + + // don't allow going to settings as last step with nothing configured + const hideSkip = stepIndex === maxSteps - 2 && !configExists + + return ( +
+ {stepIndex === 0 ? cancel : } + {!hideSkip ? :
} + + next + + +
+ ) +} + +function WalletProtocolFormField ({ type, ...props }) { + const wallet = useWallet() + const [protocol] = useProtocol() + + function transform ({ validate, encrypt, editable, help, share, ...props }) { + const [upperHint, bottomHint] = Array.isArray(props.hint) ? props.hint : [null, props.hint] + + const parseHelpText = text => Array.isArray(text) ? text.join('\n\n') : text + const _help = help + ? ( + typeof help === 'string' + ? { label: null, text: help } + : ( + Array.isArray(help) + ? { label: null, text: parseHelpText(help) } + : { label: help.label, text: parseHelpText(help.text) } + ) + ) + : null + + const readOnly = !!protocol.config?.[props.name] && editable === false + + const label = ( +
+ {props.label} + {_help && ( + + {_help.text} + + )} + + {upperHint + ? {upperHint} + : (!props.required ? 'optional' : null)} + +
+ ) + + return { ...props, hint: bottomHint, label, readOnly } + } + + switch (type) { + case 'text': { + let append + const lud16Domain = walletLud16Domain(wallet.name) + if (props.name === 'address' && lud16Domain) { + append = @{lud16Domain} + } + return + } + case 'password': + return + default: + return null + } +} diff --git a/wallets/client/components/form/settings.js b/wallets/client/components/form/settings.js new file mode 100644 index 00000000..65ac8980 --- /dev/null +++ b/wallets/client/components/form/settings.js @@ -0,0 +1,280 @@ +import { useCallback } from 'react' +import Button from 'react-bootstrap/Button' +import InputGroup from 'react-bootstrap/InputGroup' +import { useField } from 'formik' +import classNames from 'classnames' +import { useRouter } from 'next/router' +import { useMutation, useQuery } from '@apollo/client' +import { Checkbox, Form, Input, SubmitButton } from '@/components/form' +import Info from '@/components/info' +import { useToast } from '@/components/toast' +import AccordianItem from '@/components/accordian-item' +import { isNumber } from '@/lib/format' +import { walletSettingsSchema } from '@/lib/validate' +import styles from '@/styles/wallet.module.css' +import { useShowModal } from '@/components/modal' +import { SET_WALLET_SETTINGS, WALLET_SETTINGS } from '@/wallets/client/fragments' +import { useWalletDelete } from '@/wallets/client/hooks' + +import { useSaveWallet, useWallet } from './hooks' +import { BackButton } from './button' +import { isWallet } from '@/wallets/lib/util' + +export function Settings () { + const wallet = useWallet() + const { data } = useQuery(WALLET_SETTINGS) + const [setSettings] = useMutation(SET_WALLET_SETTINGS) + const toaster = useToast() + const saveWallet = useSaveWallet() + const router = useRouter() + + const onSubmit = useCallback(async (settings) => { + try { + await saveWallet() + await setSettings({ + variables: { settings }, + update: (cache, { data }) => { + cache.writeQuery({ + query: WALLET_SETTINGS, + data: { + walletSettings: { + __typename: 'WalletSettings', + ...data?.setWalletSettings + } + } + }) + } + }) + router.push('/wallets') + } catch (err) { + console.error(err) + toaster.danger('failed to save wallet') + } + }, [saveWallet, setSettings, toaster, router]) + + const initial = { + receiveCreditsBelowSats: data?.walletSettings?.receiveCreditsBelowSats ?? 10, + sendCreditsBelowSats: data?.walletSettings?.sendCreditsBelowSats ?? 10, + autoWithdrawThreshold: data?.walletSettings?.autoWithdrawThreshold ?? 10000, + autoWithdrawMaxFeePercent: data?.walletSettings?.autoWithdrawMaxFeePercent ?? 1, + autoWithdrawMaxFeeTotal: data?.walletSettings?.autoWithdrawMaxFeeTotal ?? 1, + proxyReceive: data?.walletSettings?.proxyReceive ?? true + } + + return ( + <> +
+ +
+ + {isWallet(wallet) && } + save +
+ + + ) +} + +function Separator ({ children, className }) { + return ( +
{children}
+ ) +} + +function WalletDeleteButton ({ className }) { + const showModal = useShowModal() + const wallet = useWallet() + + return ( + + ) +} + +function WalletDeleteObstacle ({ wallet, onClose }) { + const deleteWallet = useWalletDelete(wallet) + const toaster = useToast() + const router = useRouter() + + const onClick = useCallback(async () => { + try { + await deleteWallet() + onClose() + router.push('/wallets') + } catch (err) { + console.error('failed to delete wallet:', err) + toaster.danger('failed to delete wallet') + } + }, [deleteWallet, onClose, toaster, router]) + + return ( +
+

Delete wallet

+

+ Are you sure you want to delete this wallet? +

+
+ + +
+
+ ) +} + +function GlobalSettings () { + return ( + <> + global settings + + + + + + } + /> + + ) +} + +function AutowithdrawSettings () { + const [{ value: threshold }] = useField('autoWithdrawThreshold') + const sendThreshold = Math.max(Math.floor(threshold / 10), 1) + + return ( + <> + sats} + required + type='number' + min={0} + groupClassName='mb-2' + /> + + max fee rate + +
    +
  • configure fee budget for autowithdrawals
  • +
  • if max fee total is higher for a withdrawal, we will use it instead to find a route
  • +
  • higher fee settings increase the likelihood of successful withdrawals
  • +
+
+
+ } + name='autoWithdrawMaxFeePercent' + append={%} + required + type='number' + min={0} + /> + + max fee total + +
    +
  • configure fee budget for autowithdrawals
  • +
  • if max fee rate is higher for a withdrawal, we will use it instead to find a route to your wallet
  • +
  • higher fee settings increase the likelihood of successful withdrawals
  • +
+
+ + } + name='autoWithdrawMaxFeeTotal' + append={sats} + required + type='number' + min={0} + /> + + ) +} + +function LightningAddressSettings () { + return ( + <> + enhance privacy of my lightning address + +
    +
  • Enabling this setting hides details (ie node pubkey) of your attached wallets when anyone pays your SN lightning address or lnurl-pay
  • +
  • The lightning invoice will appear to have SN's node as the destination to preserve your wallet's privacy
  • +
  • This will incur in a 10% fee
  • +
  • Disable this setting to receive payments directly to your attached wallets (which will reveal their details to the payer)
  • +
  • Note: this privacy behavior is standard for internal zaps/payments on SN, and this setting only applies to external payments
  • +
+
+ + } + name='proxyReceive' + groupClassName='mb-3' + /> + + ) +} + +function CowboyCreditsSettings () { + return ( + <> + + receive credits for zaps below + +
    +
  • we will not attempt to forward zaps below this amount to you, you will receive credits instead
  • +
  • this setting is useful if small amounts are expensive to receive for you
  • +
+
+ + } + name='receiveCreditsBelowSats' + required + append={sats} + type='number' + min={0} + /> + + send credits for zaps below + +
    +
  • we will not attempt to send zaps below this amount from your wallet if you have enough credits
  • +
  • this setting is useful if small amounts are expensive to send for you
  • +
+
+ + } + name='sendCreditsBelowSats' + required + append={sats} + type='number' + min={0} + /> + + ) +} diff --git a/wallets/client/components/forms.js b/wallets/client/components/forms.js deleted file mode 100644 index e9f3ad6a..00000000 --- a/wallets/client/components/forms.js +++ /dev/null @@ -1,345 +0,0 @@ -import { useCallback, useMemo, createContext, useContext } from 'react' -import { Button, InputGroup, Nav } from 'react-bootstrap' -import Link from 'next/link' -import { useParams, usePathname } from 'next/navigation' -import { useRouter } from 'next/router' -import { WalletLayout, WalletLayoutHeader, WalletLayoutImageOrName, WalletLogs } from '@/wallets/client/components' -import { protocolDisplayName, protocolFields, protocolClientSchema, unurlify, urlify, isWallet, isTemplate, walletLud16Domain } from '@/wallets/lib/util' -import styles from '@/styles/wallet.module.css' -import navStyles from '@/styles/nav.module.css' -import { Checkbox, Form, Input, PasswordInput, SubmitButton } from '@/components/form' -import CancelButton from '@/components/cancel-button' -import { useWalletProtocolUpsert, useWalletProtocolRemove, useProtocolTemplates, TemplateLogsProvider } from '@/wallets/client/hooks' -import { useToast } from '@/components/toast' -import Text from '@/components/text' -import Info from '@/components/info' -import classNames from 'classnames' - -const WalletFormsContext = createContext() - -export function WalletForms ({ wallet, refetch }) { - return ( - -
- - {wallet && } - - {wallet && ( - - - - )} -
-
- ) -} - -function WalletFormsProvider ({ children, wallet, refetch }) { - const value = useMemo(() => ({ refetch, wallet }), [refetch, wallet]) - return ( - - {children} - - ) -} - -function useWalletRefetch () { - const { refetch } = useContext(WalletFormsContext) - return refetch -} - -function useWallet () { - const { wallet } = useContext(WalletFormsContext) - return wallet -} - -function WalletFormSelector () { - const sendRecvParam = useSendRecvParam() - const protocolParam = useWalletProtocolParam() - - return ( - <> - - {sendRecvParam && ( -
-
- - {protocolParam && ( - - - - )} -
-
- )} - - ) -} - -function WalletSendRecvSelector () { - const wallet = useWallet() - const path = useWalletPathname() - const protocols = useProtocolTemplates(wallet) - const selected = useSendRecvParam() - - const firstSend = protocols.find(p => p.send) - const firstRecv = protocols.find(p => !p.send) - - // TODO(wallet-v2): if you click a nav link again, it will update the URL - // but not run the effect again to select the first protocol by default - return ( - - ) -} - -function WalletProtocolSelector () { - const walletPath = useWalletPathname() - const sendRecvParam = useSendRecvParam() - const selected = useWalletProtocolParam() - const path = `${walletPath}/${sendRecvParam}` - - const wallet = useWallet() - const protocols = useProtocolTemplates(wallet).filter(p => sendRecvParam === 'send' ? p.send : !p.send) - - if (protocols.length === 0) { - // TODO(wallet-v2): let user know how to request support if the wallet actually does support sending - return ( -
- {sendRecvParam === 'send' ? 'sending' : 'receiving'} not supported -
- ) - } - - return ( - - ) -} - -function WalletProtocolForm () { - const sendRecvParam = useSendRecvParam() - const router = useRouter() - const protocol = useSelectedProtocol() - if (!protocol) return null - - // I think it is okay to skip this hook if the protocol is not found - // because we will need to change the URL to get a different protocol - // so the amount of rendered hooks should stay the same during the lifecycle of this component - const wallet = useWallet() - const upsertWalletProtocol = useWalletProtocolUpsert(wallet, protocol) - const toaster = useToast() - const refetch = useWalletRefetch() - - const { fields, initial, schema } = useProtocolForm(protocol) - - // create a copy of values to avoid mutating the original - const onSubmit = useCallback(async ({ ...values }) => { - const lud16Domain = walletLud16Domain(wallet.name) - if (values.address && lud16Domain) { - values.address = `${values.address}@${lud16Domain}` - } - - const upsert = await upsertWalletProtocol(values) - if (isWallet(wallet)) { - toaster.success('wallet saved') - refetch() - return - } - // we just created a new user wallet from a template - router.replace(`/wallets/${upsert.id}/${sendRecvParam}/${urlify(protocol.name)}`, null, { shallow: true }) - toaster.success('wallet attached', { persistOnNavigate: true }) - }, [upsertWalletProtocol, toaster, wallet, router]) - - return ( - <> -
- {fields.map(field => )} - - - - - - ) -} - -function WalletProtocolFormButtons () { - const protocol = useSelectedProtocol() - const removeWalletProtocol = useWalletProtocolRemove(protocol) - const refetch = useWalletRefetch() - const router = useRouter() - const wallet = useWallet() - const isLastProtocol = wallet.protocols.length === 1 - - const onDetach = useCallback(async () => { - await removeWalletProtocol() - if (isLastProtocol) { - router.replace('/wallets', null, { shallow: true }) - return - } - refetch() - }, [removeWalletProtocol, refetch, isLastProtocol, router]) - - return ( -
- {!isTemplate(protocol) && } - cancel - {isWallet(wallet) ? 'save' : 'attach'} -
- ) -} - -function WalletProtocolFormField ({ type, ...props }) { - const wallet = useWallet() - const protocol = useSelectedProtocol() - - function transform ({ validate, encrypt, editable, help, ...props }) { - const [upperHint, bottomHint] = Array.isArray(props.hint) ? props.hint : [null, props.hint] - - const parseHelpText = text => Array.isArray(text) ? text.join('\n\n') : text - const _help = help - ? ( - typeof help === 'string' - ? { label: null, text: help } - : ( - Array.isArray(help) - ? { label: null, text: parseHelpText(help) } - : { label: help.label, text: parseHelpText(help.text) } - ) - ) - : null - - const readOnly = !!protocol.config?.[props.name] && editable === false - - const label = ( -
- {props.label} - {_help && ( - - {_help.text} - - )} - - {upperHint - ? {upperHint} - : (!props.required ? 'optional' : null)} - -
- ) - - return { ...props, hint: bottomHint, label, readOnly } - } - - switch (type) { - case 'text': { - let append - const lud16Domain = walletLud16Domain(wallet.name) - if (props.name === 'address' && lud16Domain) { - append = @{lud16Domain} - } - return - } - case 'password': - return - default: - return null - } -} - -function useWalletPathname () { - const pathname = usePathname() - // returns /wallets/:name - return pathname.split('/').filter(Boolean).slice(0, 2).join('/') -} - -function useSendRecvParam () { - const params = useParams() - // returns only :send in /wallets/:name/:send - return ['send', 'receive'].includes(params.slug[1]) ? params.slug[1] : null -} - -function useWalletProtocolParam () { - const params = useParams() - const name = params.slug[2] - // returns only :protocol in /wallets/:name/:send/:protocol - return name ? unurlify(name) : null -} - -function useSelectedProtocol () { - const wallet = useWallet() - const sendRecvParam = useSendRecvParam() - const protocolParam = useWalletProtocolParam() - - const send = sendRecvParam === 'send' - let protocol = wallet.protocols.find(p => p.name === protocolParam && p.send === send) - if (!protocol && isWallet(wallet)) { - // the protocol was not found as configured, look for it in the template - protocol = wallet.template.protocols.find(p => p.name === protocolParam && p.send === send) - } - - return protocol -} - -function useProtocolForm (protocol) { - const wallet = useWallet() - const lud16Domain = walletLud16Domain(wallet.name) - const fields = protocolFields(protocol) - const initial = fields.reduce((acc, field) => { - // wallet templates don't have a config - let value = protocol.config?.[field.name] - - if (field.name === 'address' && lud16Domain && value) { - value = value.split('@')[0] - } - - return { - ...acc, - [field.name]: value || '' - } - }, { enabled: protocol.enabled }) - - let schema = protocolClientSchema(protocol) - if (lud16Domain) { - schema = schema.transform(({ address, ...rest }) => { - return { - address: address ? `${address}@${lud16Domain}` : '', - ...rest - } - }) - } - - return { fields, initial, schema } -} diff --git a/wallets/client/components/index.js b/wallets/client/components/index.js index 6c35ddef..95e1abd5 100644 --- a/wallets/client/components/index.js +++ b/wallets/client/components/index.js @@ -1,6 +1,6 @@ export * from './card' export * from './draggable' -export * from './forms' +export * from './form/index' export * from './layout' export * from './passphrase' export * from './logger' diff --git a/wallets/client/fragments/wallet.js b/wallets/client/fragments/wallet.js index 6633ba72..6541c565 100644 --- a/wallets/client/fragments/wallet.js +++ b/wallets/client/fragments/wallet.js @@ -203,6 +203,11 @@ export const RESET_WALLETS = gql` } ` +export const DELETE_WALLET = gql` + mutation deleteWallet($id: ID!) { + deleteWallet(id: $id) + }` + export const DISABLE_PASSPHRASE_EXPORT = gql` mutation DisablePassphraseExport { disablePassphraseExport @@ -224,7 +229,14 @@ export const WALLET_SETTINGS = gql` export const SET_WALLET_SETTINGS = gql` mutation SetWalletSettings($settings: WalletSettingsInput!) { - setWalletSettings(settings: $settings) + setWalletSettings(settings: $settings) { + receiveCreditsBelowSats + sendCreditsBelowSats + proxyReceive + autoWithdrawMaxFeePercent + autoWithdrawMaxFeeTotal + autoWithdrawThreshold + } } ` diff --git a/wallets/client/hooks/query.js b/wallets/client/hooks/query.js index c196f067..b86b8856 100644 --- a/wallets/client/hooks/query.js +++ b/wallets/client/hooks/query.js @@ -13,7 +13,6 @@ import { UPSERT_WALLET_SEND_PHOENIXD, UPSERT_WALLET_SEND_WEBLN, WALLETS, - REMOVE_WALLET_PROTOCOL, UPDATE_WALLET_ENCRYPTION, RESET_WALLETS, DISABLE_PASSPHRASE_EXPORT, @@ -25,13 +24,14 @@ import { TEST_WALLET_RECEIVE_LIGHTNING_ADDRESS, TEST_WALLET_RECEIVE_NWC, TEST_WALLET_RECEIVE_CLN_REST, - TEST_WALLET_RECEIVE_LND_GRPC + TEST_WALLET_RECEIVE_LND_GRPC, + DELETE_WALLET } from '@/wallets/client/fragments' import { gql, useApolloClient, useMutation, useQuery } from '@apollo/client' -import { useDecryption, useEncryption, useSetKey, useWalletLogger, useWalletsUpdatedAt, WalletStatus } from '@/wallets/client/hooks' +import { useDecryption, useEncryption, useSetKey, useWalletLoggerFactory, useWalletsUpdatedAt, WalletStatus } from '@/wallets/client/hooks' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { - isEncryptedField, isTemplate, isWallet, protocolAvailable, protocolClientSchema, reverseProtocolRelationName + isEncryptedField, isTemplate, isWallet, protocolAvailable, protocolClientSchema, protocolLogName, reverseProtocolRelationName } from '@/wallets/lib/util' import { protocolTestSendPayment } from '@/wallets/client/protocols' import { timeoutSignal } from '@/lib/time' @@ -146,37 +146,19 @@ function server2Client (wallet) { return wallet ? undoFieldAlias(checkProtocolAvailability(wallet)) : wallet } -export function useWalletProtocolUpsert (wallet, protocol) { - const mutation = getWalletProtocolUpsertMutation(protocol) - const [mutate] = useMutation(mutation) - const { encryptConfig } = useEncryptConfig(protocol) - const testSendPayment = useTestSendPayment(protocol) - const testCreateInvoice = useTestCreateInvoice(protocol) - const logger = useWalletLogger(protocol) +export function useWalletProtocolUpsert () { + const client = useApolloClient() + const loggerFactory = useWalletLoggerFactory() + const { encryptConfig } = useEncryptConfig() - return useCallback(async (values) => { - logger.info('saving wallet ...') + return useCallback(async (wallet, protocol, values) => { + const logger = loggerFactory(protocol) + const mutation = protocolUpsertMutation(protocol) + const name = `${protocolLogName(protocol)} ${protocol.send ? 'send' : 'receive'}` - if (isTemplate(protocol)) { - values.enabled = true - } + logger.info(`saving ${name} ...`) - // skip network tests if we're disabling the wallet - if (values.enabled) { - try { - if (protocol.send) { - const additionalValues = await testSendPayment(values) - values = { ...values, ...additionalValues } - } else { - await testCreateInvoice(values) - } - } catch (err) { - logger.error(err.message) - throw err - } - } - - const encrypted = await encryptConfig(values) + const encrypted = await encryptConfig(values, { protocol }) const variables = encrypted if (isWallet(wallet)) { @@ -187,8 +169,8 @@ export function useWalletProtocolUpsert (wallet, protocol) { let updatedWallet try { - const { data } = await mutate({ variables }) - logger.ok('wallet saved') + const { data } = await client.mutate({ mutation, variables }) + logger.ok(`${name} saved`) updatedWallet = Object.values(data)[0] } catch (err) { logger.error(err.message) @@ -198,29 +180,20 @@ export function useWalletProtocolUpsert (wallet, protocol) { requestPersistentStorage() return updatedWallet - }, [wallet, protocol, logger, testSendPayment, testCreateInvoice, encryptConfig, mutate]) + }, [client, loggerFactory, encryptConfig]) } export function useLightningAddressUpsert () { - // TODO(wallet-v2): parse domain from address input to use correct wallet template - // useWalletProtocolUpsert needs to support passing in the wallet in the callback for that - const wallet = { name: 'LN_ADDR', __typename: 'WalletTemplate' } - const protocol = { name: 'LN_ADDR', send: false, __typename: 'WalletProtocolTemplate' } - return useWalletProtocolUpsert(wallet, protocol) -} + const wallet = useMemo(() => ({ name: 'LN_ADDR', __typename: 'WalletTemplate' }), []) + const protocol = useMemo(() => ({ name: 'LN_ADDR', send: false, __typename: 'WalletProtocolTemplate' }), []) + const upsert = useWalletProtocolUpsert() + const testCreateInvoice = useTestCreateInvoice(protocol) -export function useWalletProtocolRemove (protocol) { - const [mutate] = useMutation(REMOVE_WALLET_PROTOCOL) - const toaster = useToast() - - return useCallback(async () => { - try { - await mutate({ variables: { id: protocol.id } }) - toaster.success('protocol detached') - } catch (err) { - toaster.danger('failed to detach protocol: ' + err.message) - } - }, [protocol?.id, mutate, toaster]) + return useCallback(async (values) => { + // TODO(wallet-v2): parse domain from address input to use correct wallet template + await testCreateInvoice(values) + return await upsert(wallet, protocol, { ...values, enabled: true }) + }, [testCreateInvoice, upsert, wallet, protocol]) } export function useWalletEncryptionUpdate () { @@ -295,7 +268,7 @@ export function useSetWalletPriorities () { // (the mutation would throw if called but we make sure to never call it.) const NOOP_MUTATION = gql`mutation noop { noop }` -function getWalletProtocolUpsertMutation (protocol) { +function protocolUpsertMutation (protocol) { switch (protocol.name) { case 'LNBITS': return protocol.send ? UPSERT_WALLET_SEND_LNBITS : UPSERT_WALLET_RECEIVE_LNBITS @@ -320,7 +293,7 @@ function getWalletProtocolUpsertMutation (protocol) { } } -function getWalletProtocolTestMutation (protocol) { +function protocolTestMutation (protocol) { if (protocol.send) return NOOP_MUTATION switch (protocol.name) { @@ -343,7 +316,7 @@ function getWalletProtocolTestMutation (protocol) { } } -function useTestSendPayment (protocol) { +export function useTestSendPayment (protocol) { return useCallback(async (values) => { return await protocolTestSendPayment( protocol, @@ -353,8 +326,8 @@ function useTestSendPayment (protocol) { }, [protocol]) } -function useTestCreateInvoice (protocol) { - const mutation = getWalletProtocolTestMutation(protocol) +export function useTestCreateInvoice (protocol) { + const mutation = protocolTestMutation(protocol) const [testCreateInvoice] = useMutation(mutation) return useCallback(async (values) => { @@ -362,6 +335,14 @@ function useTestCreateInvoice (protocol) { }, [testCreateInvoice]) } +export function useWalletDelete (wallet) { + const [mutate] = useMutation(DELETE_WALLET) + + return useCallback(async () => { + await mutate({ variables: { id: wallet.id } }) + }, [mutate, wallet.id]) +} + function useWalletDecryption () { const { decryptConfig, ready } = useDecryptConfig() @@ -491,7 +472,7 @@ export function useWalletMigrationMutation () { } await client.mutate({ - mutation: getWalletProtocolUpsertMutation(protocol), + mutation: protocolUpsertMutation(protocol), variables: { ...(walletId ? { walletId } : { templateName }), enabled, diff --git a/wallets/lib/protocols/index.js b/wallets/lib/protocols/index.js index 8caa5f52..12fbb4ed 100644 --- a/wallets/lib/protocols/index.js +++ b/wallets/lib/protocols/index.js @@ -32,6 +32,7 @@ import webln from './webln' * @property {yup.Schema} validate - validation rules to apply * @property {string} [placeholder] - placeholder text shown in input field * @property {string} [hint] - hint text shown below field + * @property {boolean} [share] - whether field can be used to prepopulate field of complementary send/receive protocol */ /** @type {Protocol[]} */ diff --git a/wallets/lib/protocols/lnbits.js b/wallets/lib/protocols/lnbits.js index bf201aba..7aa8fea0 100644 --- a/wallets/lib/protocols/lnbits.js +++ b/wallets/lib/protocols/lnbits.js @@ -14,7 +14,8 @@ export default [ label: 'url', type: 'text', validate: urlValidator('clearnet', 'tor'), - required: true + required: true, + share: true }, { name: 'apiKey', @@ -37,7 +38,8 @@ export default [ label: 'url', type: 'text', validate: urlValidator('clearnet', 'tor'), - required: true + required: true, + share: true }, { name: 'apiKey', diff --git a/wallets/lib/protocols/nwc.js b/wallets/lib/protocols/nwc.js index 360516eb..522708ff 100644 --- a/wallets/lib/protocols/nwc.js +++ b/wallets/lib/protocols/nwc.js @@ -10,6 +10,7 @@ export default [ name: 'NWC', send: true, displayName: 'Nostr Wallet Connect', + logName: 'NWC', fields: [ { name: 'url', @@ -27,6 +28,7 @@ export default [ name: 'NWC', send: false, displayName: 'Nostr Wallet Connect', + logName: 'NWC', fields: [ { name: 'url', diff --git a/wallets/lib/protocols/phoenixd.js b/wallets/lib/protocols/phoenixd.js index 4355c910..381ba282 100644 --- a/wallets/lib/protocols/phoenixd.js +++ b/wallets/lib/protocols/phoenixd.js @@ -14,7 +14,8 @@ export default [ type: 'text', label: 'url', validate: urlValidator('clearnet'), - required: true + required: true, + share: true }, { name: 'apiKey', @@ -42,7 +43,8 @@ export default [ type: 'text', label: 'url', validate: urlValidator('clearnet'), - required: true + required: true, + share: true }, { name: 'apiKey', diff --git a/wallets/lib/util.js b/wallets/lib/util.js index eabb754f..ef49defe 100644 --- a/wallets/lib/util.js +++ b/wallets/lib/util.js @@ -30,6 +30,10 @@ export function protocolDisplayName ({ name, send }) { return protocol({ name, send })?.displayName || titleCase(name) } +export function protocolLogName ({ name, send }) { + return protocol({ name, send })?.logName ?? protocolDisplayName({ name, send }) +} + export function protocolRelationName ({ name, send }) { return protocol({ name, send })?.relationName } @@ -120,3 +124,10 @@ export function isWallet (wallet) { export function isTemplate (obj) { return obj.__typename.endsWith('Template') } + +export function protocolFormId ({ name, send }) { + // we don't use the protocol id as the form id because then we can't find the + // complementary protocol to share fields between templates and non-templates + // by simply flipping send to recv and vice versa + return `${name}-${send ? 'send' : 'recv'}` +} diff --git a/wallets/server/resolvers/protocol.js b/wallets/server/resolvers/protocol.js index f53e8d4f..78c1652f 100644 --- a/wallets/server/resolvers/protocol.js +++ b/wallets/server/resolvers/protocol.js @@ -62,12 +62,11 @@ export const resolvers = { }, []) ), addWalletLog, - removeWalletProtocol, deleteWalletLogs } } -function testWalletProtocol (protocol) { +export function testWalletProtocol (protocol) { return async (parent, args, { me, models, tx }) => { if (!me) { throw new GqlAuthenticationError() @@ -223,6 +222,7 @@ export function upsertWalletProtocol (protocol) { } } +// not exposed to the client via GraphQL API, but used when resetting wallets export async function removeWalletProtocol (parent, { id }, { me, models, tx }) { if (!me) { throw new GqlAuthenticationError() @@ -336,7 +336,7 @@ async function deleteWalletLogs (parent, { protocolId, debug }, { me, models }) return true } -async function updateWalletBadges ({ userId, tx }) { +export async function updateWalletBadges ({ userId, tx }) { const pushNotifications = [] const wallets = await tx.wallet.findMany({ diff --git a/wallets/server/resolvers/wallet.js b/wallets/server/resolvers/wallet.js index c1902a25..964b3d41 100644 --- a/wallets/server/resolvers/wallet.js +++ b/wallets/server/resolvers/wallet.js @@ -1,6 +1,6 @@ import { GqlAuthenticationError, GqlInputError } from '@/lib/error' import { mapWalletResolveTypes } from '@/wallets/server/resolvers/util' -import { removeWalletProtocol, upsertWalletProtocol } from './protocol' +import { removeWalletProtocol, upsertWalletProtocol, updateWalletBadges } from './protocol' import { validateSchema, walletSettingsSchema } from '@/lib/validate' const WalletOrTemplate = { @@ -47,7 +47,8 @@ export const resolvers = { resetWallets, setWalletPriorities, disablePassphraseExport, - setWalletSettings + setWalletSettings, + deleteWallet } } @@ -168,6 +169,17 @@ async function updateKeyHash (parent, { keyHash }, { me, models }) { return count > 0 } +async function deleteWallet (parent, { id }, { me, models }) { + if (!me) throw new GqlAuthenticationError() + + await models.$transaction(async tx => { + await tx.wallet.delete({ where: { id: Number(id), userId: me.id } }) + await updateWalletBadges({ userId: me.id, tx }) + }) + + return true +} + async function resetWallets (parent, { newKeyHash }, { me, models }) { if (!me) throw new GqlAuthenticationError() @@ -233,5 +245,5 @@ async function setWalletSettings (parent, { settings }, { me, models }) { await models.user.update({ where: { id: me.id }, data: settings }) - return true + return settings }