Skip to content
This repository was archived by the owner on Oct 15, 2025. It is now read-only.

Commit 485580c

Browse files
committed
feat(command): Allow bulk adding users
1 parent 6591854 commit 485580c

File tree

1 file changed

+183
-0
lines changed

1 file changed

+183
-0
lines changed

src/Command/CreateUsersCommand.php

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Command;
6+
7+
use App\Entity\User;
8+
use Doctrine\ORM\EntityManagerInterface;
9+
use Random\RandomException;
10+
use Symfony\Component\Console\Attribute\AsCommand;
11+
use Symfony\Component\Console\Command\Command;
12+
use Symfony\Component\Console\Input\InputArgument;
13+
use Symfony\Component\Console\Input\InputInterface;
14+
use Symfony\Component\Console\Input\InputOption;
15+
use Symfony\Component\Console\Output\OutputInterface;
16+
use Symfony\Component\Console\Style\SymfonyStyle;
17+
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
18+
19+
#[AsCommand(
20+
name: 'app:create-users',
21+
description: 'Creates new users from a CSV file. Passwords will be generated randomly.',
22+
)]
23+
class CreateUsersCommand extends Command
24+
{
25+
public function __construct(
26+
private readonly EntityManagerInterface $entityManager,
27+
private readonly UserPasswordHasherInterface $passwordHasher,
28+
) {
29+
parent::__construct();
30+
}
31+
32+
protected function configure(): void
33+
{
34+
$this->addArgument('filename', InputArgument::REQUIRED, 'The filename of this account.');
35+
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Whether to perform a dry run.');
36+
}
37+
38+
/**
39+
* @throws RandomException
40+
*/
41+
protected function execute(InputInterface $input, OutputInterface $output): int
42+
{
43+
$io = new SymfonyStyle($input, $output);
44+
45+
/**
46+
* @var string $filename
47+
*/
48+
$filename = $input->getArgument('filename');
49+
/**
50+
* @var bool $dryRun
51+
*/
52+
$dryRun = $input->getOption('dry-run');
53+
54+
$file = fopen($filename, 'r');
55+
if (false === $file) {
56+
$io->error("Could not open file $filename for reading.");
57+
58+
return Command::FAILURE;
59+
}
60+
61+
$io->title("Creating users from $filename");
62+
63+
/**
64+
* @var array{email: string, name: string, roles: list<string>}[] $users
65+
*/
66+
$users = $io->progressIterate(self::parseUsers($filename));
67+
68+
/**
69+
* @var array{email: string, password: string}[] $userPasswordPair
70+
*/
71+
$userPasswordPair = [];
72+
73+
$this->entityManager->beginTransaction();
74+
75+
foreach ($users as $user) {
76+
$password = self::generateRandomPassword();
77+
78+
$user = (new User())->setName($user['name'])->setEmail($user['email'])->setRoles($user['roles']);
79+
$hashedPassword = $this->passwordHasher->hashPassword($user, $password);
80+
$user->setPassword($hashedPassword);
81+
82+
$this->entityManager->persist($user);
83+
84+
$userPasswordPair[] = [
85+
'email' => $user->getEmail(),
86+
'password' => $password,
87+
];
88+
}
89+
90+
self::writePasswordList(str_replace('.csv', '-password.csv', $filename), $userPasswordPair);
91+
92+
if ($dryRun) {
93+
$this->entityManager->rollback();
94+
$io->warning('Dry run completed. No changes were made.');
95+
} else {
96+
$this->entityManager->commit();
97+
$this->entityManager->flush();
98+
$io->success("Created users from $filename");
99+
}
100+
101+
return Command::SUCCESS;
102+
}
103+
104+
/**
105+
* @return array{email: string, name: string, roles: list<string>}[]
106+
*/
107+
private static function parseUsers(string $filename): array
108+
{
109+
$file = fopen($filename, 'r');
110+
if (false === $file) {
111+
throw new \RuntimeException("Could not open file $filename for reading.");
112+
}
113+
114+
$header = fgetcsv($file);
115+
if (false === $header) {
116+
throw new \RuntimeException("Could not read header from $filename.");
117+
}
118+
119+
$emailIndex = array_search('email', $header, true);
120+
$nameIndex = array_search('name', $header, true);
121+
$rolesIndex = array_search('roles', $header, true);
122+
123+
if (!\is_int($emailIndex) || !\is_int($nameIndex) || !\is_int($rolesIndex)) {
124+
throw new \RuntimeException("Could not find email, name, or roles in the header of $filename.");
125+
}
126+
127+
$users = [];
128+
while (($row = fgetcsv($file)) !== false) {
129+
$email = $row[$emailIndex];
130+
$name = $row[$nameIndex];
131+
$roles = $row[$rolesIndex];
132+
133+
if (!\is_string($email) || !\is_string($name) || !\is_string($roles)) {
134+
throw new \RuntimeException("Invalid row in $filename.");
135+
}
136+
137+
$users[] = [
138+
'email' => $email,
139+
'name' => $name,
140+
'roles' => explode(',', $roles),
141+
];
142+
}
143+
144+
fclose($file);
145+
146+
return $users;
147+
}
148+
149+
/**
150+
* @param array<array<string, string>> $userPasswordPair
151+
*/
152+
private static function writePasswordList(string $filename, array $userPasswordPair): void
153+
{
154+
$file = fopen($filename, 'w');
155+
if (false === $file) {
156+
throw new \RuntimeException("Could not open file $filename for writing.");
157+
}
158+
159+
fputcsv($file, array_keys($userPasswordPair[0]));
160+
161+
foreach ($userPasswordPair as $pair) {
162+
fputcsv($file, array_values($pair));
163+
}
164+
165+
fclose($file);
166+
}
167+
168+
/**
169+
* @throws RandomException
170+
*/
171+
private function generateRandomPassword(): string
172+
{
173+
$keyspace = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
174+
175+
$pieces = [];
176+
$max = mb_strlen($keyspace, '8bit') - 1;
177+
for ($i = 0; $i < 16; ++$i) {
178+
$pieces[] = $keyspace[random_int(0, $max)];
179+
}
180+
181+
return implode('', $pieces);
182+
}
183+
}

0 commit comments

Comments
 (0)