final touches
This commit is contained in:
parent
3cfbaf4638
commit
b8216740d4
|
@ -1,238 +0,0 @@
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import { useMe } from './me'
|
|
||||||
import { useShowModal } from './modal'
|
|
||||||
import { useVaultConfigurator } from './vault/use-vault-configurator'
|
|
||||||
import { Button, InputGroup } from 'react-bootstrap'
|
|
||||||
import { Form, Input, PasswordInput, SubmitButton } from './form'
|
|
||||||
import bip39Words from '@/lib/bip39-words'
|
|
||||||
import Info from './info'
|
|
||||||
import CancelButton from './cancel-button'
|
|
||||||
import * as yup from 'yup'
|
|
||||||
import { deviceSyncSchema } from '@/lib/validate'
|
|
||||||
import RefreshIcon from '@/svgs/refresh-line.svg'
|
|
||||||
import { useApolloClient } from '@apollo/client'
|
|
||||||
|
|
||||||
export default function DeviceSync () {
|
|
||||||
const { me } = useMe()
|
|
||||||
const apollo = useApolloClient()
|
|
||||||
const { key, setVaultKey, clearVault } = useVaultConfigurator()
|
|
||||||
const showModal = useShowModal()
|
|
||||||
|
|
||||||
const enabled = !!me?.privates?.vaultKeyHash
|
|
||||||
const connected = !!key
|
|
||||||
|
|
||||||
const manage = useCallback(async () => {
|
|
||||||
if (enabled && connected) {
|
|
||||||
showModal((onClose) => (
|
|
||||||
<div>
|
|
||||||
<h2>Device sync is enabled!</h2>
|
|
||||||
<p>
|
|
||||||
Sensitive data (like wallet credentials) are 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 className='me-2 text-muted nav-link fw-bold' variant='link' onClick={onClose}>close</Button>
|
|
||||||
<Button
|
|
||||||
variant='primary'
|
|
||||||
onClick={() => {
|
|
||||||
clearVault()
|
|
||||||
onClose()
|
|
||||||
}}
|
|
||||||
>disconnect
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
showModal((onClose) => (
|
|
||||||
<ConnectForm onClose={onClose} onConnect={onConnect} enabled={enabled} />
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}, [enabled, connected, key])
|
|
||||||
|
|
||||||
const reset = useCallback(async () => {
|
|
||||||
const schema = yup.object().shape({
|
|
||||||
confirm: yup.string()
|
|
||||||
.oneOf(['yes'], 'you must confirm by typing "yes"')
|
|
||||||
.required('required')
|
|
||||||
})
|
|
||||||
showModal((onClose) => (
|
|
||||||
<div>
|
|
||||||
<h2>Reset device sync</h2>
|
|
||||||
<p>
|
|
||||||
This will delete all encrypted data on the server and disconnect all devices.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You will need to enter a new passphrase on this and all other devices to sync data again.
|
|
||||||
</p>
|
|
||||||
<Form
|
|
||||||
className='mt-3'
|
|
||||||
initial={{ confirm: '' }}
|
|
||||||
schema={schema}
|
|
||||||
onSubmit={async values => {
|
|
||||||
await clearVault()
|
|
||||||
onClose()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label='This action cannot be undone. Type `yes` to confirm.'
|
|
||||||
name='confirm'
|
|
||||||
placeholder=''
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
autoComplete='off'
|
|
||||||
/>
|
|
||||||
<div className='d-flex justify-content-between'>
|
|
||||||
<div className='d-flex align-items-center ms-auto'>
|
|
||||||
<CancelButton onClick={onClose} />
|
|
||||||
<SubmitButton variant='danger'>
|
|
||||||
continue
|
|
||||||
</SubmitButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onConnect = useCallback(async (values, formik) => {
|
|
||||||
if (values.passphrase) {
|
|
||||||
try {
|
|
||||||
await setVaultKey(values.passphrase)
|
|
||||||
apollo.cache.evict({ fieldName: 'BestWallets' })
|
|
||||||
apollo.cache.gc()
|
|
||||||
await apollo.refetchQueries({ include: ['BestWallets'] })
|
|
||||||
} catch (e) {
|
|
||||||
formik?.setErrors({ passphrase: e.message })
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [setVaultKey])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className='form-label mt-3'>device sync</div>
|
|
||||||
<div className='mt-2 d-flex align-items-center'>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
variant='secondary'
|
|
||||||
onClick={manage}
|
|
||||||
>
|
|
||||||
{enabled ? (connected ? 'Manage ' : 'Connect to ') : 'Enable '}
|
|
||||||
device sync
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Info>
|
|
||||||
<p>
|
|
||||||
Device sync uses end-to-end encryption to securely synchronize your data across devices.
|
|
||||||
</p>
|
|
||||||
<p className='text-muted text-sm'>
|
|
||||||
Your sensitive data remains private and inaccessible to our servers while being synced across all your connected devices using only a passphrase.
|
|
||||||
</p>
|
|
||||||
</Info>
|
|
||||||
</div>
|
|
||||||
{enabled && !connected && (
|
|
||||||
<div className='mt-2 d-flex align-items-center'>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
variant='danger'
|
|
||||||
onClick={reset}
|
|
||||||
>
|
|
||||||
Reset device sync data
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Info>
|
|
||||||
<p>
|
|
||||||
If you have lost your passphrase or wish to erase all encrypted data from the server, you can reset the device sync data and start over.
|
|
||||||
</p>
|
|
||||||
<p className='text-muted text-sm'>
|
|
||||||
This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
</Info>
|
|
||||||
</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 ConnectForm ({ onClose, onConnect, enabled }) {
|
|
||||||
const [passphrase, setPassphrase] = useState(!enabled ? generatePassphrase : '')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const scannedPassphrase = window.localStorage.getItem('qr:passphrase')
|
|
||||||
if (scannedPassphrase) {
|
|
||||||
setPassphrase(scannedPassphrase)
|
|
||||||
window.localStorage.removeItem('qr:passphrase')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const newPassphrase = useCallback(() => {
|
|
||||||
setPassphrase(() => generatePassphrase(12))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>{!enabled ? 'Enable device sync' : 'Input your passphrase'}</h2>
|
|
||||||
<p>
|
|
||||||
{!enabled
|
|
||||||
? 'Enable secure sync of sensitive data (like wallet credentials) between your devices. You’ll need to enter this passphrase on each device you want to connect.'
|
|
||||||
: 'Enter the passphrase from device sync to access your encrypted sensitive data (like wallet credentials) on the server.'}
|
|
||||||
</p>
|
|
||||||
<Form
|
|
||||||
schema={enabled ? undefined : deviceSyncSchema}
|
|
||||||
initial={{ passphrase }}
|
|
||||||
enableReinitialize
|
|
||||||
onSubmit={async (values, formik) => {
|
|
||||||
try {
|
|
||||||
await onConnect(values, formik)
|
|
||||||
onClose()
|
|
||||||
} catch {}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PasswordInput
|
|
||||||
label='passphrase'
|
|
||||||
name='passphrase'
|
|
||||||
placeholder=''
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
as='textarea'
|
|
||||||
rows={3}
|
|
||||||
readOnly={!enabled}
|
|
||||||
copy={!enabled}
|
|
||||||
append={
|
|
||||||
!enabled && (
|
|
||||||
<InputGroup.Text style={{ cursor: 'pointer', userSelect: 'none' }} onClick={newPassphrase}>
|
|
||||||
<RefreshIcon width={16} height={16} />
|
|
||||||
</InputGroup.Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className='text-muted text-sm'>
|
|
||||||
{
|
|
||||||
!enabled
|
|
||||||
? 'This passphrase is stored only on your device and cannot be shown again.'
|
|
||||||
: 'If you have forgotten your passphrase, you can reset and start over.'
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
<div className='mt-3'>
|
|
||||||
<div className='d-flex justify-content-between'>
|
|
||||||
<div className='d-flex align-items-center ms-auto gap-2'>
|
|
||||||
<CancelButton onClick={onClose} />
|
|
||||||
<SubmitButton variant='primary'>{enabled ? 'connect' : 'enable'}</SubmitButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -31,7 +31,6 @@ import { OverlayTrigger, Tooltip } from 'react-bootstrap'
|
||||||
import { useField } from 'formik'
|
import { useField } from 'formik'
|
||||||
import styles from './settings.module.css'
|
import styles from './settings.module.css'
|
||||||
import { AuthBanner } from '@/components/banners'
|
import { AuthBanner } from '@/components/banners'
|
||||||
import DeviceSync from '@/components/device-sync'
|
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
|
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
|
||||||
|
|
||||||
|
@ -612,7 +611,6 @@ export default function Settings ({ ssrData }) {
|
||||||
<div className='form-label'>saturday newsletter</div>
|
<div className='form-label'>saturday newsletter</div>
|
||||||
<Button href='https://mail.stacker.news/subscription/form' target='_blank'>(re)subscribe</Button>
|
<Button href='https://mail.stacker.news/subscription/form' target='_blank'>(re)subscribe</Button>
|
||||||
{settings?.authMethods && <AuthMethods methods={settings.authMethods} apiKeyEnabled={settings.apiKeyEnabled} />}
|
{settings?.authMethods && <AuthMethods methods={settings.authMethods} apiKeyEnabled={settings.apiKeyEnabled} />}
|
||||||
<DeviceSync />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
@ -33,7 +33,14 @@ export default function DeviceSync ({ ssrData }) {
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className='pb-3 w-100 mt-2'>
|
<div className='pb-3 w-100 mt-2'>
|
||||||
<SettingsHeader />
|
<SettingsHeader />
|
||||||
<div className='mt-3' style={{ maxWidth: '600px' }}>
|
<small className='line-height-md d-block mt-3' style={{ maxWidth: '600px' }}>
|
||||||
|
<p>
|
||||||
|
Device sync uses end-to-end encryption to securely synchronize your data across devices.
|
||||||
|
|
||||||
|
Your sensitive data remains private and inaccessible to our servers while being synced across all your connected devices using only a passphrase.
|
||||||
|
</p>
|
||||||
|
</small>
|
||||||
|
<div className='mt-4' style={{ maxWidth: '600px' }}>
|
||||||
{
|
{
|
||||||
(connected && passphrase && <Connect passphrase={passphrase} />) ||
|
(connected && passphrase && <Connect passphrase={passphrase} />) ||
|
||||||
(connected && <Connected disconnectVault={disconnectVault} />) ||
|
(connected && <Connected disconnectVault={disconnectVault} />) ||
|
||||||
|
|
|
@ -41,9 +41,11 @@ export default function WalletSettings () {
|
||||||
[field.name]: wallet?.config?.[field.name] || ''
|
[field.name]: wallet?.config?.[field.name] || ''
|
||||||
}
|
}
|
||||||
}, wallet?.config)
|
}, wallet?.config)
|
||||||
if (wallet?.def.clientOnly) {
|
|
||||||
|
if (wallet?.def.fields.every(f => f.clientOnly)) {
|
||||||
return initial
|
return initial
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...initial,
|
...initial,
|
||||||
...autowithdrawInitial({ me })
|
...autowithdrawInitial({ me })
|
||||||
|
|
|
@ -43,17 +43,23 @@ ALTER TABLE "VaultEntry" ADD CONSTRAINT "VaultEntry_userId_fkey" FOREIGN KEY ("u
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "VaultEntry" ADD CONSTRAINT "VaultEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "VaultEntry" ADD CONSTRAINT "VaultEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
CREATE FUNCTION wallet_updated_at_trigger() RETURNS TRIGGER AS $$
|
CREATE OR REPLACE FUNCTION wallet_updated_at_trigger() RETURNS TRIGGER AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
UPDATE "users" SET "walletsUpdatedAt" = NOW() WHERE "id" = NEW."userId";
|
UPDATE "users"
|
||||||
RETURN NEW;
|
SET "walletsUpdatedAt" = NOW()
|
||||||
|
WHERE "id" = CASE
|
||||||
|
WHEN TG_OP = 'DELETE'
|
||||||
|
THEN OLD."userId"
|
||||||
|
ELSE NEW."userId"
|
||||||
|
END;
|
||||||
|
RETURN NULL;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
CREATE TRIGGER wallet_updated_at_trigger
|
CREATE OR REPLACE TRIGGER wallet_updated_at_trigger
|
||||||
AFTER INSERT OR UPDATE ON "Wallet"
|
AFTER INSERT OR UPDATE OR DELETE ON "Wallet"
|
||||||
FOR EACH ROW EXECUTE PROCEDURE wallet_updated_at_trigger();
|
FOR EACH ROW EXECUTE PROCEDURE wallet_updated_at_trigger();
|
||||||
|
|
||||||
CREATE TRIGGER vault_entry_updated_at_trigger
|
CREATE OR REPLACE TRIGGER vault_entry_updated_at_trigger
|
||||||
AFTER INSERT OR UPDATE ON "VaultEntry"
|
AFTER INSERT OR UPDATE OR DELETE ON "VaultEntry"
|
||||||
FOR EACH ROW EXECUTE PROCEDURE wallet_updated_at_trigger();
|
FOR EACH ROW EXECUTE PROCEDURE wallet_updated_at_trigger();
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { blinkSchema } from '@/lib/validate'
|
|
||||||
import { string } from '@/lib/yup'
|
import { string } from '@/lib/yup'
|
||||||
|
|
||||||
export const galoyBlinkUrl = 'https://api.blink.sv/graphql'
|
export const galoyBlinkUrl = 'https://api.blink.sv/graphql'
|
||||||
|
@ -7,7 +6,6 @@ export const galoyBlinkDashboardUrl = 'https://dashboard.blink.sv/'
|
||||||
export const name = 'blink'
|
export const name = 'blink'
|
||||||
export const walletType = 'BLINK'
|
export const walletType = 'BLINK'
|
||||||
export const walletField = 'walletBlink'
|
export const walletField = 'walletBlink'
|
||||||
export const fieldValidation = blinkSchema
|
|
||||||
|
|
||||||
export const fields = [
|
export const fields = [
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
import { decodeRune } from '@/lib/cln'
|
import { decodeRune } from '@/lib/cln'
|
||||||
import { B64_URL_REGEX } from '@/lib/format'
|
import { B64_URL_REGEX } from '@/lib/format'
|
||||||
import { CLNAutowithdrawSchema } from '@/lib/validate'
|
|
||||||
import { string } from '@/lib/yup'
|
import { string } from '@/lib/yup'
|
||||||
|
|
||||||
export const name = 'cln'
|
export const name = 'cln'
|
||||||
export const walletType = 'CLN'
|
export const walletType = 'CLN'
|
||||||
export const walletField = 'walletCLN'
|
export const walletField = 'walletCLN'
|
||||||
export const fieldValidation = CLNAutowithdrawSchema
|
|
||||||
|
|
||||||
export const fields = [
|
export const fields = [
|
||||||
{
|
{
|
||||||
|
|
|
@ -127,7 +127,7 @@ export function WalletsProvider ({ children }) {
|
||||||
}
|
}
|
||||||
}, [me?.privates?.autoWithdrawMaxFeePercent, me?.privates?.autoWithdrawThreshold, me?.privates?.autoWithdrawMaxFeeTotal])
|
}, [me?.privates?.autoWithdrawMaxFeePercent, me?.privates?.autoWithdrawThreshold, me?.privates?.autoWithdrawMaxFeeTotal])
|
||||||
|
|
||||||
// if the vault key is set, and we have local wallets,
|
// whenever the vault key is set, and we have local wallets,
|
||||||
// we'll send any merged local wallets to the server, and delete them from local storage
|
// we'll send any merged local wallets to the server, and delete them from local storage
|
||||||
const syncLocalWallets = useCallback(async encrypt => {
|
const syncLocalWallets = useCallback(async encrypt => {
|
||||||
const walletsToSync = wallets.filter(w =>
|
const walletsToSync = wallets.filter(w =>
|
||||||
|
|
|
@ -4,7 +4,6 @@ import { string } from '@/lib/yup'
|
||||||
export const name = 'lnc'
|
export const name = 'lnc'
|
||||||
export const walletType = 'LNC'
|
export const walletType = 'LNC'
|
||||||
export const walletField = 'walletLNC'
|
export const walletField = 'walletLNC'
|
||||||
export const clientOnly = true
|
|
||||||
|
|
||||||
export const fields = [
|
export const fields = [
|
||||||
{
|
{
|
||||||
|
|
|
@ -76,7 +76,7 @@ function composeWalletSchema (walletDef, serverSide, skipGenerated) {
|
||||||
acc[name] = createFieldSchema(name, validate)
|
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) {
|
||||||
// if we are the server, the pairSetting will be in the vaultEntries array
|
// if we are the server, the pairSetting will be in the vaultEntries array
|
||||||
acc[name] = acc[name].when([serverSide ? 'vaultEntries' : requiredWithout], ([pairSetting], schema) => {
|
acc[name] = acc[name].when([serverSide ? 'vaultEntries' : requiredWithout], ([pairSetting], schema) => {
|
||||||
|
|
Loading…
Reference in New Issue