chore: initial user setup

This commit is contained in:
Anthony Potdevin 2026-04-01 20:36:28 -06:00
parent c63ea0d76f
commit 90dfdae4e3
No known key found for this signature in database
GPG key ID: 4403F1DFBE779457
28 changed files with 833 additions and 55 deletions

274
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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!

View file

@ -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 = () => (
<Route path="/trading" element={<TradingPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/sso" element={<SsoPage />} />
<Route path="/setup" element={<SetupPage />} />
<Route path="*" element={<HomePage />} />
</Routes>
);

View file

@ -2,18 +2,23 @@ import * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'h-8 w-full min-w-0 rounded-none border border-input bg-transparent px-2.5 py-1 text-xs transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-xs file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 md:text-xs dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className
)}
{...props}
/>
);
}
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
ref={ref}
data-slot="input"
className={cn(
'h-8 w-full min-w-0 rounded-none border border-input bg-transparent px-2.5 py-1 text-xs transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-xs file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 md:text-xs dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className
)}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };

View file

@ -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<ThunderhubConfig>): void {

View file

@ -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<CreateInitialUserMutation>;
export type CreateInitialUserMutationOptions = Apollo.BaseMutationOptions<
CreateInitialUserMutation,
CreateInitialUserMutationVariables
>;

View file

@ -11,7 +11,7 @@ export type FinalizeTapBatchMutation = {
__typename?: 'Mutation';
finalizeTapBatch: {
__typename?: 'TapFinalizeBatchResponse';
batchKey?: string | null;
batchKey: string;
};
};

View file

@ -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`

View file

@ -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
}
}
}
`;

View file

@ -15,7 +15,7 @@ export type DecodeTapAddressQuery = {
assetId: string;
groupKey?: string | null;
amount: string;
assetType?: string | null;
assetType: string;
};
};

View file

@ -15,7 +15,7 @@ export type GetTapFederationServersQuery = {
servers: Array<{
__typename?: 'TapFederationServer';
host: string;
id?: number | null;
id: number;
}>;
};
};

View file

@ -17,7 +17,7 @@ export type GetTapTransfersQuery = {
anchorTxHeightHint: number;
anchorTxChainFees: string;
transferTimestamp: string;
label?: string | null;
label: string;
inputs: Array<{
__typename?: 'TapTransferInput';
anchorPoint: string;

View file

@ -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<Scalars['String']['output']>;
@ -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<Scalars['String']['output']>;
};
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<Scalars['String']['output']>;
assetType: Scalars['String']['output'];
encoded: Scalars['String']['output'];
groupKey?: Maybe<Scalars['String']['output']>;
internalKey: Scalars['String']['output'];
@ -1174,7 +1192,7 @@ export type TapBalances = {
export type TapFederationServer = {
__typename?: 'TapFederationServer';
host: Scalars['String']['output'];
id?: Maybe<Scalars['Int']['output']>;
id: Scalars['Int']['output'];
};
export type TapFederationServerList = {
@ -1185,7 +1203,7 @@ export type TapFederationServerList = {
export type TapFinalizeBatchResponse = {
__typename?: 'TapFinalizeBatchResponse';
batchKey?: Maybe<Scalars['String']['output']>;
batchKey: Scalars['String']['output'];
};
export type TapFundChannelInput = {
@ -1205,7 +1223,7 @@ export type TapFundChannelResponse = {
export type TapMintResponse = {
__typename?: 'TapMintResponse';
batchKey?: Maybe<Scalars['String']['output']>;
batchKey: Scalars['String']['output'];
};
export enum TapOfferSortBy {
@ -1276,7 +1294,7 @@ export type TapTransfer = {
anchorTxHash: Scalars['String']['output'];
anchorTxHeightHint: Scalars['Int']['output'];
inputs: Array<TapTransferInput>;
label?: Maybe<Scalars['String']['output']>;
label: Scalars['String']['output'];
outputs: Array<TapTransferOutput>;
transferTimestamp: Scalars['String']['output'];
};
@ -1306,7 +1324,7 @@ export type TapUniverseAsset = {
assetId?: Maybe<Scalars['String']['output']>;
groupKey?: Maybe<Scalars['String']['output']>;
name?: Maybe<Scalars['String']['output']>;
proofType?: Maybe<Scalars['String']['output']>;
proofType: Scalars['String']['output'];
totalSupply: Scalars['String']['output'];
};

View file

@ -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 = () => (
<div className="relative min-h-screen">
<img
alt=""
src={appendBasePath('/static/thunderstorm.webp')}
className="absolute inset-0 z-[-1] h-72 w-full object-cover bg-background"
/>
<div className="flex flex-col gap-6 pb-12 pt-8">
<TopSection />
<Accounts />
const LoginPage = () => {
const navigate = useNavigate();
useEffect(() => {
if (config.needsSetup) {
navigate('/setup');
}
}, [navigate]);
return (
<div className="relative min-h-screen">
<img
alt=""
src={appendBasePath('/static/thunderstorm.webp')}
className="absolute inset-0 z-[-1] h-72 w-full object-cover bg-background"
/>
<div className="flex flex-col gap-6 pb-12 pt-8">
<TopSection />
<Accounts />
</div>
</div>
</div>
);
);
};
export default LoginPage;

View file

@ -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<SetupFormValues>();
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 (
<div className="relative min-h-screen">
<img
alt=""
src={appendBasePath('/static/thunderstorm.webp')}
className="absolute inset-0 z-[-1] h-72 w-full object-cover bg-background"
/>
<div className="flex flex-col gap-6 pb-12 pt-8">
<TopSection />
<div className="mx-auto w-full max-w-md px-4">
<Card>
<CardHeader>
<CardTitle>Initial Setup</CardTitle>
<CardDescription>
Create the first owner account to get started with ThunderHub.
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<label
htmlFor="email"
className="text-xs font-medium text-muted-foreground"
>
Email
</label>
<Input
id="email"
type="email"
placeholder="admin@example.com"
autoComplete="email"
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Please enter a valid email address',
},
})}
/>
{errors.email && (
<p className="text-xs text-destructive">
{errors.email.message}
</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="password"
className="text-xs font-medium text-muted-foreground"
>
Password
</label>
<Input
id="password"
type="password"
placeholder="Minimum 8 characters"
autoComplete="new-password"
{...register('password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters',
},
})}
/>
{errors.password && (
<p className="text-xs text-destructive">
{errors.password.message}
</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="confirmPassword"
className="text-xs font-medium text-muted-foreground"
>
Confirm Password
</label>
<Input
id="confirmPassword"
type="password"
placeholder="Repeat your password"
autoComplete="new-password"
{...register('confirmPassword', {
required: 'Please confirm your password',
})}
/>
{errors.confirmPassword && (
<p className="text-xs text-destructive">
{errors.confirmPassword.message}
</p>
)}
</div>
{mutationError && (
<p className="text-xs text-destructive">
{mutationError.message}
</p>
)}
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Creating account...' : 'Create Owner Account'}
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
</div>
);
};
export default SetupPage;

View file

@ -0,0 +1,7 @@
export async function hashPassword(password: string): Promise<string> {
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('');
}

View file

@ -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,

View file

@ -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 = {

View file

@ -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 {}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { PublicResolver } from './public.resolver';
@Module({
providers: [PublicResolver],
})
export class PublicModule {}

View file

@ -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<CreateInitialUserResult> {
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);
}
}

View file

@ -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;
}

View file

@ -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>('clientConfig');
async getConfig(): Promise<ClientConfig & { needsSetup: boolean }> {
const clientConfig = this.configService.get<ClientConfig>('clientConfig');
const needsSetup = await this.userService.needsSetup();
return { ...clientConfig, needsSetup };
}
}

View file

@ -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 {}

View file

@ -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<typeof drizzleSqlite>
| ReturnType<typeof drizzlePg>
| null;
| ReturnType<typeof drizzlePg>;
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<DrizzleDB> => {
useFactory: async (config: ConfigService): Promise<DrizzleProvider> => {
const dbType = config.get<string>('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}`);

View file

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { UserService } from './user.service';
@Global()
@Module({
providers: [UserService],
exports: [UserService],
})
export class UserModule {}

View file

@ -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<boolean> {
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<boolean> {
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];
}
}