hasExisting = TwoFactorRecoveryCode::where('user_id', $user->id)->exists(); // Wichtig: $plainCodes bleiben leer, außer nach generate(). // So werden bereits existierende Codes nie erneut gezeigt. $this->plainCodes = []; $this->justGenerated = false; } /** Erzeugt/rotiert Codes und zeigt sie EINMALIG an */ public function generate(): void { $user = Auth::user(); // Alte Codes verwerfen/überschreiben TwoFactorRecoveryCode::where('user_id', $user->id)->delete(); // Neue Codes erzeugen (nur hier im Speicher im Klartext zeigen) $new = []; for ($i = 0; $i < $this->count; $i++) { // 10 zufällige Großbuchstaben/Ziffern $raw = strtoupper(Str::random($this->length)); $new[] = $raw; TwoFactorRecoveryCode::create([ 'user_id' => $user->id, 'code_hash' => hash('sha256', $raw), 'used_at' => null, ]); } // Einmalige Anzeige $this->plainCodes = $new; $this->hasExisting = true; $this->justGenerated = true; } /** Optional: E-Mail leicht maskieren für die Dateiüberschrift */ protected function maskEmail(?string $email): string { if (!$email || !str_contains($email, '@')) return '—'; [$name, $domain] = explode('@', $email, 2); $nameMasked = Str::substr($name, 0, 1) . str_repeat('•', max(1, strlen($name) - 2)) . Str::substr($name, -1); $domainMasked = preg_replace('/(^.).*(?=\.)/u', '$1•••', $domain); // z.B. g•••gle.com return "{$nameMasked}@{$domainMasked}"; } /** Formatiert die TXT-Datei hübsch */ protected function buildTxtContent(array $codes): string { $app = config('app.name', 'App'); $user = Auth::user(); $who = $this->maskEmail($user->email ?? ''); $now = Carbon::now()->toDateTimeString(); // Codes als "ABCDE-FGHIJ" + Nummerierung 01), 02), … $lines = []; foreach (array_values($codes) as $i => $raw) { // in 5er Blöcke mit Bindestrich $pretty = trim(chunk_split(strtoupper($raw), 5, '-'), '-'); $nr = str_pad((string)($i + 1), 2, '0', STR_PAD_LEFT); $lines[] = "{$nr}) {$pretty}"; } $body = implode(PHP_EOL, $lines); $header = <<justGenerated || empty($this->plainCodes)) { $this->dispatch('toast', body: 'Download nur direkt nach der Erstellung möglich.'); return null; } $app = Str::slug(config('app.name', 'app')); $date = now()->format('Y-m-d_His'); $filename = "{$app}-recovery-codes_{$date}.txt"; $content = $this->buildTxtContent($this->plainCodes); return response()->streamDownload(function () use ($content) { echo $content . PHP_EOL; // final newline }, $filename, [ 'Content-Type' => 'text/plain; charset=UTF-8', ]); } /** Nur direkt nach Generierung möglich */ // public function downloadTxt() // { // if (!$this->justGenerated || empty($this->plainCodes)) { // // nichts zurückgeben => Livewire bleibt im Modal // $this->dispatch('toast', body: 'Download nur direkt nach der Erstellung möglich.'); // return null; // } // // $filename = 'recovery-codes.txt'; // $content = implode(PHP_EOL, $this->plainCodes); // // return response()->streamDownload(function () use ($content) { // echo $content; // }, $filename); // } public function render() { return view('livewire.ui.security.modal.recovery-codes-modal'); } }