diff --git a/packages/cli/package.json b/packages/cli/package.json index a666d374d..f6bd416b8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -27,6 +27,7 @@ "@homarr/auth": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0", + "@homarr/validation": "workspace:^0.1.0", "dotenv": "^16.4.7" }, "devDependencies": { diff --git a/packages/cli/src/commands/recreate-admin.ts b/packages/cli/src/commands/recreate-admin.ts new file mode 100644 index 000000000..59af3bade --- /dev/null +++ b/packages/cli/src/commands/recreate-admin.ts @@ -0,0 +1,95 @@ +import { command, string } from "@drizzle-team/brocli"; + +import { createSaltAsync, hashPasswordAsync } from "@homarr/auth"; +import { generateSecureRandomToken } from "@homarr/common/server"; +import { and, count, createId, db, eq } from "@homarr/db"; +import { getMaxGroupPositionAsync } from "@homarr/db/queries"; +import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema"; +import { usernameSchema } from "@homarr/validation"; + +export const recreateAdmin = command({ + name: "recreate-admin", + desc: "Recreate credentials admin user if none exists anymore", + options: { + username: string("username").required().alias("u").desc("Name of the admin"), + }, + // eslint-disable-next-line no-restricted-syntax + handler: async (options) => { + if (!process.env.AUTH_PROVIDERS?.toLowerCase().includes("credentials")) { + console.error("Credentials provider is not enabled"); + return; + } + + const result = await usernameSchema.safeParseAsync(options.username); + + if (!result.success) { + console.error("Invalid username:"); + console.error(result.error.errors.map((error) => `- ${error.message}`).join("\n")); + return; + } + + const totalCount = await db + .select({ + count: count(), + }) + .from(groupPermissions) + .leftJoin(groupMembers, eq(groupMembers.groupId, groupPermissions.groupId)) + .leftJoin(users, eq(users.id, groupMembers.userId)) + .where(and(eq(groupPermissions.permission, "admin"), eq(users.provider, "credentials"))) + .then((rows) => rows.at(0)?.count ?? 0); + + if (totalCount > 0) { + console.error("Credentials admin user exists"); + return; + } + + const existingUser = await db.query.users.findFirst({ + where: eq(users.name, result.data), + }); + + if (existingUser) { + console.error("User with this name already exists"); + return; + } + + const temporaryGroupId = createId(); + + const maxPosition = await getMaxGroupPositionAsync(db); + await db.insert(groups).values({ + id: temporaryGroupId, + name: temporaryGroupId, + position: maxPosition + 1, + }); + + await db.insert(groupPermissions).values({ + groupId: temporaryGroupId, + permission: "admin", + }); + + const salt = await createSaltAsync(); + const password = generateSecureRandomToken(24); + const hashedPassword = await hashPasswordAsync(password, salt); + + const userId = createId(); + await db.insert(users).values({ + id: userId, + name: result.data, + provider: "credentials", + password: hashedPassword, + salt, + }); + + await db.insert(groupMembers).values({ + groupId: temporaryGroupId, + userId, + }); + + console.log( + "We created a new admin user for you. Please keep in mind, that the admin group of it has a temporary name. You should change it to something more meaningful.", + ); + console.log(`\tUsername: ${result.data}`); + console.log(`\tPassword: ${password}`); + console.log(`\tGroup: ${temporaryGroupId}`); + console.log(""); // Empty line for better readability + }, +}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8b56f9c5c..0041651b5 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,9 +1,10 @@ import { run } from "@drizzle-team/brocli"; import { fixUsernames } from "./commands/fix-usernames"; +import { recreateAdmin } from "./commands/recreate-admin"; import { resetPassword } from "./commands/reset-password"; -const commands = [resetPassword, fixUsernames]; +const commands = [resetPassword, fixUsernames, recreateAdmin]; void run(commands, { name: "homarr-cli", diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index caf2ccee4..7bc9e9407 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -37,6 +37,6 @@ export { type BoardItemIntegration, } from "./shared"; export { superRefineCertificateFile } from "./certificates"; -export { passwordRequirements } from "./user"; +export { passwordRequirements, usernameSchema } from "./user"; export { supportedMediaUploadFormats } from "./media"; export { zodEnumFromArray, zodUnionFromArray } from "./enums"; diff --git a/packages/validation/src/user.ts b/packages/validation/src/user.ts index 91eae05eb..9e7c9da73 100644 --- a/packages/validation/src/user.ts +++ b/packages/validation/src/user.ts @@ -8,7 +8,7 @@ import { zodEnumFromArray } from "./enums"; import { createCustomErrorParams } from "./form/i18n"; // We always want the lowercase version of the username to compare it in a case-insensitive way -const usernameSchema = z.string().trim().toLowerCase().min(3).max(255); +export const usernameSchema = z.string().trim().toLowerCase().min(3).max(255); const regexCheck = (regex: RegExp) => (value: string) => regex.test(value); export const passwordRequirements = [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12a1e5c5d..6973e5168 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -783,6 +783,9 @@ importers: '@homarr/db': specifier: workspace:^0.1.0 version: link:../db + '@homarr/validation': + specifier: workspace:^0.1.0 + version: link:../validation dotenv: specifier: ^16.4.7 version: 16.4.7