-
})
-
-
-
+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.
+
+
+
+
+
+
+
+
+
+ );
+};
+
+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];
+ }
+}