diff --git a/package-lock.json b/package-lock.json index 73c22f03..c34eb2f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@nestjs/schedule": "^6.1.1", "@nestjs/serve-static": "^5.0.4", "@nestjs/throttler": "^6.5.0", + "@node-rs/argon2": "^2.0.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", @@ -70,6 +71,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-grid-layout": "^2.2.2", + "react-hook-form": "^7.72.0", "react-hot-toast": "^2.6.0", "react-router-dom": "^7.13.1", "react-tooltip": "^5.30.0", @@ -5762,6 +5764,255 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@node-rs/argon2": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.0.2.tgz", + "integrity": "sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@node-rs/argon2-android-arm-eabi": "2.0.2", + "@node-rs/argon2-android-arm64": "2.0.2", + "@node-rs/argon2-darwin-arm64": "2.0.2", + "@node-rs/argon2-darwin-x64": "2.0.2", + "@node-rs/argon2-freebsd-x64": "2.0.2", + "@node-rs/argon2-linux-arm-gnueabihf": "2.0.2", + "@node-rs/argon2-linux-arm64-gnu": "2.0.2", + "@node-rs/argon2-linux-arm64-musl": "2.0.2", + "@node-rs/argon2-linux-x64-gnu": "2.0.2", + "@node-rs/argon2-linux-x64-musl": "2.0.2", + "@node-rs/argon2-wasm32-wasi": "2.0.2", + "@node-rs/argon2-win32-arm64-msvc": "2.0.2", + "@node-rs/argon2-win32-ia32-msvc": "2.0.2", + "@node-rs/argon2-win32-x64-msvc": "2.0.2" + } + }, + "node_modules/@node-rs/argon2-android-arm-eabi": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.0.2.tgz", + "integrity": "sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-android-arm64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.0.2.tgz", + "integrity": "sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-arm64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.0.2.tgz", + "integrity": "sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-x64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.0.2.tgz", + "integrity": "sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-freebsd-x64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.0.2.tgz", + "integrity": "sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm-gnueabihf": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.0.2.tgz", + "integrity": "sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-gnu": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.0.2.tgz", + "integrity": "sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-musl": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.0.2.tgz", + "integrity": "sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-gnu": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.0.2.tgz", + "integrity": "sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-musl": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.0.2.tgz", + "integrity": "sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-wasm32-wasi": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-wasm32-wasi/-/argon2-wasm32-wasi-2.0.2.tgz", + "integrity": "sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@node-rs/argon2-win32-arm64-msvc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.0.2.tgz", + "integrity": "sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-ia32-msvc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.0.2.tgz", + "integrity": "sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-x64-msvc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.0.2.tgz", + "integrity": "sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -17632,9 +17883,10 @@ } }, "node_modules/node-gyp-build": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.7.1.tgz", - "integrity": "sha512-wTSrZ+8lsRRa3I3H8Xr65dLWSgCvY2l4AOnaeKdPA9TB/WYMPaTcrzf3rXvFoVvjKNVnu0CcWSx54qq9GKRUYg==", + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", @@ -18925,6 +19177,22 @@ "react-dom": ">= 16.3.0" } }, + "node_modules/react-hook-form": { + "version": "7.72.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.0.tgz", + "integrity": "sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-hot-toast": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", diff --git a/package.json b/package.json index 04834f49..78325b71 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@nestjs/schedule": "^6.1.1", "@nestjs/serve-static": "^5.0.4", "@nestjs/throttler": "^6.5.0", + "@node-rs/argon2": "^2.0.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", @@ -118,6 +119,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-grid-layout": "^2.2.2", + "react-hook-form": "^7.72.0", "react-hot-toast": "^2.6.0", "react-router-dom": "^7.13.1", "react-tooltip": "^5.30.0", diff --git a/schema.gql b/schema.gql index 03971ac2..a8586d1b 100644 --- a/schema.gql +++ b/schema.gql @@ -251,6 +251,12 @@ type CreateBoltzReverseSwapType { timeoutBlockHeight: Float! } +type CreateInitialUserResult { + email: String! + id: String! + role: String! +} + type CreateInvoice { chain_address: String created_at: String! @@ -415,6 +421,7 @@ type Mutation { newTapAddress(amt: Int, assetId: String, groupKey: String): TapAddress! openChannel(input: OpenChannelParams!): OpenOrCloseChannel! pay(max_fee: Float!, max_paths: Float!, out: [String!], request: String!): Boolean! + public: PublicMutation! purchaseLiquidity(amount_cents: String!): Boolean! pushBackup: Boolean! removePeer(publicKey: String): Boolean! @@ -655,6 +662,10 @@ type Policy { updated_at: String } +type PublicMutation { + create_initial_user(email: String!, password: String!): CreateInitialUserResult! +} + type Query { decodeTapAddress(addr: String!): TapAddress! getAccount: ServerAccount! diff --git a/src/client/src/App.tsx b/src/client/src/App.tsx index 9f02661f..7515a503 100644 --- a/src/client/src/App.tsx +++ b/src/client/src/App.tsx @@ -36,6 +36,7 @@ import SettingsPage from './pages/SettingsPage'; import AmbossPage from './pages/AmbossPage'; import AssetsPage from './pages/AssetsPage'; import TradingPage from './pages/TradingPage'; +import SetupPage from './pages/SetupPage'; const DashboardPage = lazy(() => import('./pages/DashboardPage')); const SettingsDashboardPage = lazy( @@ -51,7 +52,11 @@ const NotAuthenticated: FC = () => { const navigate = useNavigate(); useEffect(() => { - navigate(config.logoutUrl || '/login'); + if (config.needsSetup) { + navigate('/setup'); + } else { + navigate(config.logoutUrl || '/login'); + } }, [navigate]); return null; @@ -83,12 +88,13 @@ const Wrapper: FC<{ children?: ReactNode }> = ({ children }) => { applyTheme(theme === 'dark' ? 'dark' : 'light'); }, [theme]); + const isRoot = + pathname === '/login' || pathname === '/sso' || pathname === '/setup'; + const { data, loading, error } = useGetNodeInfoQuery({ fetchPolicy: 'network-only', - skip: pathname === '/login' || pathname === '/sso', + skip: isRoot, }); - - const isRoot = pathname === '/login' || pathname === '/sso'; const authenticated = !isRoot && !loading && !error && !!data?.getNodeInfo; const checking = !isRoot && loading; @@ -154,6 +160,7 @@ const AuthenticatedRoutes = () => ( } /> } /> } /> + } /> } /> ); diff --git a/src/client/src/components/ui/input.tsx b/src/client/src/components/ui/input.tsx index b885e0a6..a47d30f3 100644 --- a/src/client/src/components/ui/input.tsx +++ b/src/client/src/components/ui/input.tsx @@ -2,18 +2,23 @@ import * as React from 'react'; import { cn } from '@/lib/utils'; -function Input({ className, type, ...props }: React.ComponentProps<'input'>) { - return ( - - ); -} +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); + +Input.displayName = 'Input'; export { Input }; diff --git a/src/client/src/config/thunderhubConfig.ts b/src/client/src/config/thunderhubConfig.ts index 81461988..dff02772 100644 --- a/src/client/src/config/thunderhubConfig.ts +++ b/src/client/src/config/thunderhubConfig.ts @@ -11,6 +11,8 @@ interface ThunderhubConfig { logoutUrl: string; disable2FA: boolean; npmVersion: string; + dbEnabled: boolean; + needsSetup: boolean; } export const config: ThunderhubConfig = { @@ -26,6 +28,8 @@ export const config: ThunderhubConfig = { logoutUrl: '', disable2FA: false, npmVersion: '0.0.1', + dbEnabled: false, + needsSetup: false, }; export function initConfig(data: Partial): void { diff --git a/src/client/src/graphql/mutations/__generated__/createInitialUser.generated.tsx b/src/client/src/graphql/mutations/__generated__/createInitialUser.generated.tsx new file mode 100644 index 00000000..3adc799e --- /dev/null +++ b/src/client/src/graphql/mutations/__generated__/createInitialUser.generated.tsx @@ -0,0 +1,78 @@ +import * as Types from '../../types'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type CreateInitialUserMutationVariables = Types.Exact<{ + email: Types.Scalars['String']['input']; + password: Types.Scalars['String']['input']; +}>; + +export type CreateInitialUserMutation = { + __typename?: 'Mutation'; + public: { + __typename?: 'PublicMutation'; + create_initial_user: { + __typename?: 'CreateInitialUserResult'; + id: string; + email: string; + role: string; + }; + }; +}; + +export const CreateInitialUserDocument = gql` + mutation CreateInitialUser($email: String!, $password: String!) { + public { + create_initial_user(email: $email, password: $password) { + id + email + role + } + } + } +`; +export type CreateInitialUserMutationFn = Apollo.MutationFunction< + CreateInitialUserMutation, + CreateInitialUserMutationVariables +>; + +/** + * __useCreateInitialUserMutation__ + * + * To run a mutation, you first call `useCreateInitialUserMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateInitialUserMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [createInitialUserMutation, { data, loading, error }] = useCreateInitialUserMutation({ + * variables: { + * email: // value for 'email' + * password: // value for 'password' + * }, + * }); + */ +export function useCreateInitialUserMutation( + baseOptions?: Apollo.MutationHookOptions< + CreateInitialUserMutation, + CreateInitialUserMutationVariables + > +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useMutation< + CreateInitialUserMutation, + CreateInitialUserMutationVariables + >(CreateInitialUserDocument, options); +} +export type CreateInitialUserMutationHookResult = ReturnType< + typeof useCreateInitialUserMutation +>; +export type CreateInitialUserMutationResult = + Apollo.MutationResult; +export type CreateInitialUserMutationOptions = Apollo.BaseMutationOptions< + CreateInitialUserMutation, + CreateInitialUserMutationVariables +>; diff --git a/src/client/src/graphql/mutations/__generated__/finalizeTapBatch.generated.tsx b/src/client/src/graphql/mutations/__generated__/finalizeTapBatch.generated.tsx index 2efa9d4f..a93814a4 100644 --- a/src/client/src/graphql/mutations/__generated__/finalizeTapBatch.generated.tsx +++ b/src/client/src/graphql/mutations/__generated__/finalizeTapBatch.generated.tsx @@ -11,7 +11,7 @@ export type FinalizeTapBatchMutation = { __typename?: 'Mutation'; finalizeTapBatch: { __typename?: 'TapFinalizeBatchResponse'; - batchKey?: string | null; + batchKey: string; }; }; diff --git a/src/client/src/graphql/mutations/__generated__/mintTapAsset.generated.tsx b/src/client/src/graphql/mutations/__generated__/mintTapAsset.generated.tsx index 652f9854..35555743 100644 --- a/src/client/src/graphql/mutations/__generated__/mintTapAsset.generated.tsx +++ b/src/client/src/graphql/mutations/__generated__/mintTapAsset.generated.tsx @@ -13,7 +13,7 @@ export type MintTapAssetMutationVariables = Types.Exact<{ export type MintTapAssetMutation = { __typename?: 'Mutation'; - mintTapAsset: { __typename?: 'TapMintResponse'; batchKey?: string | null }; + mintTapAsset: { __typename?: 'TapMintResponse'; batchKey: string }; }; export const MintTapAssetDocument = gql` diff --git a/src/client/src/graphql/mutations/createInitialUser.ts b/src/client/src/graphql/mutations/createInitialUser.ts new file mode 100644 index 00000000..8728c9ac --- /dev/null +++ b/src/client/src/graphql/mutations/createInitialUser.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +export const CREATE_INITIAL_USER = gql` + mutation CreateInitialUser($email: String!, $password: String!) { + public { + create_initial_user(email: $email, password: $password) { + id + email + role + } + } + } +`; diff --git a/src/client/src/graphql/queries/__generated__/decodeTapAddress.generated.tsx b/src/client/src/graphql/queries/__generated__/decodeTapAddress.generated.tsx index d2ec09b3..0f3ba052 100644 --- a/src/client/src/graphql/queries/__generated__/decodeTapAddress.generated.tsx +++ b/src/client/src/graphql/queries/__generated__/decodeTapAddress.generated.tsx @@ -15,7 +15,7 @@ export type DecodeTapAddressQuery = { assetId: string; groupKey?: string | null; amount: string; - assetType?: string | null; + assetType: string; }; }; diff --git a/src/client/src/graphql/queries/__generated__/getTapFederationServers.generated.tsx b/src/client/src/graphql/queries/__generated__/getTapFederationServers.generated.tsx index 52136161..a4b1b939 100644 --- a/src/client/src/graphql/queries/__generated__/getTapFederationServers.generated.tsx +++ b/src/client/src/graphql/queries/__generated__/getTapFederationServers.generated.tsx @@ -15,7 +15,7 @@ export type GetTapFederationServersQuery = { servers: Array<{ __typename?: 'TapFederationServer'; host: string; - id?: number | null; + id: number; }>; }; }; diff --git a/src/client/src/graphql/queries/__generated__/getTapTransfers.generated.tsx b/src/client/src/graphql/queries/__generated__/getTapTransfers.generated.tsx index 9da890d4..895535d1 100644 --- a/src/client/src/graphql/queries/__generated__/getTapTransfers.generated.tsx +++ b/src/client/src/graphql/queries/__generated__/getTapTransfers.generated.tsx @@ -17,7 +17,7 @@ export type GetTapTransfersQuery = { anchorTxHeightHint: number; anchorTxChainFees: string; transferTimestamp: string; - label?: string | null; + label: string; inputs: Array<{ __typename?: 'TapTransferInput'; anchorPoint: string; diff --git a/src/client/src/graphql/types.ts b/src/client/src/graphql/types.ts index cf469f20..77246171 100644 --- a/src/client/src/graphql/types.ts +++ b/src/client/src/graphql/types.ts @@ -302,6 +302,13 @@ export type CreateBoltzReverseSwapType = { timeoutBlockHeight: Scalars['Float']['output']; }; +export type CreateInitialUserResult = { + __typename?: 'CreateInitialUserResult'; + email: Scalars['String']['output']; + id: Scalars['String']['output']; + role: Scalars['String']['output']; +}; + export type CreateInvoice = { __typename?: 'CreateInvoice'; chain_address?: Maybe; @@ -481,6 +488,7 @@ export type Mutation = { newTapAddress: TapAddress; openChannel: OpenOrCloseChannel; pay: Scalars['Boolean']['output']; + public: PublicMutation; purchaseLiquidity: Scalars['Boolean']['output']; pushBackup: Scalars['Boolean']['output']; removePeer: Scalars['Boolean']['output']; @@ -924,6 +932,16 @@ export type Policy = { updated_at?: Maybe; }; +export type PublicMutation = { + __typename?: 'PublicMutation'; + create_initial_user: CreateInitialUserResult; +}; + +export type PublicMutationCreate_Initial_UserArgs = { + email: Scalars['String']['input']; + password: Scalars['String']['input']; +}; + export type Query = { __typename?: 'Query'; decodeTapAddress: TapAddress; @@ -1093,7 +1111,7 @@ export type TapAddress = { __typename?: 'TapAddress'; amount: Scalars['String']['output']; assetId: Scalars['String']['output']; - assetType?: Maybe; + assetType: Scalars['String']['output']; encoded: Scalars['String']['output']; groupKey?: Maybe; internalKey: Scalars['String']['output']; @@ -1174,7 +1192,7 @@ export type TapBalances = { export type TapFederationServer = { __typename?: 'TapFederationServer'; host: Scalars['String']['output']; - id?: Maybe; + id: Scalars['Int']['output']; }; export type TapFederationServerList = { @@ -1185,7 +1203,7 @@ export type TapFederationServerList = { export type TapFinalizeBatchResponse = { __typename?: 'TapFinalizeBatchResponse'; - batchKey?: Maybe; + batchKey: Scalars['String']['output']; }; export type TapFundChannelInput = { @@ -1205,7 +1223,7 @@ export type TapFundChannelResponse = { export type TapMintResponse = { __typename?: 'TapMintResponse'; - batchKey?: Maybe; + batchKey: Scalars['String']['output']; }; export enum TapOfferSortBy { @@ -1276,7 +1294,7 @@ export type TapTransfer = { anchorTxHash: Scalars['String']['output']; anchorTxHeightHint: Scalars['Int']['output']; inputs: Array; - label?: Maybe; + label: Scalars['String']['output']; outputs: Array; transferTimestamp: Scalars['String']['output']; }; @@ -1306,7 +1324,7 @@ export type TapUniverseAsset = { assetId?: Maybe; groupKey?: Maybe; name?: Maybe; - proofType?: Maybe; + proofType: Scalars['String']['output']; totalSupply: Scalars['String']['output']; }; diff --git a/src/client/src/pages/LoginPage.tsx b/src/client/src/pages/LoginPage.tsx index a4b9a4fa..f581dfdb 100644 --- a/src/client/src/pages/LoginPage.tsx +++ b/src/client/src/pages/LoginPage.tsx @@ -1,19 +1,32 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import { appendBasePath } from '../utils/basePath'; import { TopSection } from '../views/homepage/Top'; import { Accounts } from '../views/homepage/Accounts'; +import { config } from '@/config/thunderhubConfig'; -const LoginPage = () => ( -
- -
- - +const LoginPage = () => { + const navigate = useNavigate(); + + useEffect(() => { + if (config.needsSetup) { + navigate('/setup'); + } + }, [navigate]); + + return ( +
+ +
+ + +
-
-); + ); +}; export default LoginPage; diff --git a/src/client/src/pages/SetupPage.tsx b/src/client/src/pages/SetupPage.tsx new file mode 100644 index 00000000..6a80ad82 --- /dev/null +++ b/src/client/src/pages/SetupPage.tsx @@ -0,0 +1,162 @@ +import { useForm } from 'react-hook-form'; +import { appendBasePath } from '../utils/basePath'; +import { TopSection } from '../views/homepage/Top'; +import { useCreateInitialUserMutation } from '../graphql/mutations/__generated__/createInitialUser.generated'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { hashPassword } from '@/utils/crypto'; + +type SetupFormValues = { + email: string; + password: string; + confirmPassword: string; +}; + +const SetupPage = () => { + const { + register, + handleSubmit, + setError, + formState: { errors }, + } = useForm(); + + const [createInitialUser, { loading, error: mutationError }] = + useCreateInitialUserMutation({ + onCompleted: () => { + window.location.href = appendBasePath('/login'); + }, + }); + + const onSubmit = async (data: SetupFormValues) => { + if (data.password !== data.confirmPassword) { + setError('confirmPassword', { message: 'Passwords do not match' }); + return; + } + + const hashedPassword = await hashPassword(data.password); + createInitialUser({ + variables: { email: data.email, password: hashedPassword }, + }); + }; + + return ( +
+ +
+ +
+ + + Initial Setup + + Create the first owner account to get started with ThunderHub. + + + +
+
+ + + {errors.email && ( +

+ {errors.email.message} +

+ )} +
+
+ + + {errors.password && ( +

+ {errors.password.message} +

+ )} +
+
+ + + {errors.confirmPassword && ( +

+ {errors.confirmPassword.message} +

+ )} +
+ {mutationError && ( +

+ {mutationError.message} +

+ )} + +
+
+
+
+
+
+ ); +}; + +export default SetupPage; diff --git a/src/client/src/utils/crypto.ts b/src/client/src/utils/crypto.ts new file mode 100644 index 00000000..52951c94 --- /dev/null +++ b/src/client/src/utils/crypto.ts @@ -0,0 +1,7 @@ +export async function hashPassword(password: string): Promise { + const encoded = new TextEncoder().encode(password); + const digest = await crypto.subtle.digest('SHA-256', encoded); + return Array.from(new Uint8Array(digest)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} diff --git a/src/server/app.module.ts b/src/server/app.module.ts index 16472400..ea5b8142 100644 --- a/src/server/app.module.ts +++ b/src/server/app.module.ts @@ -27,6 +27,7 @@ import { } from './modules/dataloader/dataloader.service'; import { DataloaderModule } from './modules/dataloader/dataloader.module'; import { DatabaseModule } from './modules/database/database.module'; +import { UserModule } from './modules/user/user.module'; const { combine, timestamp, prettyPrint, json } = format; @@ -59,6 +60,7 @@ export type JwtObjectType = { AccountsModule, FetchModule, DatabaseModule, + UserModule, ScheduleModule.forRoot(), ConfigModule.forRoot({ isGlobal: true, diff --git a/src/server/config/configuration.ts b/src/server/config/configuration.ts index a2d293e3..54aca28a 100644 --- a/src/server/config/configuration.ts +++ b/src/server/config/configuration.ts @@ -68,6 +68,7 @@ export type ClientConfig = { logoutUrl: string; disable2FA: boolean; npmVersion: string; + dbEnabled: boolean; }; type SqliteConfig = { @@ -214,6 +215,7 @@ export default (): ConfigType => { logoutUrl: process.env.LOGOUT_URL || '', disable2FA: process.env.DISABLE_TWOFA === 'true', npmVersion, + dbEnabled: !!process.env.DB_TYPE, }; const config: ConfigType = { diff --git a/src/server/modules/api/api.module.ts b/src/server/modules/api/api.module.ts index ef72ae9f..a7bada6e 100644 --- a/src/server/modules/api/api.module.ts +++ b/src/server/modules/api/api.module.ts @@ -22,6 +22,7 @@ import { InvoicesModule } from './invoices/invoices.module'; import { BoltzModule } from './boltz/boltz.module'; import { UserConfigModule } from './userConfig/userConfig.module'; import { TapdApiModule } from './tapd/tapd.module'; +import { PublicModule } from './public/public.module'; @Module({ imports: [ @@ -48,6 +49,7 @@ import { TapdApiModule } from './tapd/tapd.module'; InvoicesModule, BoltzModule, TapdApiModule, + PublicModule, ], }) export class ApiModule {} diff --git a/src/server/modules/api/public/public.module.ts b/src/server/modules/api/public/public.module.ts new file mode 100644 index 00000000..e3047c8b --- /dev/null +++ b/src/server/modules/api/public/public.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { PublicResolver } from './public.resolver'; + +@Module({ + providers: [PublicResolver], +}) +export class PublicModule {} diff --git a/src/server/modules/api/public/public.resolver.ts b/src/server/modules/api/public/public.resolver.ts new file mode 100644 index 00000000..903098f0 --- /dev/null +++ b/src/server/modules/api/public/public.resolver.ts @@ -0,0 +1,51 @@ +import { Args, Mutation, ResolveField, Resolver } from '@nestjs/graphql'; +import { Inject } from '@nestjs/common'; +import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; +import { Logger } from 'winston'; +import { Public } from '../../security/security.decorators'; +import { Throttle, seconds } from '@nestjs/throttler'; +import { UserService } from '../../user/user.service'; +import { CreateInitialUserResult, PublicMutation } from './public.types'; + +@Resolver(() => PublicMutation) +export class PublicResolver { + constructor( + private readonly userService: UserService, + @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger + ) {} + + @Public() + @Mutation(() => PublicMutation) + async public() { + return {}; + } + + @Public() + @Throttle({ default: { limit: 4, ttl: seconds(10) } }) + @ResolveField(() => CreateInitialUserResult) + async create_initial_user( + @Args('email') email: string, + @Args('password') password: string + ): Promise { + if (!this.userService.isDbEnabled()) { + throw new Error('Database is not enabled'); + } + + const needsSetup = await this.userService.needsSetup(); + if (!needsSetup) { + throw new Error('Initial setup has already been completed'); + } + + if (!email || !email.includes('@')) { + throw new Error('A valid email is required'); + } + + if (!password || password.length < 8) { + throw new Error('Password must be at least 8 characters'); + } + + this.logger.info('Creating initial owner user', { email }); + + return this.userService.createInitialUser(email, password); + } +} diff --git a/src/server/modules/api/public/public.types.ts b/src/server/modules/api/public/public.types.ts new file mode 100644 index 00000000..42fd0c87 --- /dev/null +++ b/src/server/modules/api/public/public.types.ts @@ -0,0 +1,19 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class CreateInitialUserResult { + @Field() + id: string; + + @Field() + email: string; + + @Field() + role: string; +} + +@ObjectType() +export class PublicMutation { + @Field(() => CreateInitialUserResult) + create_initial_user: CreateInitialUserResult; +} diff --git a/src/server/modules/clientConfig/clientConfig.controller.ts b/src/server/modules/clientConfig/clientConfig.controller.ts index 56ac561a..acbb69a8 100644 --- a/src/server/modules/clientConfig/clientConfig.controller.ts +++ b/src/server/modules/clientConfig/clientConfig.controller.ts @@ -3,15 +3,23 @@ import { ConfigService } from '@nestjs/config'; import { SkipThrottle } from '@nestjs/throttler'; import { Public } from '../security/security.decorators'; import { ClientConfig } from '../../config/configuration'; +import { UserService } from '../user/user.service'; @Controller('api') @Public() @SkipThrottle() export class ClientConfigController { - constructor(private configService: ConfigService) {} + constructor( + private configService: ConfigService, + private userService: UserService + ) {} @Get('config') - getConfig(): ClientConfig { - return this.configService.get('clientConfig'); + async getConfig(): Promise { + const clientConfig = this.configService.get('clientConfig'); + + const needsSetup = await this.userService.needsSetup(); + + return { ...clientConfig, needsSetup }; } } diff --git a/src/server/modules/database/database.module.ts b/src/server/modules/database/database.module.ts index 6ca320d5..70978b4f 100644 --- a/src/server/modules/database/database.module.ts +++ b/src/server/modules/database/database.module.ts @@ -1,9 +1,9 @@ import { Global, Module } from '@nestjs/common'; -import { DrizzleProvider, DRIZZLE } from './drizzle.provider'; +import { drizzleProvider, DRIZZLE } from './drizzle.provider'; @Global() @Module({ - providers: [DrizzleProvider], + providers: [drizzleProvider], exports: [DRIZZLE], }) export class DatabaseModule {} diff --git a/src/server/modules/database/drizzle.provider.ts b/src/server/modules/database/drizzle.provider.ts index 2baac57f..4e110847 100644 --- a/src/server/modules/database/drizzle.provider.ts +++ b/src/server/modules/database/drizzle.provider.ts @@ -7,20 +7,28 @@ import { migrate as migratePg } from 'drizzle-orm/postgres-js/migrator'; import Database from 'better-sqlite3'; import postgres from 'postgres'; import { join } from 'path'; +import * as sqliteSchema from './schema/sqlite'; +import * as pgSchema from './schema/pg'; export const DRIZZLE = Symbol('DRIZZLE'); export type DrizzleDB = | ReturnType - | ReturnType - | null; + | ReturnType; + +export type DbSchema = typeof sqliteSchema | typeof pgSchema; + +export type DrizzleProvider = { + db: DrizzleDB; + schema: DbSchema; +} | null; const logger = new Logger('DatabaseProvider'); -export const DrizzleProvider: FactoryProvider = { +export const drizzleProvider: FactoryProvider = { provide: DRIZZLE, inject: [ConfigService], - useFactory: async (config: ConfigService): Promise => { + useFactory: async (config: ConfigService): Promise => { const dbType = config.get('database.type'); if (!dbType) return null; @@ -34,9 +42,11 @@ export const DrizzleProvider: FactoryProvider = { } const db = drizzlePg(postgres(url)); logger.log('Running PostgreSQL migrations...'); - await migratePg(db, { migrationsFolder: join(migrationsRoot, 'pg') }); + await migratePg(db, { + migrationsFolder: join(migrationsRoot, 'pg'), + }); logger.log('PostgreSQL migrations complete.'); - return db; + return { db, schema: pgSchema }; } if (dbType === 'sqlite') { @@ -50,7 +60,7 @@ export const DrizzleProvider: FactoryProvider = { migrationsFolder: join(migrationsRoot, 'sqlite'), }); logger.log('SQLite migrations complete.'); - return db; + return { db, schema: sqliteSchema }; } throw new Error(`Unsupported DB_TYPE: ${dbType}`); diff --git a/src/server/modules/user/user.module.ts b/src/server/modules/user/user.module.ts new file mode 100644 index 00000000..e1124949 --- /dev/null +++ b/src/server/modules/user/user.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { UserService } from './user.service'; + +@Global() +@Module({ + providers: [UserService], + exports: [UserService], +}) +export class UserModule {} diff --git a/src/server/modules/user/user.service.ts b/src/server/modules/user/user.service.ts new file mode 100644 index 00000000..6e9d921f --- /dev/null +++ b/src/server/modules/user/user.service.ts @@ -0,0 +1,80 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { DRIZZLE, DrizzleProvider } from '../database/drizzle.provider'; +import { count } from 'drizzle-orm'; +import { hash } from '@node-rs/argon2'; + +@Injectable() +export class UserService { + private readonly logger = new Logger(UserService.name); + + constructor(@Inject(DRIZZLE) private readonly drizzle: DrizzleProvider) {} + + isDbEnabled(): boolean { + return this.drizzle !== null; + } + + async hasUsers(): Promise { + if (!this.drizzle) return false; + + const { db, schema } = this.drizzle; + const rows = await (db as any) + .select({ count: count() }) + .from(schema.users); + + return Number(rows[0]?.count ?? 0) > 0; + } + + async needsSetup(): Promise { + if (!this.isDbEnabled()) return false; + return !(await this.hasUsers()); + } + + async createInitialUser( + email: string, + password: string + ): Promise<{ id: string; email: string; role: string }> { + if (!this.drizzle) { + throw new Error('Database is not enabled'); + } + + const hasExistingUsers = await this.hasUsers(); + if (hasExistingUsers) { + throw new Error('Initial setup has already been completed'); + } + + const { db, schema } = this.drizzle; + const passwordHash = await hash(password); + + const teamRows = await (db as any) + .insert(schema.teams) + .values({ name: 'Default' }) + .returning({ id: schema.teams.id }); + + const teamId = teamRows[0]?.id; + if (!teamId) { + throw new Error('Failed to create default team'); + } + + const userRows = await (db as any) + .insert(schema.users) + .values({ + email, + password_hash: passwordHash, + role: 'owner', + team_id: teamId, + }) + .returning({ + id: schema.users.id, + email: schema.users.email, + role: schema.users.role, + }); + + if (!userRows[0]) { + throw new Error('Failed to create owner user'); + } + + this.logger.log(`Initial owner user created: ${email}`); + + return userRows[0]; + } +}