diff --git a/packages/admin-ui/src/Main.jsx b/packages/admin-ui/src/Main.jsx index e162b96..4600cee 100644 --- a/packages/admin-ui/src/Main.jsx +++ b/packages/admin-ui/src/Main.jsx @@ -29,11 +29,22 @@ const GET_USER_DATA = gql` const Main = () => { const [location, navigate] = useLocation() - const { wizardTested, userData, setUserData } = useContext(AppContext) + const { wizardTested, userData, setUserData} = useContext(AppContext) const [loading, setLoading] = useState(true) const [restrictionLevel, setRestrictionLevel] = useState(null) + // Skip auth queries on unauthenticated pages (like /register and /login) + const isPublicPage = location.startsWith('/register') || location.startsWith('/login') + + // Set loading to false immediately for public pages + React.useEffect(() => { + if (isPublicPage) { + setLoading(false) + } + }, [isPublicPage]) + useQuery(GET_USER_DATA, { + skip: isPublicPage, onCompleted: userResponse => { if (!userData && userResponse?.userData) { setUserData(userResponse.userData) @@ -43,6 +54,10 @@ const Main = () => { } setLoading(false) }, + onError: () => { + // If query fails, just mark as not loading + setLoading(false) + }, }) const sidebar = hasSidebar(location) diff --git a/packages/admin-ui/src/pages/Authentication/LoginState.jsx b/packages/admin-ui/src/pages/Authentication/LoginState.jsx index ef7541c..1ba1420 100644 --- a/packages/admin-ui/src/pages/Authentication/LoginState.jsx +++ b/packages/admin-ui/src/pages/Authentication/LoginState.jsx @@ -83,6 +83,11 @@ const LoginState = ({ dispatch, strategy }) => { if (!loginResponse.login) return + // Handle SKIP2FA case - directly get user data and navigate + if (loginResponse.login === 'SKIP2FA') { + return getUserData() + } + return dispatch({ type: loginResponse.login, payload: { diff --git a/packages/admin-ui/src/pages/Services/schemas/index.js b/packages/admin-ui/src/pages/Services/schemas/index.js index 695aa59..f6519a9 100644 --- a/packages/admin-ui/src/pages/Services/schemas/index.js +++ b/packages/admin-ui/src/pages/Services/schemas/index.js @@ -11,6 +11,7 @@ import inforu from './inforu' import infura from './infura' import _itbit from './itbit' import _kraken from './kraken' +import lnbits from './lnbits' import mailgun from './mailgun' import scorechain from './scorechain' import sumsub from './sumsub' @@ -31,6 +32,7 @@ const schemas = (markets = {}) => { return { [bitgo.code]: bitgo, [galoy.code]: galoy, + [lnbits.code]: lnbits, [bitstamp.code]: bitstamp, [blockcypher.code]: blockcypher, [elliptic.code]: elliptic, diff --git a/packages/admin-ui/src/pages/Services/schemas/lnbits.js b/packages/admin-ui/src/pages/Services/schemas/lnbits.js new file mode 100644 index 0000000..00d03e5 --- /dev/null +++ b/packages/admin-ui/src/pages/Services/schemas/lnbits.js @@ -0,0 +1,36 @@ +import * as Yup from 'yup' + +import { + SecretInput, + TextInput, +} from '../../../components/inputs/formik' + +import { secretTest } from './helper' + +export default { + code: 'lnbits', + name: 'LNBits', + title: 'LNBits (Wallet)', + elements: [ + { + code: 'endpoint', + display: 'LNBits Server URL', + component: TextInput, + }, + { + code: 'adminKey', + display: 'Admin Key', + component: SecretInput, + }, + ], + getValidationSchema: account => { + return Yup.object().shape({ + endpoint: Yup.string('The endpoint must be a string') + .max(200, 'The endpoint is too long') + .required('The endpoint is required'), + adminKey: Yup.string('The Admin Key must be a string') + .max(200, 'The Admin Key is too long') + .test(secretTest(account?.adminKey)), + }) + }, +} \ No newline at end of file diff --git a/packages/admin-ui/src/pages/Wizard/components/Wallet/ChooseWallet.jsx b/packages/admin-ui/src/pages/Wizard/components/Wallet/ChooseWallet.jsx index c99295b..f46bb5f 100644 --- a/packages/admin-ui/src/pages/Wizard/components/Wallet/ChooseWallet.jsx +++ b/packages/admin-ui/src/pages/Wizard/components/Wallet/ChooseWallet.jsx @@ -36,7 +36,7 @@ const SAVE_ACCOUNTS = gql` ` const isConfigurable = it => - R.includes(it)(['infura', 'bitgo', 'trongrid', 'galoy']) + R.includes(it)(['infura', 'bitgo', 'trongrid', 'galoy', 'lnbits']) const isLocalHosted = it => R.includes(it)([ @@ -178,6 +178,19 @@ const ChooseWallet = ({ data: currentData, addData }) => { /> )} + {selected === 'lnbits' && ( + <> +

Enter wallet information

+ + + )} ) } diff --git a/packages/server/lib/new-admin/config/accounts.js b/packages/server/lib/new-admin/config/accounts.js index 87a3e35..e955082 100644 --- a/packages/server/lib/new-admin/config/accounts.js +++ b/packages/server/lib/new-admin/config/accounts.js @@ -103,6 +103,7 @@ const ALL_ACCOUNTS = [ cryptos: [BTC, ZEC, LTC, BCH, DASH], }, { code: 'galoy', display: 'Galoy', class: WALLET, cryptos: [LN] }, + { code: 'lnbits', display: 'LNBits', class: WALLET, cryptos: [LN] }, { code: 'bitstamp', display: 'Bitstamp', diff --git a/packages/server/lib/new-admin/graphql/modules/userManagement.js b/packages/server/lib/new-admin/graphql/modules/userManagement.js index 492afa9..4591ed2 100644 --- a/packages/server/lib/new-admin/graphql/modules/userManagement.js +++ b/packages/server/lib/new-admin/graphql/modules/userManagement.js @@ -10,6 +10,7 @@ const users = require('../../../users') const sessionManager = require('../../../session-manager') const authErrors = require('../errors') const credentials = require('../../../hardware-credentials') +const { skip2fa } = require('../../../environment-helper') const REMEMBER_ME_AGE = 90 * T.day @@ -162,15 +163,25 @@ const deleteSession = (sessionID, context) => { return sessionManager.deleteSessionById(sessionID) } -const login = (username, password) => { +const login = (username, password, context) => { return authenticateUser(username, password) .then(user => { + // Skip 2FA if environment variable is set + if (skip2fa) { + initializeSession(context, user, false) + return 'SKIP2FA' + } + return Promise.all([ credentials.getHardwareCredentialsByUserId(user.id), user.twofa_code, ]) }) - .then(([devices, twoFASecret]) => { + .then(result => { + // If we already handled skip2fa, return the result + if (result === 'SKIP2FA') return result + + const [devices, twoFASecret] = result if (!_.isEmpty(devices)) return 'FIDO' return twoFASecret ? 'INPUT2FA' : 'SETUP2FA' }) diff --git a/packages/server/lib/new-admin/graphql/resolvers/users.resolver.js b/packages/server/lib/new-admin/graphql/resolvers/users.resolver.js index 513a341..8e2f941 100644 --- a/packages/server/lib/new-admin/graphql/resolvers/users.resolver.js +++ b/packages/server/lib/new-admin/graphql/resolvers/users.resolver.js @@ -124,8 +124,8 @@ const resolver = { sessionManager.deleteSessionsByUsername(username), changeUserRole: (...[, { confirmationCode, id, newRole }, context]) => userManagement.changeUserRole(confirmationCode, id, newRole, context), - login: (...[, { username, password }]) => - userManagement.login(username, password), + login: (...[, { username, password }, context]) => + userManagement.login(username, password, context), input2FA: (...[, { username, password, rememberMe, code }, context]) => userManagement.input2FA(username, password, rememberMe, code, context), setup2FA: ( diff --git a/packages/server/lib/new-admin/services/restriction-level.js b/packages/server/lib/new-admin/services/restriction-level.js index 63196f0..7c96af6 100644 --- a/packages/server/lib/new-admin/services/restriction-level.js +++ b/packages/server/lib/new-admin/services/restriction-level.js @@ -5,7 +5,15 @@ const { machines } = require('typesafe-db') const CACHE_DURATION = 30 * 60 * 1000 const _getHighestRestrictionLevel = async () => { - return machines.getHighestRestrictionLevel() + try { + const level = await machines.getHighestRestrictionLevel() + // Return 0 if null/undefined (no machines in database) + return level ?? 0 + } catch (err) { + // Log error and return 0 for empty database or other errors + console.error('Error fetching restriction level:', err.message) + return 0 + } } const getCachedRestrictionLevel = mem(_getHighestRestrictionLevel, { diff --git a/packages/server/lib/plugins/wallet/lnbits/lnbits.js b/packages/server/lib/plugins/wallet/lnbits/lnbits.js index e48ced7..e0fa1b1 100644 --- a/packages/server/lib/plugins/wallet/lnbits/lnbits.js +++ b/packages/server/lib/plugins/wallet/lnbits/lnbits.js @@ -93,11 +93,11 @@ async function newAddress(account, info, tx) { const endpoint = `${account.endpoint}/api/v1/payments` const result = await request(endpoint, 'POST', invoiceData, account.adminKey) - if (!result.payment_request) { - throw new Error('LNBits did not return a payment request') + if (!result.bolt11) { + throw new Error('LNBits did not return a bolt11 invoice') } - return result.payment_request + return result.bolt11 } async function getStatus(account, tx) { @@ -131,12 +131,44 @@ async function getStatus(account, tx) { } } +async function sendLNURL(account, lnurl, cryptoAtoms) { + validateConfig(account) + + const paymentData = { + lnurl: lnurl, + amount: parseInt(cryptoAtoms.toString()) * 1000, // Convert satoshis to millisatoshis + comment: `Lamassu ATM - ${new Date().toISOString()}` + } + + const endpoint = `${account.endpoint}/api/v1/payments/lnurl` + const result = await request(endpoint, 'POST', paymentData, account.adminKey) + + if (!result.payment_hash) { + throw new Error('LNBits LNURL payment failed: No payment hash returned') + } + + return { + txid: result.payment_hash, + fee: result.fee_msat ? Math.ceil(result.fee_msat / 1000) : 0 + } +} + async function sendCoins(account, tx) { validateConfig(account) const { toAddress, cryptoAtoms, cryptoCode } = tx await checkCryptoCode(cryptoCode) + // Handle LNURL addresses + if (isLnurl(toAddress)) { + return sendLNURL(account, toAddress, cryptoAtoms) + } + + // Handle bolt11 invoices + if (!isLnInvoice(toAddress)) { + throw new Error('Invalid Lightning address: must be bolt11 invoice or LNURL') + } + const paymentData = { out: true, bolt11: toAddress @@ -189,10 +221,9 @@ async function newFunding(account, cryptoCode) { const [walletBalance, fundingAddress] = await Promise.all(promises) return { - fundingAddress, - fundingAddressQr: fundingAddress, - confirmed: walletBalance.gte(0), - confirmedBalance: walletBalance.toString() + fundingPendingBalance: new BN(0), + fundingConfirmedBalance: walletBalance, + fundingAddress } } diff --git a/packages/server/migrations/1750000000000-add-lnbits-config.js b/packages/server/migrations/1750000000000-add-lnbits-config.js index 6e9c34a..351f0b1 100644 --- a/packages/server/migrations/1750000000000-add-lnbits-config.js +++ b/packages/server/migrations/1750000000000-add-lnbits-config.js @@ -1,36 +1,17 @@ -const db = require('./db') +const { saveConfig } = require('../lib/new-settings-loader') exports.up = function (next) { - const sql = ` - INSERT INTO user_config (name, display_name, type, data_type, config_type, enabled, secret, options) - VALUES - ('lnbitsEndpoint', 'LNBits Server URL', 'text', 'string', 'wallets', false, false, null), - ('lnbitsAdminKey', 'LNBits Admin Key', 'text', 'string', 'wallets', false, true, null) - ON CONFLICT (name) DO NOTHING; - - -- Add LNBits as a valid wallet option for Lightning Network - INSERT INTO user_config (name, display_name, type, data_type, config_type, enabled, secret, options) - VALUES - ('LN_wallet', 'Lightning Network Wallet', 'text', 'string', 'wallets', true, false, - '[{"code": "lnbits", "display": "LNBits"}, {"code": "galoy", "display": "Galoy (Blink)"}, {"code": "bitcoind", "display": "Bitcoin Core"}]') - ON CONFLICT (name) - DO UPDATE SET options = EXCLUDED.options - WHERE user_config.options NOT LIKE '%lnbits%'; - ` + const config = { + 'lnbits_endpoint': '', + 'lnbits_adminKey': '', + 'LN_wallet': 'lnbits' + } - db.multi(sql, next) + saveConfig(config).then(next).catch(next) } exports.down = function (next) { - const sql = ` - DELETE FROM user_config - WHERE name IN ('lnbitsEndpoint', 'lnbitsAdminKey'); - - -- Remove LNBits from wallet options - UPDATE user_config - SET options = REPLACE(options, ', {"code": "lnbits", "display": "LNBits"}', '') - WHERE name = 'LN_wallet'; - ` - - db.multi(sql, next) + // No-op - removing config entries is not typically done in down migrations + // as it could break existing configurations + next() } \ No newline at end of file diff --git a/packages/server/package.json b/packages/server/package.json index 696271d..d72f949 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -31,6 +31,7 @@ "bchaddrjs": "^0.3.0", "bignumber.js": "9.0.1", "bip39": "^2.3.1", + "bolt11": "^1.4.1", "ccxt": "2.9.16", "compression": "^1.7.4", "connect-pg-simple": "^6.2.1",