mirror of
https://github.com/apotdevin/thunderhub.git
synced 2026-08-13 12:33:08 +02:00
chore: initial user setup
This commit is contained in:
parent
c63ea0d76f
commit
90dfdae4e3
28 changed files with 833 additions and 55 deletions
274
package-lock.json
generated
274
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
11
schema.gql
11
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!
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
78
src/client/src/graphql/mutations/__generated__/createInitialUser.generated.tsx
generated
Normal file
78
src/client/src/graphql/mutations/__generated__/createInitialUser.generated.tsx
generated
Normal 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
|
||||
>;
|
||||
|
|
@ -11,7 +11,7 @@ export type FinalizeTapBatchMutation = {
|
|||
__typename?: 'Mutation';
|
||||
finalizeTapBatch: {
|
||||
__typename?: 'TapFinalizeBatchResponse';
|
||||
batchKey?: string | null;
|
||||
batchKey: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
13
src/client/src/graphql/mutations/createInitialUser.ts
Normal file
13
src/client/src/graphql/mutations/createInitialUser.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
@ -15,7 +15,7 @@ export type DecodeTapAddressQuery = {
|
|||
assetId: string;
|
||||
groupKey?: string | null;
|
||||
amount: string;
|
||||
assetType?: string | null;
|
||||
assetType: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export type GetTapFederationServersQuery = {
|
|||
servers: Array<{
|
||||
__typename?: 'TapFederationServer';
|
||||
host: string;
|
||||
id?: number | null;
|
||||
id: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export type GetTapTransfersQuery = {
|
|||
anchorTxHeightHint: number;
|
||||
anchorTxChainFees: string;
|
||||
transferTimestamp: string;
|
||||
label?: string | null;
|
||||
label: string;
|
||||
inputs: Array<{
|
||||
__typename?: 'TapTransferInput';
|
||||
anchorPoint: string;
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
162
src/client/src/pages/SetupPage.tsx
Normal file
162
src/client/src/pages/SetupPage.tsx
Normal 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;
|
||||
7
src/client/src/utils/crypto.ts
Normal file
7
src/client/src/utils/crypto.ts
Normal 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('');
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
7
src/server/modules/api/public/public.module.ts
Normal file
7
src/server/modules/api/public/public.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { PublicResolver } from './public.resolver';
|
||||
|
||||
@Module({
|
||||
providers: [PublicResolver],
|
||||
})
|
||||
export class PublicModule {}
|
||||
51
src/server/modules/api/public/public.resolver.ts
Normal file
51
src/server/modules/api/public/public.resolver.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
19
src/server/modules/api/public/public.types.ts
Normal file
19
src/server/modules/api/public/public.types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
9
src/server/modules/user/user.module.ts
Normal file
9
src/server/modules/user/user.module.ts
Normal 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 {}
|
||||
80
src/server/modules/user/user.service.ts
Normal file
80
src/server/modules/user/user.service.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue