# Plan — Ofis T1: Ön Yüz Temeli ve Giriş

> **Uygulayıcı için:** Bu plan görev görev uygulanır. Adımlar `- [ ]` kutucuklarıyla takip edilir. Her görev kendi test döngüsünü taşır ve bağımsız olarak commit edilir.

*Sürüm 1.0 — 28.07.2026. Ticket: `EGT-54`. Spec: `docs/specs/ofis-onyuz-spec.md` v1.4 (heyet 149 APPROVED).*

**Hedef:** Ofis panelinin ön yüz temelini kurmak ve giriş yolculuğunu uçtan uca çalışır hale getirmek — tasarım token'ları koda, atomic iskelet yerine, kimlik ekranları mevcut backend akışlarının üstüne.

**Mimari:** Vue 3 + Inertia + Tailwind 4. Kabuk route grubundan seçilir, derleme girişi panel başına ayrılır. Çeviri çalışma zamanında Inertia prop'u olarak gelir. Sunucu tarafı mevcut katman sözleşmesine uyar: Controller iş yapmaz, Action tek `__invoke` taşır, sorgu `app/Queries/` altındadır.

**Stack:** Vue 3.5 · `@inertiajs/vue3` 3.6 · `inertiajs/inertia-laravel` 3.1 · Tailwind 4 · Vite 8 · Pest · Playwright (yalnız responsive ve erişilebilirlik ölçümü).

---

## Global kısıtlar

Her görevin gereklilikleri bu bölümü **örtük olarak içerir**.

- **Dil:** kod tarafı İngilizce (dosya, sınıf, metot, değişken, yorum, PHPDoc, test adı); arayüz Türkçe ve **yalnız** `lang/` üzerinden. Koda gömülü kullanıcı metni yasak. `tests/Arch/LocaleTest.php` bunu `.vue` dosyalarında da denetliyor.
- **Token dışı değer yasak:** renk, spacing, radius, gölge — hepsi `resources/css/tokens.css`'ten. Ham hex (`#1E262E`) ve keyfi Tailwind değeri (`bg-[#…]`, `p-[13px]`) reddedilir.
- **Katman sözleşmesi (AD-25, `docs/arch/kod-yerlesimi.md`):** Controller'da query builder ve `validate()` yok · Action tek public `__invoke` · sorgu `app/Queries/` · doğrulama `app/Http/Requests/` · DTO `app/Data/` · framework'e bağlı ama kararsız yardımcı `app/Support/`.
- **Test grubu:** ofis omurga ekranlarının testleri `tests/Feature/Omurga/` altındadır ve `omurga` grubuna girer (dizin grubu belirler, `tests/Pest.php`). Grupsuz test `tests/Arch/TestDisciplineTest.php` tarafından reddedilir.
- **Testte çıplak `now()` yok** — saat `tests/Pest.php`'de global olarak donduruluyor; başka bir an gerekiyorsa `travelTo()`.
- **Audit:** kritik işlem **aynı transaction'da** loglanır, kuyruğa atılmaz (kırmızı çizgi 13).
- **`env()` yalnız `config/` içinde.**
- **Commit mesajı:** ilk satır `#EGT-54 In Progress work <süre>`, ikinci satır conventional commit. AI attribution yasak (hook reddeder). Gövdede "Fixed" gibi YouTrack komut kelimesi kullanma — hook onu da reddeder.
- **Kalite kapısı — geliştirme sırasında LOKAL** *(Sets, 28.07.2026)*: `./vendor/bin/pint --dirty` → `composer analyse` → `./vendor/bin/pest --group=omurga` → commit. **Görev başına TeamCity tetiklenmez.** Her düzenlemeden sonra CI beklemek işi bitirmiyor; lokal koşu geliştirme sırasında yeterli sinyaldir.
- **Kalite kapısı — ticket kapanışında TEAMCITY:** `./deployment/scripts/teamcity.sh trigger` + `wait <id>`, **yeşil**. Yeşil full run olmadan `EGT-54` kapanmaz ve "tamamlandı" denmez. Tek istisna görev 1 adım 14'tür: orada CI'nın kendisi değiştiği için değişikliğin çalıştığı **o anda** doğrulanır.
- **Migration yok.** T1 şema değiştirmez. (T2 ve T3 değiştirir.)

### Bu planın spec'e eklediği tek türetme

⚠ **İkinci faktörü kurulu olan ofis kullanıcısı her girişte faktörünü kanıtlar.** Spec §5.2 (v1.15) kuralı oturuma sorar ve *"passkey kaydetmek şifreyle girişi sessizce tek faktöre düşürüyordu"* vakasını arşivler; spec'in T1 ekran listesinde de "ikinci faktör doğrulama" var. Ama *"kurulu olan her girişte sorulur"* cümlesi spec'te açıkça yazılı değil. Yazılmazsa faktör dekoratif kalır: kişi kurar, kimse sormaz.

Bu plan kuralı şöyle uygular ve **Sets'in itirazına açıktır**: `two_factor_confirmed_at` doluysa şifre adımından sonra doğrulama adımı gelir; boşsa gelmez (karar 6 — ofiste kurulum dayatılmaz). Karar 6 bozulmaz, çünkü zorlanan şey kurulum değil, **kurulmuş olanın kullanılması**.

---

## Dosya haritası

**Yeni — ön yüz**

| Dosya | Sorumluluk |
|---|---|
| `resources/css/tokens.css` | `00 Tokenlar`'ın tamamı, Tailwind 4 `@theme` bloğu. Tek kaynak. |
| `resources/css/office.css` | Ofis derleme girişinin stil kökü: `tailwindcss` + `tokens.css` + `@font-face` + taban stiller. |
| `resources/fonts/` | Archivo ve IBM Plex Mono woff2 dosyaları. |
| `resources/js/office.js` | Inertia girişi. Yalnız `./pages/Office/**` çözer. |
| `resources/js/lib/i18n.js` | `t(key, replacements)` — anahtar, `:değişken`, `\|` çoğul. |
| `resources/js/lib/format.js` | Tarih, para, telefon biçimleyicileri. |
| `resources/js/atoms/*.vue` | Tek görevli, durumsuz kontroller. |
| `resources/js/molecules/*.vue` | Etiket+kontrol+hata bileşimleri, rozet, kilit notu, modal. |
| `resources/js/organisms/*.vue` | Sidebar, topbar. |
| `resources/js/templates/*.vue` | `AuthLayout`, `PanelLayout`, `ErrorLayout`. |
| `resources/js/pages/Office/**/*.vue` | Inertia sayfaları — yalnız kompozisyon ve veri bağlama. |
| `resources/views/office.blade.php` | Ofis kök şablonu: iskelet, `<html lang>`, ofis derleme girişi. |

**Yeni — sunucu**

| Dosya | Sorumluluk |
|---|---|
| `app/Http/Middleware/HandleInertiaRequests.php` | Paylaşılan prop'lar; kök şablonu route grubu belirler. |
| `app/Support/Inertia/TranslationBundle.php` | Sayfanın bildirdiği çeviri gruplarını diziye çevirir. |
| `app/Http/Controllers/Office/DashboardController.php` | Kokpit kabuğu (T1'de boş içerik + kabuk). |
| `app/Http/Controllers/Office/ProfileSecurityController.php` | Profil → güvenlik ekranı. |
| `app/Http/Controllers/Auth/OfficeSecondFactorController.php` | Giriş sonrası ikinci faktör adımı. |
| `app/Http/Controllers/Auth/OfficePasswordResetController.php` | Şifre sıfırlama: iste ve uygula. |
| `app/Support/Auth/AppAuthenticator.php` | `pragmarx/google2fa` sarmalayıcı: sır üret, QR üret, kod doğrula. |
| `app/Actions/Auth/EnrolAppAuthentication.php` | Sır üretir ve oturumda tutar (henüz kaydetmez). |
| `app/Actions/Auth/ConfirmAppAuthentication.php` | Kodu doğrular, sırrı ve kurtarma kodlarını yazar, audit'ler. |
| `app/Actions/Auth/DisableAppAuthentication.php` | Kapatır, audit'ler. |
| `app/Actions/Auth/AnswerSecondFactorChallenge.php` | Giriş adımındaki kodu (veya kurtarma kodunu) doğrular. |
| `app/Actions/Auth/RequestOfficePasswordReset.php` | Sıfırlama bağlantısı gönderir. |
| `app/Actions/Auth/ResetOfficePassword.php` | Şifreyi değiştirir, tüm oturumları kapatır, audit'ler. |
| `app/Http/Requests/Auth/*` | Her form için ayrı Request. |
| `lang/{tr,en}/office.php` | Ofis arayüz metinleri. |

**Değişecek**

| Dosya | Değişiklik |
|---|---|
| `vite.config.js` | Vue eklentisi; ofis girişi; bunny font eklentisi kaldırılır (fontlar repoda). |
| `package.json` | `vue`, `@inertiajs/vue3`, `@vitejs/plugin-vue`; Playwright dev bağımlılığı. |
| `bootstrap/app.php` | `HandleInertiaRequests` web grubuna eklenir. |
| `routes/office.php` | Ekran rotaları; mevcut uçların Inertia yanıtına dönmesi. |
| `app/Http/Controllers/Auth/OfficeSessionController.php` | `JsonResponse` → Inertia/redirect. |
| `app/Http/Controllers/Office/UserController.php`, `SystemLogController.php` | T1'de **dokunulmaz** — T2'nin işi. |
| `config/security.php` | `default_methods.office`'a `magic_link` ve `passkey` (karar 11; akışları T3'te). |
| `tests/Arch/LocaleTest.php` | Attribute/prop taraması eklenir. |
| `docs/arch/kod-yerlesimi.md`, `docs/arch/mimari-kararlar.md` | Spec §7'nin revizyonları. |

---

## Görev 0: Üst belge revizyonlarını işle

Spec §7'nin listesi. Kod yazmadan önce, çünkü sonraki görevler bu kurallara dayanacak.

**Dosyalar:**
- Değişecek: `docs/arch/kod-yerlesimi.md` (§3, §4), `docs/arch/mimari-kararlar.md` (AD-25), `docs/specs/omurga-spec.md` (§5.3 iki madde, §5.5, §14 kriter 25), `docs/issues.md`, `current_todo.md`

- [ ] **Adım 1: `kod-yerlesimi.md` §4'ün token cümlesini düzelt**

Mevcut: *"Design token'ları **tek CSS değişken kaynağından** hem Vue hem Filament tarafına beslenir — iki ayrı palet oluşmaz."*

Yeni:

```markdown
- Design token'ları **tek CSS değişken kaynağından** Filament dışındaki tüm ön yüzlere beslenir. Filament kendi paletini taşır: tasarımcı onu kapsam dışı bıraktı ve backoffice kendi bileşen sistemiyle boyandı (ofis spec'i karar 8, 28.07.2026). Sınır Filament'tir — sayım değil dışlama, çünkü sayım yarın doğacak yüzeyi dışarıda bırakır.
```

- [ ] **Adım 2: `kod-yerlesimi.md` §3'e derleme girişi kuralını ekle**

`resources/js/` ağacının altına:

```markdown
**Derleme girişi panel başına ayrılır.** `office` · `portal` (müşteri) · `participant` · `landing`. Her kök şablon kendi girişini çağırır; hangi şablonun kullanılacağını route grubu söyler. Katılımcı neredeyse tamamen mobil ve hayatında bir-iki kez girer — tek pakette ofisin tablo ve matris kodu o telefona da inerdi. Ortak atom ve moleküller Vite'ın paylaşılan chunk'ına düşer; kod tekrarı doğmaz.

⚠ Bir giriş dosyası **yalnız kendi sayfa dizinini** çözer (`import.meta.glob('./pages/Office/**/*.vue')`). Glob'u genişletmek paylaşımı değil sızmayı üretir.
```

- [ ] **Adım 3: AD-25'in son cümlesini netleştir**

`docs/arch/mimari-kararlar.md:373`, cümlenin son hâli:

```markdown
Design token'ları **tek CSS değişken kaynağından** Filament dışındaki tüm ön yüzlere beslenir.
```

- [ ] **Adım 4: `omurga-spec.md` §5.3'e ofis niteleyicilerini ekle**

*"Magic link panel yetkisini AÇMAZ"* maddesinin sonuna:

```markdown
⚠ **Ofis istisnası** *(Sets, 28.07.2026 — ofis spec'i karar 7)*: ofis kimliğinde magic link **tek başına** hâlâ panel açmaz, ama üzerine ikinci faktör kanıtlandıysa oturum güçlüdür. Paylaşılan bağlantının sahibi ikinci faktörü veremez. Müşteri ve katılımcı tarafında kural aynen geçerlidir.
```

*"her oturumda ek kimlik teyidi"* maddesinin sonuna:

```markdown
⚠ **Ofiste teyit adımının yerini ikinci faktör alır** *(Sets, 28.07.2026 — ofis spec'i §5.4)*. Ofis `users` tablosu TCKN taşımaz, dolayısıyla `national_id_last_four` orada sorulamaz; adımın kendisi kaldırılmaz, **daha güçlüsüyle** değiştirilir. İkinci faktörü kurulu olmayan kişi ara ekrana düşer ve kurmadan devam edemez. Müşteri ve katılımcıda soru aynen `national_id_last_four`'dur.
```

- [ ] **Adım 5: §5.5 ve §14 kriter 25'e niteleyici ekle**

§5.5'in cümlesi:

```markdown
… magic link'in ikinci kullanımının reddedildiği ve magic link oturumunun panel ekranlarını açmadığı gösterilir (ofiste: ikinci faktör kanıtlanmadan açmadığı, kanıtlandıktan sonra açtığı — §5.3 ofis istisnası).
```

Kriter 25:

```markdown
25. Panel yetkili kişi magic link oturumundayken panel ekranına eriştiğinde şifre/2FA isteniyor; katılımcı ekranları erişilebilir kalıyor (§5.3). Ofiste ikinci faktör kanıtlandığında panel ekranı açılıyor (§5.3 ofis istisnası).
```

- [ ] **Adım 6: `docs/issues.md` passkey kaydını adresle**

*"Passkey — üç kimlik alanı, tek authenticatable model"* bölümünün sonuna:

```markdown
**Adres:** ofis passkey'i `EGT-56` (Ofis T3). Kendi tablosu, kendi modeli ve ofis relying-party id'si o turda doğar.
```

- [ ] **Adım 7: `config/security.php`'nin ofis yöntem tohumunu güncelle** *(heyet bulgusu P-6)*

Karar 11'in tek kod revizyonu. Spec §7 listeliyordu, hiçbir görev üstlenmemişti.

```php
    'default_methods' => [
        'office' => ['password', 'two_factor', 'magic_link', 'passkey'],
```

⚠ Bugün eklemek zararsız: bu bir **tohum varsayılanıdır**, kalıcı ayar veritabanındadır (AD-08) ve `AuthSettings` açık olmayan yöntemi zaten reddeder. Akışlar T3'te doğar; ayarın önce açılması, T3'ün ilk gününde bir config değişikliği beklememesini sağlar.

- [ ] **Adım 8: `current_todo.md` §7'nin birinci maddesini kapat**

*"kısıtla"* satırını kapanmış olarak işaretle: hız sınırı, yaptırım değil; elle uygulanabilen yaptırım dondur ve kapat (ofis spec'i §8.1).

- [ ] **Adım 9: Commit**

```bash
git add docs/ current_todo.md config/security.php
git commit -m "#EGT-54 In Progress work 30m

docs: ofis spec 7'nin ust belge revizyonlari islendi"
```

---

## Görev 1: Inertia + Vue kurulumu ve ofis kabuğu

İlk çalışan Inertia sayfası. Bu görev bitince ofis host'unda bir Vue sayfası render oluyor ve testi var.

**Dosyalar:**
- Oluştur: `resources/views/office.blade.php`, `resources/js/office.js`, `resources/js/pages/Office/Dashboard.vue`, `app/Http/Middleware/HandleInertiaRequests.php`, `app/Http/Controllers/Office/DashboardController.php`
- Değiştir: `package.json`, `vite.config.js`, `bootstrap/app.php`, `routes/office.php`
- Test: `tests/Feature/Omurga/OfficeShellTest.php`

**Arayüzler:**
- Üretir: `HandleInertiaRequests` paylaşılan prop sözleşmesi — `auth`, `navigation`, `flash`, `locale`, `translations`. Sonraki görevler bu adlara dayanır.
- Üretir: `office.dashboard` rota adı.

- [ ] **Adım 1: Bağımlılıkları kur**

```bash
npm install --save vue@^3.5.40 @inertiajs/vue3@^3.6.1
npm install --save-dev @vitejs/plugin-vue@^6.0.8
```

- [ ] **Adım 2: `vite.config.js`'i güncelle**

`bunny()` font eklentisi kaldırılır — fontlar görev 2'de repoya alınıyor ve dış istek atılmayacak.

```javascript
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
    plugins: [
        laravel({
            // One entry per panel (kod-yerlesimi.md 3). The office bundle must
            // not carry the participant's screens, nor the other way round.
            input: [
                'resources/css/app.css',
                'resources/js/app.js',
                'resources/css/office.css',
                'resources/js/office.js',
            ],
            refresh: true,
        }),
        vue({
            template: {
                transformAssetUrls: {
                    base: null,
                    includeAbsolute: false,
                },
            },
        }),
        tailwindcss(),
    ],
    server: {
        watch: {
            ignored: ['**/storage/framework/views/**'],
        },
    },
});
```

- [ ] **Adım 3: Kök şablonu yaz**

`resources/views/office.blade.php`:

```blade
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title inertia>{{ config('app.name') }}</title>
    @vite(['resources/css/office.css', 'resources/js/office.js'])
    @inertiaHead
</head>
<body class="min-h-screen bg-bg text-ink-800 antialiased">
    @inertia
</body>
</html>
```

⚠ `bg-bg` ve `text-ink-800` görev 2'de doğuyor. Bu görevde stil dosyası henüz yok; adım 5'te `resources/css/office.css` geçici olarak yalnız `@import 'tailwindcss';` içerir ve görev 2 onu doldurur.

- [ ] **Adım 4: Inertia girişini yaz**

`resources/js/office.js`:

```javascript
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';

createInertiaApp({
    // Only this panel's pages. Widening the glob would not share code — Vite
    // already shares what is imported in common — it would ship the customer
    // portal to an office browser (kod-yerlesimi.md 3).
    resolve: (name) => {
        const pages = import.meta.glob('./pages/Office/**/*.vue', { eager: true });

        return pages[`./pages/Office/${name}.vue`];
    },
    setup({ el, App, props, plugin }) {
        createApp({ render: () => h(App, props) })
            .use(plugin)
            .mount(el);
    },
});
```

- [ ] **Adım 5: Geçici stil kökünü oluştur**

`resources/css/office.css`:

```css
@import 'tailwindcss';

@source '../js/**/*.vue';
```

- [ ] **Adım 6: Paylaşılan prop middleware'ini yaz**

`app/Http/Middleware/HandleInertiaRequests.php`:

```php
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use App\Queries\Office\OfficeNavigation;
use App\Support\Auth\CurrentIdentityDomain;
use App\Support\Inertia\TranslationBundle;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Middleware;

/**
 * The props every office page receives (ofis spec 4.2).
 *
 * ⚠ The root view is NOT chosen here. It is bound by the route group
 * (ofis spec, karar 4): the public host alone serves three faces — the landing
 * page, the customer portal and the participant flow — and the last two share
 * one identity domain. A shell picked from the identity domain would be right
 * in the office and wrong on the public host from day one.
 */
final class HandleInertiaRequests extends Middleware
{
    public function share(Request $request): array
    {
        $domain = app(CurrentIdentityDomain::class)->get();
        $user = Auth::guard($domain->guard())->user();

        return [
            ...parent::share($request),
            'auth' => $user === null ? null : [
                'name' => $user->name,
                'email' => $user->email,
                // Rendered by the avatar atom. Computed here rather than in
                // JavaScript: initials of Turkish names depend on locale
                // aware upper casing, which the browser does differently.
                'initials' => $this->initials((string) $user->name),
                'has_second_factor' => $user->hasCompletedMfa(),
            ],
            'navigation' => $user === null ? [] : app(OfficeNavigation::class)->for($user),
            'flash' => [
                'success' => $request->session()->get('flash.success'),
                'warning' => $request->session()->get('flash.warning'),
            ],
            'locale' => app()->getLocale(),
            'translations' => fn (): array => app(TranslationBundle::class)->forRequest(),
        ];
    }

    private function initials(string $name): string
    {
        $letters = collect(preg_split('/\s+/u', trim($name)) ?: [])
            ->filter()
            ->take(2)
            ->map(fn (string $part): string => mb_strtoupper(mb_substr($part, 0, 1), 'UTF-8'));

        return $letters->implode('');
    }
}
```

⚠ `TranslationBundle` görev 3'te doğuyor. Bu görevde geçici bir sınıf yazma — **görev 3'e kadar `'translations' => []`** bırak ve görev 3 satırı değiştirsin. Yarım bir sınıf, iki yerde güncellenmesi gereken bir sözleşme demektir.

- [ ] **Adım 7: Middleware'i kaydet**

`bootstrap/app.php` içinde `$middleware->web(append: [...])` listesine, `SetLocale::class`'tan **sonra** (çeviri prop'u dilin çözülmüş olmasını ister) ve `LogUsage::class`'tan **önce**:

```php
            SetLocale::class,
            // Shares the office props. After SetLocale, because the translation
            // bundle is resolved in the request's language; before LogUsage,
            // which reads the finished response.
            HandleInertiaRequests::class,
```

- [ ] **Adım 8: Kök şablonu route grubuna bağla**

`routes/office.php` başına, ilk rotadan önce:

```php
/*
| The shell is bound here rather than derived from the identity domain
| (ofis spec, karar 4). What the root view says is the HTML skeleton, the
| document language and which build entry loads — not the layout; sidebar or
| no sidebar is a Vue template's decision.
*/
Inertia::setRootView('office');
```

- [ ] **Adım 9: Kokpit controller'ını ve sayfasını yaz**

`app/Http/Controllers/Office/DashboardController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Office;

use App\Http\Controllers\Controller;
use Inertia\Inertia;
use Inertia\Response;

/**
 * The office cockpit.
 *
 * ⚠ Empty on purpose in T1. What fills it — the planner's queue, the seller's
 * signals — comes from modules that are not written yet; a cockpit built on
 * invented numbers would have to be rebuilt when the real ones arrive.
 */
final class DashboardController extends Controller
{
    public function __invoke(): Response
    {
        return Inertia::render('Dashboard');
    }
}
```

`resources/js/pages/Office/Dashboard.vue`:

```vue
<script setup>
defineProps({
    auth: { type: Object, default: null },
});
</script>

<template>
    <div data-testid="office-dashboard">
        <h1>{{ auth?.name }}</h1>
    </div>
</template>
```

⚠ Bu geçici sayfa görev 8'de `PanelLayout` altına alınacak ve başlık çeviriye bağlanacak. Şimdilik `auth.name` bir **veri** değeri, arayüz metni değil — `LocaleTest`'in ham metin kuralına takılmaz.

- [ ] **Adım 10: Rotayı ekle**

`routes/office.php`, `auth:office` grubunun içine, kullanıcı yönetimi rotalarından önce:

```php
    Route::get('/', DashboardController::class)->name('office.dashboard');
```

Grubun dışındaki mevcut `Route::get('/', fn () => response()->noContent())->name('office.home')` satırı **kaldırılır** — aynı yolu iki kez tanımlamak, ikincisini ölü bırakır.

- [ ] **Adım 10b: Kaldırılan rotanın mevcut testini üstlen** *(heyet bulgusu P-3)*

⚠ `tests/Feature/Omurga/HostRoutingTest.php:36` ve `:44` **`office.home` adına bakıyor** ve `:34` misafir isteğinde `assertNoContent()` bekliyor. Rota adı değişip `/` kimlik doğrulama arkasına girince ikisi de kırılır. Plan bunu üstlenmezse ilk `--group=omurga` koşusunda kırmızı sürpriz olarak bulunur.

İki assert şöyle değişir:

```php
it('serves the office routes on the office host', function (): void {
    // The cockpit is behind authentication now, so a guest is redirected —
    // which still proves the office file answered on this host.
    $this->get('http://'.config('app.hosts.office').'/')->assertRedirect();

    expect(Route::currentRouteName())->toBe('office.dashboard');
});

it('does not answer office routes on the public host', function (): void {
    $this->get('http://'.config('app.hosts.public').'/');

    expect(Route::currentRouteName())->not->toBe('office.dashboard');
});
```

`tests/Feature/Omurga/HostRoutingTest.php` bu görevin "Değiştir" listesine eklenir.

- [ ] **Adım 11: Testi yaz**

`tests/Feature/Omurga/OfficeShellTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\User;
use Inertia\Testing\AssertableInertia;

it('renders the office cockpit for a signed in office user', function (): void {
    $user = User::factory()->create(['name' => 'Elif Karaca']);

    $this->actingAs($user, 'office')
        ->get(officeUrl('/'))
        ->assertOk()
        ->assertInertia(fn (AssertableInertia $page) => $page
            ->component('Dashboard')
            ->where('auth.name', 'Elif Karaca')
            ->where('auth.initials', 'EK')
            ->where('auth.has_second_factor', false)
            ->has('navigation')
        );
});

it('refuses the cockpit to a guest', function (): void {
    $this->get(officeUrl('/'))->assertRedirect();
});

it('serves the office root view, not another panel shell', function (): void {
    $user = User::factory()->create();

    $this->actingAs($user, 'office')
        ->get(officeUrl('/'))
        ->assertSee('resources/js/office.js', escape: false);
});
```

- [ ] **Adım 12: Testi koştur, kırmızı gördüğünü doğrula**

```bash
./vendor/bin/pest tests/Feature/Omurga/OfficeShellTest.php
```

Beklenen: rota bulunamadı ya da bileşen çözülemedi.

- [ ] **Adım 13: Derle ve testi yeşile al**

```bash
npm run build
./vendor/bin/pest tests/Feature/Omurga/OfficeShellTest.php
```

- [ ] **Adım 14: CI'ya derleme adımını EKLE — bu bir kontrol değil, eksik bir adım**

⚠ **Ölçüldü:** CI build sırası bugün `git pull → composer install → stack.sh up postgresql → optimize:clear → pint --test → phpstan → pest` (`docs/infra/ortamlar.md:53`). **`npm ci && npm run build` yok**, ve bu bilinen bir boşluk olarak `:146`'da EGT-51'e yazılmış: *"Bu ikisi henüz hiçbir deploy script'inde yok."*

Şimdiye kadar sorun çıkarmadı çünkü hiçbir test derlenmiş varlık istemiyordu. Bu görevle değişiyor: `@vite` manifest'i **render anında** okur, manifest yoksa `Vite manifest not found` fırlar ve **her Inertia testi** CI'da düşer. Lokalde yeşil, CI'da kırmızı — ve sebebi koda bakarak görünmez.

`composer install` adımından **sonra**, `optimize:clear`'dan **önce** yeni bir build step:

```bash
npm ci && npm run build
```

TeamCity'de `Egitim_Egitim` build type'ına eklenir. Ekledikten sonra bir build tetikleyip yeşil gördüğünü doğrula:

```bash
./deployment/scripts/teamcity.sh trigger Egitim_Egitim main
./deployment/scripts/teamcity.sh wait <id>
```

⚠ `docs/infra/ortamlar.md:53`'ün build adımı listesi de güncellenir — *ortamda var olan ile yazılı olan ayrı şeylerdir* dersi (PA-225) tam olarak bu satırın altında duruyor.

- [ ] **Adım 15: Kalite kapısı ve commit**

```bash
./vendor/bin/pint --dirty && composer analyse && ./vendor/bin/pest --group=omurga
git add package.json package-lock.json vite.config.js bootstrap/app.php routes/office.php \
        resources/views/office.blade.php resources/js resources/css app/Http tests/Feature/Omurga
git commit -m "#EGT-54 In Progress work 2h

feat(ofis): inertia + vue kurulumu, ofis kok sablonu ve kokpit kabugu"
```

---

## Görev 2: Token katmanı ve fontlar

`00 Tokenlar`'ın tamamı koda. Bu görevden sonra hiçbir dosyada ham renk değeri kalmaz.

**Dosyalar:**
- Oluştur: `resources/css/tokens.css`, `resources/fonts/` (woff2 dosyaları), `tests/Arch/FrontendTest.php`
- Değiştir: `resources/css/office.css`

**Arayüzler:**
- Üretir: Tailwind yardımcı adları — `bg-surface`, `bg-bg`, `bg-bg-sunken`, `text-ink-{950,800,600,400,300,200,100,50}`, `bg-accent-{50,600,700}`, `text-critical-fg`, `bg-critical-bg`, `border-critical-border`, `bg-critical-dot` (ve uyarı/dikkat/başarı/bilgi/nötr için aynı dörtlü), `text-viz-{1..8}`, `rounded-{chip,input,banner,card}`, `shadow-{overlay,modal}`, `text-{display,h1,h2,h3,body,small,caption,micro}`.

- [ ] **Adım 1: Fontları indir ve repoya al**

Archivo (400, 500, 600, 700, 800) ve IBM Plex Mono (400, 500) woff2 dosyalarını `resources/fonts/` altına koy. Kaynak: Google Fonts'un woff2 çıktısı, ama **dosya olarak** repoda durur.

⚠ Mockup'lar `fonts.googleapis.com` kullanıyor. Üretimde her sayfa yüklemesinde üçüncü tarafa giden bir istek, hem gereksiz bir bağımlılık hem KVKK açısından açıklanması gereken bir veri akışı. Tasarımın bağlayıcı olan kısmı **hangi font**, nereden yüklendiği değil.

- [ ] **Adım 2: `tokens.css`'i yaz**

```css
/*
| Design tokens — "Kirma + Mürekkep" (3a), design/00 Tokenlar.dc.html
|
| The single source: no colour, spacing, radius or shadow value is written
| anywhere else. Values are copied verbatim from the handoff; changing one here
| changes it everywhere, which is the whole point of the layer.
*/

@theme {
    /* Neutral scale — one grey-blue axis */
    --color-ink-950: #1e262e;
    --color-ink-800: #3d4954;
    --color-ink-600: #5a6771;
    --color-ink-400: #78858f;
    --color-ink-300: #95a5a6;
    --color-ink-200: #b7c1c9;
    --color-ink-100: #d9dfe3;
    --color-ink-50: #e7ebee;

    /* Surfaces */
    --color-surface: #ffffff;
    --color-bg: #eef1f3;
    --color-bg-sunken: #e6eaed;

    /* Accent — one colour. Action, link, active state, focus ring.
       Never decoration. */
    --color-accent-600: oklch(0.5 0.1 243);
    --color-accent-700: oklch(0.42 0.1 243);
    --color-accent-50: #e0ebf3;

    /* Semantic quartets — dot / fg / bg / border.
       Blue never describes a problem. The same colour means the same thing
       everywhere: badge, banner, bar and timeline share these. */
    --color-critical-dot: #c4553f;
    --color-critical-fg: #a8442f;
    --color-critical-bg: #f6e3de;
    --color-critical-border: #e8cfc7;

    --color-warning-dot: #d98e32;
    --color-warning-fg: #9a5b15;
    --color-warning-bg: #f8e9d4;
    --color-warning-border: #ead6b5;

    --color-caution-dot: #c9a227;
    --color-caution-fg: #7d650f;
    --color-caution-bg: #f7efcf;
    --color-caution-border: #e7dba8;

    --color-success-dot: #4e9a6e;
    --color-success-fg: #2e6b4f;
    --color-success-bg: #dfede5;
    --color-success-border: #c4dccb;

    --color-info-dot: #3f7fac;
    --color-info-fg: #21618c;
    --color-info-bg: #e0ebf3;
    --color-info-border: #c4d7e5;

    --color-neutral-dot: #95a5a6;
    --color-neutral-fg: #4a5866;
    --color-neutral-bg: #e7ebee;
    --color-neutral-border: #d5dce1;

    /* Data visualisation — 8 colours, colour blindness safe.
       The order is fixed: series 1 always starts at viz-1. */
    --color-viz-1: #3f7fac;
    --color-viz-2: #d98e32;
    --color-viz-3: #4e9a6e;
    --color-viz-4: #7b6fa8;
    --color-viz-5: #c4553f;
    --color-viz-6: #c9a227;
    --color-viz-7: #2e8b74;
    --color-viz-8: #8b98a2;

    /* Typography */
    --font-sans: 'Archivo', ui-sans-serif, system-ui, sans-serif;
    --font-mono: 'IBM Plex Mono', ui-monospace, monospace;

    --text-display: 2.125rem;
    --text-display--line-height: 2.5rem;
    --text-display--font-weight: 800;
    --text-display--letter-spacing: -0.075rem;

    --text-h1: 1.5625rem;
    --text-h1--line-height: 2rem;
    --text-h1--font-weight: 800;
    --text-h1--letter-spacing: -0.019rem;

    --text-h2: 1.25rem;
    --text-h2--line-height: 1.75rem;
    --text-h2--font-weight: 800;
    --text-h2--letter-spacing: -0.0125rem;

    --text-h3: 0.9375rem;
    --text-h3--line-height: 1.375rem;
    --text-h3--font-weight: 800;
    --text-h3--letter-spacing: -0.0125rem;

    --text-body: 0.84375rem;
    --text-body--line-height: 1.25rem;
    --text-body--font-weight: 500;

    --text-small: 0.78125rem;
    --text-small--line-height: 1.125rem;
    --text-small--font-weight: 500;

    --text-caption: 0.71875rem;
    --text-caption--line-height: 1rem;
    --text-caption--font-weight: 500;

    --text-micro: 0.65625rem;
    --text-micro--line-height: 0.875rem;
    --text-micro--font-weight: 700;
    --text-micro--letter-spacing: 0.1rem;

    /* Spacing — 4px grid */
    --spacing: 0.25rem;

    /* Radius */
    --radius-chip: 4px;
    --radius-input: 8px;
    --radius-banner: 10px;
    --radius-card: 12px;

    /* Shadow — floating layers only. A card carries a border, not a shadow. */
    --shadow-overlay: 0 4px 16px rgba(30, 38, 46, 0.12);
    --shadow-modal: 0 12px 32px rgba(30, 38, 46, 0.18);

    /* Breakpoints */
    --breakpoint-sm: 375px;
    --breakpoint-md: 768px;
    --breakpoint-lg: 1024px;
    --breakpoint-xl: 1440px;
}
```

- [ ] **Adım 3: `office.css`'i doldur**

```css
@import 'tailwindcss';
@import './tokens.css';

@source '../js/**/*.vue';

/*
| Fonts are served from this repository rather than a CDN. Every page load
| would otherwise reach a third party for a file that never changes.
*/
@font-face {
    font-family: 'Archivo';
    font-style: normal;
    font-weight: 400 800;
    font-display: swap;
    src: url('../fonts/archivo-variable.woff2') format('woff2');
}

@font-face {
    font-family: 'IBM Plex Mono';
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    src: url('../fonts/ibm-plex-mono-400.woff2') format('woff2');
}

@font-face {
    font-family: 'IBM Plex Mono';
    font-style: normal;
    font-weight: 500;
    font-display: swap;
    src: url('../fonts/ibm-plex-mono-500.woff2') format('woff2');
}

@layer base {
    /* Every numeric column lines up. The product is full of them. */
    html {
        font-variant-numeric: tabular-nums;
    }

    /* 2px accent outline, 2px offset — on every interactive element
       (00 Tokenlar, accessibility). */
    :focus-visible {
        outline: 2px solid var(--color-accent-600);
        outline-offset: 2px;
    }
}
```

- [ ] **Adım 4: Token dışı değer taramasının testini yaz**

`tests/Arch/FrontendTest.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\File;
use Symfony\Component\Finder\SplFileInfo;

/*
|--------------------------------------------------------------------------
| Front end contract (ofis spec 4.3, 4.4; kod-yerlesimi.md 3)
|--------------------------------------------------------------------------
*/

it('uses no colour or spacing value outside the token layer', function (): void {
    // A raw hex or an arbitrary Tailwind value is a second palette forming.
    // tokens.css is the one file allowed to name a colour.
    $rawHex = '/#[0-9a-fA-F]{3,8}\b/';
    $arbitraryUtility = '/\b[a-z-]+-\[[^\]]+\]/';

    $offenders = [];

    foreach (frontendSourceFiles() as $file) {
        $contents = $file->getContents();

        foreach ([$rawHex, $arbitraryUtility] as $pattern) {
            if (preg_match($pattern, $contents) === 1) {
                $offenders[] = $file->getRelativePathname();
                break;
            }
        }
    }

    expect($offenders)->toBe([]);
})->group('omurga');

it('resolves only this panel from the office entry', function (): void {
    // One entry per panel. A widened glob does not share code — Vite already
    // shares what is imported in common — it ships another panel's screens.
    $entry = File::get(resource_path('js/office.js'));

    expect($entry)->toContain("import.meta.glob('./pages/Office/**/*.vue'");

    foreach (['Portal', 'Participant', 'Landing'] as $otherPanel) {
        expect($entry)->not->toContain("pages/{$otherPanel}");
    }
})->group('omurga');

/**
 * Vue and CSS sources, excluding the token layer itself.
 *
 * @return list<SplFileInfo>
 */
function frontendSourceFiles(): array
{
    $files = [];

    foreach (['js', 'css'] as $directory) {
        $root = resource_path($directory);

        if (! is_dir($root)) {
            continue;
        }

        foreach (File::allFiles($root) as $file) {
            if (! in_array($file->getExtension(), ['vue', 'css', 'js'], strict: true)) {
                continue;
            }

            if ($file->getRelativePathname() === 'tokens.css') {
                continue;
            }

            $files[] = $file;
        }
    }

    return $files;
}
```

- [ ] **Adım 5: Testi koştur**

```bash
./vendor/bin/pest tests/Arch/FrontendTest.php
```

Beklenen: geçer. Geçmiyorsa raporlanan dosyadaki ham değeri token'a çevir.

- [ ] **Adım 6: Kök şablonun sınıflarını doğrula**

`office.blade.php`'deki `bg-bg` ve `text-ink-800` artık gerçek. Derle ve tarayıcıda zeminin `#EEF1F3` olduğunu gör:

```bash
npm run build
```

- [ ] **Adım 7: Kalite kapısı ve commit**

```bash
./vendor/bin/pint --dirty && composer analyse && ./vendor/bin/pest --group=omurga
git add resources/css resources/fonts tests/Arch/FrontendTest.php vite.config.js
git commit -m "#EGT-54 In Progress work 1h30m

feat(ofis): tasarim token katmani ve yerel fontlar"
```

---

## Görev 3: Çalışma zamanı çeviri mekanizması

**Dosyalar:**
- Oluştur: `app/Support/Inertia/TranslationBundle.php`, `resources/js/lib/i18n.js`, `lang/tr/office.php`, `lang/en/office.php`
- Değiştir: `app/Http/Middleware/HandleInertiaRequests.php`, `resources/js/office.js`, `tests/Arch/LocaleTest.php`
- Test: `tests/Feature/Omurga/OfficeTranslationTest.php`

**Arayüzler:**
- Tüketir: `HandleInertiaRequests`'in `translations` prop'u (görev 1).
- Üretir: `t(key, replacements)` global — her `.vue` dosyası bunu çağırır. `Inertia::render(..., ['translationGroups' => [...]])` sözleşmesi.

- [ ] **Adım 1: Testi yaz**

`tests/Feature/Omurga/OfficeTranslationTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\User;
use Inertia\Testing\AssertableInertia;

it('ships only the translation groups the page asked for', function (): void {
    $user = User::factory()->create();

    $this->actingAs($user, 'office')
        ->get(officeUrl('/'))
        ->assertInertia(fn (AssertableInertia $page) => $page
            ->has('translations.office')
            // The backoffice permission dictionary has no business in an
            // office browser. Shipping every group would put it there.
            ->missing('translations.permission')
        );
});

it('resolves translations in the request language', function (): void {
    $user = User::factory()->create();

    config()->set('locales.resolution_chain', ['application']);
    config()->set('locales.default', 'en');

    $this->actingAs($user, 'office')
        ->get(officeUrl('/'))
        ->assertInertia(fn (AssertableInertia $page) => $page->where('locale', 'en'));
});
```

- [ ] **Adım 2: Testi koştur, kırmızı gördüğünü doğrula**

```bash
./vendor/bin/pest tests/Feature/Omurga/OfficeTranslationTest.php
```

Beklenen: `translations.office` yok.

- [ ] **Adım 3: `TranslationBundle`'ı yaz**

`app/Support/Inertia/TranslationBundle.php`:

```php
<?php

declare(strict_types=1);

namespace App\Support\Inertia;

use Illuminate\Contracts\Translation\Translator;
use Illuminate\Http\Request;

/**
 * The translation groups a page asked for, resolved in the request's language
 * (ofis spec, karar 3).
 *
 * ⚠ Delivered per request rather than compiled at build time. A translation
 * panel is planned for the backoffice; with a build step between the editor
 * and the screen, every correction would need a deploy. Here the panel writes,
 * the translator reads, the prop arrives fresh.
 *
 * ⚠ Only what the page declares. Shipping every group would hand an office
 * browser the backoffice permission dictionary, and grow without limit as
 * modules land. The cost is that a forgotten group shows raw keys on screen —
 * visible, not silent.
 */
final class TranslationBundle
{
    /**
     * Always present: the shell speaks on every page.
     *
     * @var list<string>
     */
    private const ALWAYS = ['office', 'validation'];

    public function __construct(
        private readonly Request $request,
        private readonly Translator $translator,
    ) {}

    /**
     * @return array<string, array<string, mixed>>
     */
    public function forRequest(): array
    {
        $groups = array_unique([
            ...self::ALWAYS,
            ...$this->declaredByPage(),
        ]);

        $bundle = [];

        foreach ($groups as $group) {
            /** @var array<string, mixed> $lines */
            $lines = (array) $this->translator->get($group);

            $bundle[$group] = $lines;
        }

        return $bundle;
    }

    /**
     * @return list<string>
     */
    private function declaredByPage(): array
    {
        $declared = $this->request->attributes->get('translation_groups', []);

        return array_values(array_filter(
            (array) $declared,
            fn (mixed $group): bool => is_string($group) && $group !== '',
        ));
    }
}
```

⚠ Sayfanın bildirimi `Request` attribute'undan okunur, prop'tan değil: prop'lar `share()` çalışırken henüz hazır değildir.

- [ ] **Adım 4: Bildirim yardımcısını controller tarafına ekle**

`app/Http/Controllers/Controller.php` içine:

```php
    /**
     * Declare which translation groups this page needs (ofis spec, karar 3).
     *
     * A group that is not declared shows raw keys on screen. That is the
     * deliberate trade for shipping only what a page uses.
     */
    protected function withTranslations(string ...$groups): void
    {
        request()->attributes->set('translation_groups', $groups);
    }
```

- [ ] **Adım 5: Middleware'i bağla**

`HandleInertiaRequests::share()` içindeki `'translations' => []` satırını gerçeğiyle değiştir:

```php
            'translations' => fn (): array => app(TranslationBundle::class)->forRequest(),
```

- [ ] **Adım 6: Çeviri dosyalarını oluştur**

`lang/tr/office.php`:

```php
<?php

declare(strict_types=1);

return [
    'shell' => [
        'sign_out' => 'Çıkış yap',
        'profile' => 'Bilgilerim',
        'security' => 'Güvenlik',
        'notifications' => 'Bildirim tercihleri',
    ],
];
```

`lang/en/office.php`: aynı anahtarlar, İngilizce karşılıklarıyla. `LocaleTest`'in *"keeps every locale on the same key set"* testi ikisinin eşitliğini zorluyor.

⚠ `lang/tr/office.php` Türkçe metin taşır ve `LocaleTest`'in Türkçe harf taraması `config` ve `resources` dizinlerini tarar, `lang/`'ı taramaz — çakışma yok.

- [ ] **Adım 7: `t()` yardımcısını yaz**

`resources/js/lib/i18n.js`:

```javascript
/**
 * Reads a translation from the bundle the server shipped with the page.
 *
 * Keys are dotted and the first segment is the group: `office.shell.sign_out`.
 * A missing key returns the key itself — visible on screen, which is how a
 * forgotten translation group announces itself.
 */
export function translate(bundle, key, replacements = {}) {
    const line = key.split('.').reduce((carry, segment) => {
        if (carry === null || typeof carry !== 'object') {
            return null;
        }

        return segment in carry ? carry[segment] : null;
    }, bundle);

    if (typeof line !== 'string') {
        return key;
    }

    return Object.entries(replacements).reduce(
        (carry, [name, value]) => carry.replaceAll(`:${name}`, String(value)),
        line,
    );
}

/**
 * Laravel's pipe syntax: "tekil|çoğul". Chosen over a full pluralisation
 * engine because Turkish has one plural form and the product has no second
 * language planned; a fuller implementation would be code nobody reads.
 */
export function choose(bundle, key, count, replacements = {}) {
    const line = translate(bundle, key, { count, ...replacements });

    if (! line.includes('|')) {
        return line;
    }

    const [singular, plural] = line.split('|');

    return count === 1 ? singular : plural;
}
```

- [ ] **Adım 8: `t()`'yi global olarak bağla**

`resources/js/office.js` içinde `setup` bloğu:

```javascript
import { createApp, h } from 'vue';
import { createInertiaApp, usePage } from '@inertiajs/vue3';
import { choose, translate } from './lib/i18n';

createInertiaApp({
    resolve: (name) => {
        const pages = import.meta.glob('./pages/Office/**/*.vue', { eager: true });

        return pages[`./pages/Office/${name}.vue`];
    },
    setup({ el, App, props, plugin }) {
        const app = createApp({ render: () => h(App, props) }).use(plugin);

        // Global rather than an import in every component: a label is needed
        // in almost every file, and an import nobody can forget is one that is
        // not written per file.
        app.config.globalProperties.$t = (key, replacements) =>
            translate(usePage().props.translations, key, replacements);

        app.config.globalProperties.$tChoice = (key, count, replacements) =>
            choose(usePage().props.translations, key, count, replacements);

        app.mount(el);
    },
});
```

- [ ] **Adım 9: Attribute/prop taramasını `LocaleTest`'e ekle**

`tests/Arch/LocaleTest.php` sonuna:

```php
it('binds every user facing attribute to a translation', function (): void {
    // The existing template test strips whole tags, so it never sees attribute
    // values — and in a form heavy product most visible text arrives as one:
    // `label="Ad Soyad"` renders exactly like a text node and passes silently
    // (ofis spec, kriter 4; heyet bulgusu R2-2).
    //
    // The rule is positive: these must be BOUND, never literal.
    $userFacing = [
        'label', 'placeholder', 'title', 'alt', 'aria-label', 'aria-description',
        'hint', 'helperText', 'errorText', 'confirmLabel', 'cancelLabel',
    ];

    $offenders = [];

    foreach (templateFiles() as $file) {
        if ($file->getExtension() !== 'vue') {
            continue;
        }

        $markup = $file->getContents();

        foreach ($userFacing as $attribute) {
            // `label="..."` — literal. `:label="..."` and `v-bind:label` are
            // bound and therefore fine; the binding is what carries t().
            $pattern = '/(?<![:\w.-])'.preg_quote($attribute, '/').'\s*=\s*"[^"]*\p{L}{2,}[^"]*"/u';

            if (preg_match($pattern, $markup) === 1) {
                $offenders[] = $file->getRelativePathname().': '.$attribute;
            }
        }
    }

    expect($offenders)->toBe([]);
})->group('omurga');
```

- [ ] **Adım 10: Testleri koştur**

```bash
./vendor/bin/pest tests/Feature/Omurga/OfficeTranslationTest.php tests/Arch/LocaleTest.php
```

Beklenen: hepsi geçer.

- [ ] **Adım 11: Kalite kapısı ve commit**

```bash
./vendor/bin/pint --dirty && composer analyse && ./vendor/bin/pest --group=omurga
git add app/Support/Inertia app/Http lang resources/js tests
git commit -m "#EGT-54 In Progress work 2h

feat(ofis): calisma zamani ceviri teslimati ve attribute taramasi"
```

---

## Görev 4: Atom seti

Spec karar 1: atomlar baştan, molekül ve organizmalar ekranla birlikte. Bu görev yalnız atomları yazar.

**Dosyalar:**
- Oluştur: `resources/js/atoms/` altında `BaseButton.vue`, `TextInput.vue`, `PasswordInput.vue`, `OtpInput.vue`, `SelectInput.vue`, `SearchableSelect.vue`, `Checkbox.vue`, `RadioInput.vue`, `ToggleSwitch.vue`, `StatusDot.vue`, `Badge.vue`, `Chip.vue`, `Avatar.vue`, `Spinner.vue`, `FieldLabel.vue`, `HelperText.vue`, `ErrorText.vue`

**Arayüzler:**
- Üretir: her atomun public prop ve event şeması. Sonraki görevler bunlara dayanır; değiştiren, tüketenleri de günceller.

**Ortak sözleşme — her atom için geçerli:**

- Dokunma hedefi ≥44px, mobil form kontrolü ≥48px (`min-h-11 md:min-h-11`, form kontrolleri `min-h-12 md:min-h-11`).
- Focus ring taban stilden gelir; atom kendi outline'ını yazmaz.
- Renk yalnız token'dan.
- Kullanıcı metni **prop olarak** girer, atomun içine yazılmaz.

- [ ] **Adım 1: `BaseButton.vue`**

```vue
<script setup>
defineProps({
    variant: {
        type: String,
        default: 'primary',
        validator: (value) => ['primary', 'secondary', 'ghost', 'destructive'].includes(value),
    },
    type: { type: String, default: 'button' },
    disabled: { type: Boolean, default: false },
    loading: { type: Boolean, default: false },
    block: { type: Boolean, default: false },
});

const variants = {
    primary: 'bg-ink-950 text-surface hover:bg-ink-800 border border-transparent',
    secondary: 'bg-surface text-ink-950 border border-ink-200 hover:bg-bg',
    ghost: 'bg-transparent text-accent-600 border border-transparent hover:bg-accent-50',
    destructive: 'bg-surface text-critical-fg border border-critical-border hover:bg-critical-bg',
};
</script>

<template>
    <button
        :type="type"
        :disabled="disabled || loading"
        :class="[
            variants[variant],
            block ? 'w-full' : '',
            'inline-flex min-h-11 items-center justify-center gap-2 rounded-input px-4 text-body font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-50',
        ]"
    >
        <Spinner v-if="loading" />
        <slot />
    </button>
</template>

<script>
import Spinner from './Spinner.vue';

export default { components: { Spinner } };
</script>
```

- [ ] **Adım 2: `TextInput.vue`**

```vue
<script setup>
defineProps({
    modelValue: { type: [String, Number], default: '' },
    type: { type: String, default: 'text' },
    invalid: { type: Boolean, default: false },
    disabled: { type: Boolean, default: false },
    readonly: { type: Boolean, default: false },
    autocomplete: { type: String, default: 'off' },
});

defineEmits(['update:modelValue']);
</script>

<template>
    <input
        :type="type"
        :value="modelValue"
        :disabled="disabled"
        :readonly="readonly"
        :autocomplete="autocomplete"
        :aria-invalid="invalid"
        :class="[
            invalid ? 'border-critical-dot' : 'border-ink-200',
            'min-h-12 w-full rounded-input border bg-surface px-3 text-body text-ink-950 placeholder:text-ink-400 read-only:bg-bg disabled:cursor-not-allowed disabled:bg-bg disabled:text-ink-400 md:min-h-11',
        ]"
        @input="$emit('update:modelValue', $event.target.value)"
    >
</template>
```

- [ ] **Adım 3: `PasswordInput.vue`**

`TextInput`'u sarar, göster/gizle düğmesi ekler. Düğmenin erişilebilir adı **prop olarak** girer (`showLabel`, `hideLabel`) — atom metin taşımaz.

```vue
<script setup>
import { ref } from 'vue';
import TextInput from './TextInput.vue';

defineProps({
    modelValue: { type: String, default: '' },
    invalid: { type: Boolean, default: false },
    autocomplete: { type: String, default: 'current-password' },
    showLabel: { type: String, required: true },
    hideLabel: { type: String, required: true },
});

defineEmits(['update:modelValue']);

const visible = ref(false);
</script>

<template>
    <div class="relative">
        <TextInput
            :model-value="modelValue"
            :type="visible ? 'text' : 'password'"
            :invalid="invalid"
            :autocomplete="autocomplete"
            class="pr-20"
            @update:model-value="$emit('update:modelValue', $event)"
        />
        <button
            type="button"
            class="absolute inset-y-0 right-0 flex min-h-11 items-center px-3 text-caption font-bold text-info-fg"
            :aria-label="visible ? hideLabel : showLabel"
            @click="visible = ! visible"
        >
            {{ visible ? hideLabel : showLabel }}
        </button>
    </div>
</template>
```

- [ ] **Adım 4: `OtpInput.vue`**

Altı kutulu giriş. `10 Ortak Auth`'un 2FA kartı: 44×52 kutular, otomatik ilerleme, yapıştırma desteği.

```vue
<script setup>
import { ref, watch } from 'vue';

const props = defineProps({
    modelValue: { type: String, default: '' },
    length: { type: Number, default: 6 },
    invalid: { type: Boolean, default: false },
});

const emit = defineEmits(['update:modelValue', 'complete']);

const boxes = ref(Array.from({ length: props.length }, () => ''));

watch(boxes, (value) => {
    const code = value.join('');

    emit('update:modelValue', code);

    if (code.length === props.length) {
        emit('complete', code);
    }
}, { deep: true });

function onInput(index, event) {
    const digit = event.target.value.replace(/\D/g, '').slice(-1);

    boxes.value[index] = digit;

    if (digit && index < props.length - 1) {
        event.target.nextElementSibling?.focus();
    }
}

function onKeydown(index, event) {
    if (event.key === 'Backspace' && ! boxes.value[index] && index > 0) {
        event.target.previousElementSibling?.focus();
    }
}

function onPaste(event) {
    const digits = (event.clipboardData?.getData('text') ?? '').replace(/\D/g, '');

    if (! digits) {
        return;
    }

    event.preventDefault();

    boxes.value = Array.from({ length: props.length }, (_, i) => digits[i] ?? '');
}
</script>

<template>
    <div class="flex gap-1.5" @paste="onPaste">
        <input
            v-for="(digit, index) in boxes"
            :key="index"
            :value="digit"
            inputmode="numeric"
            autocomplete="one-time-code"
            maxlength="1"
            :aria-invalid="invalid"
            :class="[
                invalid ? 'border-critical-dot' : 'border-ink-200',
                'h-13 w-11 rounded-input border bg-surface text-center text-h2 font-extrabold text-ink-950',
            ]"
            @input="onInput(index, $event)"
            @keydown="onKeydown(index, $event)"
        >
    </div>
</template>
```

- [ ] **Adım 5: Kalan atomları yaz**

Aynı sözleşmeyle: `SelectInput` (yerel `<select>`, aynı çerçeve stili) · `SearchableSelect` (arama kutusu + liste + "sonuç yok" slot'u, prop'la gelen metinler) · `Checkbox` · `RadioInput` · `ToggleSwitch` · `StatusDot` (7px nokta, semantik dörtlüden renk) · `Badge` (dörtlünün dördünü de kullanır: dot + fg + bg + border, `rounded-full`) · `Chip` (`rounded-chip`, monospace varyantı referans kodu için) · `Avatar` (baş harfler, `bg-ink-950 text-surface`) · `Spinner` · `FieldLabel` · `HelperText` · `ErrorText`.

⚠ **`Badge`'in rengi `tone` prop'undan gelir** (`critical` | `warning` | `caution` | `success` | `info` | `neutral`) ve dörtlünün tamamını uygular. Statü adını **çağıran** verir; atom hangi statünün hangi ton olduğunu bilmez — o eşleme molekül seviyesinde, `StatusBadge`'te yaşar (görev 8).

- [ ] **Adım 6: Atomların testini yaz**

Atomlar için ayrı bir JS koşusu **açılmıyor** (karar 10). Doğrulama iki yerden gelir: arch testleri (token dışı değer yok, literal attribute yok) ve atomları kullanan sayfaların Inertia testleri. Bu adımda yalnız arch testlerini koştur:

```bash
./vendor/bin/pest tests/Arch/FrontendTest.php tests/Arch/LocaleTest.php
```

- [ ] **Adım 7: Kalite kapısı ve commit**

```bash
npm run build
./vendor/bin/pint --dirty && ./vendor/bin/pest --group=omurga
git add resources/js/atoms
git commit -m "#EGT-54 In Progress work 3h

feat(ofis): standart atom seti"
```

---

## Görev 5: Giriş ekranı ve `AuthLayout`

**Dosyalar:**
- Oluştur: `resources/js/templates/AuthLayout.vue`, `resources/js/molecules/FormField.vue`, `resources/js/pages/Office/Auth/SignIn.vue`
- Değiştir: `app/Http/Controllers/Auth/OfficeSessionController.php`, `routes/office.php`, `lang/{tr,en}/office.php`
- Test: `tests/Feature/Omurga/OfficeSignInScreenTest.php`

**Arayüzler:**
- Tüketir: `TextInput`, `PasswordInput`, `BaseButton`, `FieldLabel`, `ErrorText` (görev 4); `t()` (görev 3).
- Üretir: `office.login.show` rota adı; `FormField` molekülü (etiket + kontrol + yardım + hata).
- Üretir: `ThrottlesSignIn` — giriş hız sınırı, görev 6'nın challenge ucu da bunu kullanır.

### ⚠ Bu görev bir omurga boşluğunu kapatıyor: A-1 hiç uygulanmamış

Ölçüldü: `config/security.php:66` `login_throttle` ayarını (`max_attempts: 5`, `lockout_minutes: 15`) tanımlıyor ama **hiçbir kod okumuyor** — `grep -rn "login_throttle" config/ app/` yalnız config dosyasının kendisini buluyor. Yani omurga spec A-1 (*"başarısız giriş denemeleri hız sınırına tabidir; eşik ve kilit süresi admin-tanımlıdır"*) **üç kimlik alanının hiçbirinde zorlanmıyor**: ayar var, zorlama yok.

⚠ Bu, ofis spec'i §8.1'in altını da oyuyor. Orada *"kısıtla"*yı hız sınırına bağlayıp *"sistemin ürettiği geçici durum"* dedik; `63`'ün *"Girişi Kısıtlı (30 dk)"* rozeti de buna dayanıyor. Mekanizma yoksa rozet hiç çıkmaz ve T2'nin *"kısıtı kaldır"* aksiyonunun kaldıracağı bir şey olmaz.

**Karar:** ofis girişinin hız sınırı **bu turda** yazılır — sign-in yüzeyini kuran tur burasıdır ve `AnonymousRequestLimiter` (`app/Support/Auth/AnonymousRequestLimiter.php`) zaten var, `RequestMagicLink` onu kullanıyor.

**Adresli erteleme:** müşteri girişinin (`SignInCustomerUser`) aynı boşluğu **kapatılmıyor** — o yüzeyi kuran tur müşteri portalıdır (`EGT-24`). Kayıt `docs/issues.md`'ye düşülür; adressiz bırakılmaz.

⚠ **`AnonymousRequestLimiter` de bu turda dokunulmuyor, ama "aynı kusur değil" diye değil** *(heyet bulgusu R3-1)*. Aynı kusur, ve en kritik uçta daha keskin: `RequestMagicLink:53-58` eşiği `password_reset_throttle`'dan okuyor (**3 istek / 60 dakika**) ve tek `$max`'ı iki anahtara birden uyguluyor. Katılımcılar eğitim yerine birlikte gelir; 25 kişilik bir sınıfın aynı salon wifi'sinden link istemesi **4. kişiden itibaren** IP anahtarına takılır — üstelik `return 0` ile **sessizce**: kişi *"bağlantı gönderildi"* görür, posta gelmez. Dokunmama gerekçesi kapsamdır (katılımcı yüzeyi), risk değerlendirmesi değil. Kayıt `docs/issues.md`'de, adresi katılımcı akışını kuran tur + `EGT-51`.

- [ ] **Adım 1: Testi yaz**

`tests/Feature/Omurga/OfficeSignInScreenTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\User;
use Inertia\Testing\AssertableInertia;

it('shows the sign in screen to a guest', function (): void {
    $this->get(officeUrl('/giris'))
        ->assertOk()
        ->assertInertia(fn (AssertableInertia $page) => $page->component('Auth/SignIn'));
});

it('signs a user in and lands them on the cockpit', function (): void {
    $user = User::factory()->create(['password' => 'Passw0rd!']);

    $this->post(officeUrl('/giris'), [
        'email' => $user->email,
        'password' => 'Passw0rd!',
    ])->assertRedirect(officeUrl('/'));

    expect(auth('office')->id())->toBe($user->getKey());
});

it('shows the failure on the screen rather than swallowing it', function (): void {
    // ⚠ This is the half that matters. bootstrap/app.php answers
    // AuthenticationRefused with a 422 JSON body when the request "expects
    // JSON", and with back()->withErrors() otherwise. An Inertia request must
    // take the second branch: the first one produces a response Inertia does
    // not render as a validation error, and the person sees nothing at all
    // (ofis spec 4.5).
    $user = User::factory()->create(['password' => 'Passw0rd!']);

    $this->from(officeUrl('/giris'))
        ->post(officeUrl('/giris'), [
            'email' => $user->email,
            'password' => 'wrong-password',
        ], ['X-Inertia' => 'true', 'X-Inertia-Version' => ''])
        ->assertRedirect(officeUrl('/giris'))
        ->assertSessionHasErrors('email');

    expect(auth('office')->check())->toBeFalse();
});

it('sends a signed in user away from the sign in screen', function (): void {
    $user = User::factory()->create();

    $this->actingAs($user, 'office')
        ->get(officeUrl('/giris'))
        ->assertRedirect(officeUrl('/'));
});

it('stops guessing after the configured number of attempts', function (): void {
    // A-1. The setting existed before this task and nothing read it
    // (config/security.php:66) — the office sign in is where it starts.
    $user = User::factory()->create(['password' => 'Passw0rd!']);

    $max = (int) config('security.login_throttle.max_attempts');

    foreach (range(1, $max) as $attempt) {
        $this->from(officeUrl('/giris'))->post(officeUrl('/giris'), [
            'email' => $user->email,
            'password' => 'wrong-password',
        ]);
    }

    // ⚠ The right password now, and it must still be refused. A limit that
    // lets the attempt through once it is finally correct is not a limit.
    $this->from(officeUrl('/giris'))
        ->post(officeUrl('/giris'), ['email' => $user->email, 'password' => 'Passw0rd!'])
        ->assertSessionHasErrors('email');

    expect(auth('office')->check())->toBeFalse();
});
```

⚠ **Üçüncü testin başlık seti gerçek olmalı** *(heyet bulgusu P-5)*. Yalnız `X-Inertia` göndermek `expectsJson()`'ı üretimdeki fıkradan **farklı** bir fıkradan geçirir: gerçek istek `X-Requested-With: XMLHttpRequest` ile ajax sayılır ve `Accept: text/html, application/xhtml+xml` yüzünden `acceptsAnyContentType()` yanlış döner. İkisi bugün aynı dala düşüyor, ama testin kanıtlaması gereken şey üretim kombinasyonudur:

```php
        ->post(officeUrl('/giris'), [
            'email' => $user->email,
            'password' => 'wrong-password',
        ], [
            'X-Inertia' => 'true',
            'X-Inertia-Version' => '',
            'X-Requested-With' => 'XMLHttpRequest',
            'Accept' => 'text/html, application/xhtml+xml',
        ])
```

- [ ] **Adım 2: Testi koştur, kırmızı gördüğünü doğrula**

```bash
./vendor/bin/pest tests/Feature/Omurga/OfficeSignInScreenTest.php
```

⚠ Üçüncü test şu anda **gerçek bir bilinmezi** ölçüyor: `expectsJson()`'ın Inertia isteğinde hangi dala düştüğü. Kırmızıysa `bootstrap/app.php`'nin `AuthenticationRefused` işleyicisine Inertia'yı dışlayan bir koşul eklenir:

```php
        $exceptions->render(function (AuthenticationRefused $e, Request $request) {
            // Inertia sends X-Requested-With and asks for HTML; it must take
            // the redirect branch, or the sign in error never reaches a screen.
            return $request->expectsJson() && ! $request->header('X-Inertia')
                ? response()->json(['message' => __('auth.failed')], 422)
                : back()->withErrors(['email' => __('auth.failed')]);
        });
```

- [ ] **Adım 2b: Hız sınırını yaz** *(heyet bulgusu P-1a, A-1)*

`app/Support/Auth/ThrottlesSignIn.php` — `AnonymousRequestLimiter`'ın deseniyle, ama **kilit süresi** kavramıyla:

```php
<?php

declare(strict_types=1);

namespace App\Support\Auth;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

/**
 * Rate limits sign in attempts (spec A-1).
 *
 * ⚠ `config/security.php` has carried `login_throttle` since chunk5 and
 * nothing read it: the setting existed, the enforcement did not. The office
 * sign in surface is the first one built, so it is where the rule starts.
 * The customer side has the same gap and its own tour (docs/issues.md).
 *
 * Counted per address AND per IP: per address alone lets an attacker walk a
 * list, per IP alone lets a botnet hammer one account.
 *
 * ⚠ Deliberately NOT the shape of AnonymousRequestLimiter, which applies ONE
 * threshold to both keys. That works for a low frequency endpoint and breaks
 * wherever real people share an address — this office behind one NAT, and a
 * classroom of participants on one venue's wifi (docs/issues.md). Each key
 * carries its own budget here; see budgets().
 *
 * Decides nothing (AD-25): whether to refuse is the action's call.
 */
final class ThrottlesSignIn
{
    public function __construct(private readonly Request $request) {}

    public function locked(string $email): bool
    {
        foreach ($this->budgets($email) as [$key, $max]) {
            if (RateLimiter::tooManyAttempts($key, $max)) {
                return true;
            }
        }

        return false;
    }

    public function recordFailure(string $email): void
    {
        foreach ($this->budgets($email) as [$key, $max]) {
            RateLimiter::hit($key, $this->lockoutSeconds());
        }
    }

    /**
     * ⚠ Cleared only on a COMPLETED sign in, not on a correct password. When a
     * second factor follows, the sign in is not finished until it is answered
     * — clearing early would hand an attacker who guessed the password a fresh
     * budget for guessing the code.
     */
    public function clear(string $email): void
    {
        foreach ($this->budgets($email) as [$key, $max]) {
            RateLimiter::clear($key);
        }
    }

    public function availableIn(string $email): int
    {
        return collect($this->budgets($email))
            ->map(fn (array $budget): int => RateLimiter::availableIn($budget[0]))
            ->max() ?? 0;
    }

    /**
     * The two counters and the budget each one gets.
     *
     * ⚠ **Two different thresholds, and the difference is the whole point.**
     * An address is one person: five wrong passwords is somebody who forgot
     * theirs. An IP is a BUILDING — the whole office sits behind one NAT, so
     * the same five would be the team's ordinary Monday morning and would lock
     * everybody out for fifteen minutes. One shared threshold cannot be tuned
     * out of this either: tightening the address budget tightens the office's.
     *
     * The IP counter is not there to catch a forgotten password; it is there
     * to catch somebody walking a list of addresses from one machine. It is
     * therefore far looser and measured against its own setting.
     *
     * @return list<array{0: string, 1: int}>
     */
    private function budgets(string $email): array
    {
        return [
            [
                'sign-in:email:'.hash('sha256', mb_strtolower($email)),
                (int) config('security.login_throttle.max_attempts'),
            ],
            [
                'sign-in:ip:'.$this->request->ip(),
                (int) config('security.login_throttle.ip_max_attempts'),
            ],
        ];
    }

    private function lockoutSeconds(): int
    {
        return (int) config('security.login_throttle.lockout_minutes') * 60;
    }
}
```

`SignInOfficeUser` içine, yöntem kontrolünden **sonra**, `attempt()`'ten **önce**:

```php
        if ($this->throttle->locked($email)) {
            throw AuthenticationRefused::throttled($this->throttle->availableIn($email));
        }
```

ve `attempt()` başarısızsa `recordFailure($email)`. ⚠ `AuthenticationRefused::throttled()` yeni bir kurucu ister; mesajı **aynı** kalır (`auth.failed` ailesinden ayrı bir satır: kalan süreyi söyler ama hesabın var olup olmadığını söylemez — spec T-1).

⚠ **`clear()` şifre doğru diye çağrılmaz** — giriş, ikinci faktör varsa henüz bitmemiştir. Çağrı yeri **yola göre** değişir ve ikisi de yazılıdır: faktörü **olan** kullanıcıda görev 6'nın challenge'ı başarıyla cevaplandığında, faktörü **olmayan** kullanıcıda `store()` panele yönlendirmeden hemen önce. İkisi de "giriş tamamlandı" anıdır.

⚠ **`config/security.php`'ye yeni alan:** `login_throttle.ip_max_attempts`. Tohum değeri **30**; e-postanın 5'iyle aynı olamaz (yukarıdaki NAT gerekçesi). Spec §15/4 operasyon teyidi geldiğinde iki alan **ayrı ayrı** ayarlanır — tek alanla ikisini birden ayarlamak imkânsızdı.

- [ ] **Adım 3: `FormField` molekülünü yaz**

```vue
<script setup>
import ErrorText from '../atoms/ErrorText.vue';
import FieldLabel from '../atoms/FieldLabel.vue';
import HelperText from '../atoms/HelperText.vue';

defineProps({
    label: { type: String, required: true },
    hint: { type: String, default: '' },
    error: { type: String, default: '' },
    required: { type: Boolean, default: false },
    inputId: { type: String, required: true },
});
</script>

<template>
    <div class="flex flex-col gap-1.5">
        <div class="flex items-baseline justify-between gap-2">
            <FieldLabel :for="inputId" :required="required">{{ label }}</FieldLabel>
            <slot name="labelAside" />
        </div>

        <slot :invalid="Boolean(error)" />

        <HelperText v-if="hint && ! error">{{ hint }}</HelperText>
        <ErrorText v-if="error">{{ error }}</ErrorText>
    </div>
</template>
```

- [ ] **Adım 4: `AuthLayout`'u yaz**

`10 Ortak Auth`'un ofis girişi: solda koyu kurumsal kolon, sağda form. 768px altında sol kolon gizlenir.

```vue
<script setup>
defineProps({
    tagline: { type: String, required: true },
    notice: { type: String, required: true },
    footer: { type: String, required: true },
    logoAlt: { type: String, required: true },
});
</script>

<template>
    <div class="flex min-h-screen">
        <aside class="hidden w-90 shrink-0 flex-col bg-ink-950 p-8 md:flex">
            <img src="/images/logo-card-dark-akademi.svg" :alt="logoAlt" class="w-52">
            <div class="mt-auto">
                <p class="text-h2 leading-snug text-surface">{{ tagline }}</p>
                <p class="mt-3 text-small text-ink-300">{{ notice }}</p>
            </div>
            <p class="mt-7 text-caption text-ink-600">{{ footer }}</p>
        </aside>

        <main class="flex flex-1 items-center justify-center bg-bg px-5 py-10">
            <div class="w-full max-w-85">
                <slot />
            </div>
        </main>
    </div>
</template>
```

⚠ Logo `public/images/` altına kopyalanır (`design/assets/logo-card-dark-akademi.svg`). `HANDOFF.md`'nin açık kalanlar bölümü logo SVG'lerinin repoya taşınmasını zaten istiyor.

- [ ] **Adım 5: Giriş sayfasını yaz**

```vue
<script setup>
import { useForm } from '@inertiajs/vue3';
import BaseButton from '../../../atoms/BaseButton.vue';
import PasswordInput from '../../../atoms/PasswordInput.vue';
import TextInput from '../../../atoms/TextInput.vue';
import FormField from '../../../molecules/FormField.vue';
import AuthLayout from '../../../templates/AuthLayout.vue';

// No `remember` field: the design's office sign in does not offer one, and
// a form key nothing renders is a value nobody can set.
const form = useForm({ email: '', password: '' });

function submit() {
    form.post('/giris', { onFinish: () => form.reset('password') });
}
</script>

<template>
    <AuthLayout
        :tagline="$t('office.auth.tagline')"
        :notice="$t('office.auth.audit_notice')"
        :footer="$t('office.auth.footer')"
        :logo-alt="$t('office.auth.logo_alt')"
    >
        <h1 class="text-h2 text-ink-950">{{ $t('office.auth.sign_in_title') }}</h1>
        <p class="mt-1 text-small text-ink-600">{{ $t('office.auth.sign_in_subtitle') }}</p>

        <form class="mt-6 flex flex-col gap-3.5" @submit.prevent="submit">
            <FormField
                input-id="email"
                :label="$t('office.auth.email')"
                :error="form.errors.email"
                required
            >
                <template #default="{ invalid }">
                    <TextInput
                        id="email"
                        v-model="form.email"
                        type="email"
                        autocomplete="username"
                        :invalid="invalid"
                    />
                </template>
            </FormField>

            <FormField
                input-id="password"
                :label="$t('office.auth.password')"
                :error="form.errors.password"
                required
            >
                <template #labelAside>
                    <a href="/sifre/baglanti" class="text-caption font-bold text-info-fg">
                        {{ $t('office.auth.forgot_password') }}
                    </a>
                </template>
                <template #default="{ invalid }">
                    <PasswordInput
                        id="password"
                        v-model="form.password"
                        :invalid="invalid"
                        :show-label="$t('office.auth.show_password')"
                        :hide-label="$t('office.auth.hide_password')"
                    />
                </template>
            </FormField>

            <BaseButton type="submit" block :loading="form.processing">
                {{ $t('office.auth.submit') }}
            </BaseButton>
        </form>
    </AuthLayout>
</template>
```

- [ ] **Adım 6: Controller'ı Inertia'ya çevir**

`OfficeSessionController`:

```php
    public function create(): Response
    {
        $this->withTranslations('office', 'auth');

        return Inertia::render('Auth/SignIn');
    }

    public function store(SignInRequest $request, SignInOfficeUser $signIn): RedirectResponse
    {
        $signIn(
            email: (string) $request->validated('email'),
            password: (string) $request->validated('password'),
        );

        return redirect()->intended(route('office.dashboard'));
    }

    public function destroy(SignOut $signOut): RedirectResponse
    {
        $signOut();

        return redirect()->route('office.login.show');
    }
```

⚠ `intended()` korunur: ikinci faktör adımı ve derin bağlantılar buna dayanır.

- [ ] **Adım 7: Rotaları ekle**

```php
Route::middleware('guest:office')->group(function (): void {
    Route::get('/giris', [OfficeSessionController::class, 'create'])->name('office.login.show');
    Route::post('/giris', [OfficeSessionController::class, 'store'])->name('office.login');
});
```

⚠ `guest:office` middleware'i, girmiş kullanıcıyı `RouteServiceProvider::HOME`'a değil ofis kokpitine yönlendirmeli. `app/Http/Middleware/RedirectIfAuthenticated`'ın ofis dalını kontrol et; yoksa `bootstrap/app.php`'de `redirectUsersTo` ile ofis host'una bağla.

- [ ] **Adım 8: Çevirileri ekle** — `office.auth.*` anahtarları, `lang/tr` ve `lang/en`.

- [ ] **Adım 9: Testleri yeşile al ve tarayıcıda gör**

```bash
npm run build && ./vendor/bin/pest tests/Feature/Omurga/OfficeSignInScreenTest.php
```

Tarayıcıda `https://ofis.egitim.dev.sets.fyi/giris` — konsolda JS/Inertia/Vue hatası olmadığını doğrula (kalite kapısı madde 5).

- [ ] **Adım 10: Kalite kapısı ve commit**

```bash
./vendor/bin/pint --dirty && composer analyse && ./vendor/bin/pest --group=omurga
git commit -am "#EGT-54 In Progress work 2h30m

feat(ofis): giris ekrani, auth iskeleti ve form alani molekulu"
```

---

## Görev 6: İkinci faktör — kurulum, doğrulama, kurtarma kodları

**Dosyalar:**
- Oluştur: `app/Support/Auth/AppAuthenticator.php`, `app/Actions/Auth/{EnrolAppAuthentication,ConfirmAppAuthentication,DisableAppAuthentication,AnswerSecondFactorChallenge}.php`, `app/Http/Controllers/Auth/OfficeSecondFactorController.php`, `app/Http/Requests/Auth/{ConfirmSecondFactorRequest,SecondFactorChallengeRequest}.php`, `resources/js/pages/Office/Auth/SecondFactor.vue`, `resources/js/molecules/ConfirmModal.vue`
- Değiştir: `routes/office.php`, `app/Http/Controllers/Auth/OfficeSessionController.php`
- Test: `tests/Feature/Omurga/OfficeSecondFactorTest.php`

⚠ `SignInOfficeUser` bu görevde **değişmez** — kimlik doğrulamayı o yapar ve zaten `User` döndürüyor; hız sınırı görev 5'te eklendi. Faktör adımı controller'da kurulur.

**Arayüzler:**
- Üretir: `AppAuthenticator::secret(): string`, `::qrCodeSvg(User $user, string $secret): string`, `::verify(string $secret, string $code): bool`
- Üretir: oturum anahtarı `office_second_factor.pending_user` — doğrulanmamış giriş bu anahtarla bekler.

- [ ] **Adım 1: Testi yaz**

```php
<?php

declare(strict_types=1);

use App\Models\User;
use App\Support\Auth\AppAuthenticator;
use Inertia\Testing\AssertableInertia;

it('asks for the second factor when the account has one', function (): void {
    $secret = app(AppAuthenticator::class)->secret();

    $user = User::factory()->create([
        'password' => 'Passw0rd!',
        'two_factor_secret' => $secret,
        'two_factor_confirmed_at' => now(),
    ]);

    $this->post(officeUrl('/giris'), ['email' => $user->email, 'password' => 'Passw0rd!'])
        ->assertRedirect(officeUrl('/ikinci-faktor'));

    // ⚠ Not signed in yet. A session that is authenticated before the factor
    // is proven is a session that can reach a screen by pressing back.
    expect(auth('office')->check())->toBeFalse();
});

it('does not ask when the account has no second factor', function (): void {
    $user = User::factory()->create(['password' => 'Passw0rd!']);

    $this->post(officeUrl('/giris'), ['email' => $user->email, 'password' => 'Passw0rd!'])
        ->assertRedirect(officeUrl('/'));

    expect(auth('office')->check())->toBeTrue();
});

it('completes the sign in when the code is right', function (): void {
    $authenticator = app(AppAuthenticator::class);
    $secret = $authenticator->secret();

    $user = User::factory()->create([
        'password' => 'Passw0rd!',
        'two_factor_secret' => $secret,
        'two_factor_confirmed_at' => now(),
    ]);

    $this->post(officeUrl('/giris'), ['email' => $user->email, 'password' => 'Passw0rd!']);

    $this->post(officeUrl('/ikinci-faktor'), ['code' => $authenticator->currentCode($secret)])
        ->assertRedirect(officeUrl('/'));

    expect(auth('office')->id())->toBe($user->getKey());
});

it('refuses a wrong code and keeps the session out', function (): void {
    $authenticator = app(AppAuthenticator::class);

    $user = User::factory()->create([
        'password' => 'Passw0rd!',
        'two_factor_secret' => $authenticator->secret(),
        'two_factor_confirmed_at' => now(),
    ]);

    $this->post(officeUrl('/giris'), ['email' => $user->email, 'password' => 'Passw0rd!']);

    $this->from(officeUrl('/ikinci-faktor'))
        ->post(officeUrl('/ikinci-faktor'), ['code' => '000000'])
        ->assertSessionHasErrors('code');

    expect(auth('office')->check())->toBeFalse();
});

it('accepts a recovery code once and only once', function (): void {
    $authenticator = app(AppAuthenticator::class);

    $user = User::factory()->create([
        'password' => 'Passw0rd!',
        'two_factor_secret' => $authenticator->secret(),
        'two_factor_recovery_codes' => ['AAAA-1111', 'BBBB-2222'],
        'two_factor_confirmed_at' => now(),
    ]);

    $this->post(officeUrl('/giris'), ['email' => $user->email, 'password' => 'Passw0rd!']);
    $this->post(officeUrl('/ikinci-faktor'), ['code' => 'AAAA-1111'])->assertRedirect(officeUrl('/'));

    expect($user->fresh()->two_factor_recovery_codes)->toBe(['BBBB-2222']);
});

it('shows the enrolment screen with a QR code', function (): void {
    $user = User::factory()->create();

    $this->actingAs($user, 'office')
        ->get(officeUrl('/profil/guvenlik/ikinci-faktor'))
        ->assertOk()
        ->assertInertia(fn (AssertableInertia $page) => $page
            ->component('Profile/EnrolSecondFactor')
            ->has('qrCode')
            ->has('secret')
        );
});

it('writes an audit row in the same transaction as the enrolment', function (): void {
    // Sensitive operation (spec A-4). The audit row is written with the change,
    // never queued (kirmizi cizgi 13).
    $authenticator = app(AppAuthenticator::class);
    $user = User::factory()->create();

    $this->actingAs($user, 'office')->get(officeUrl('/profil/guvenlik/ikinci-faktor'));

    $secret = session('office_second_factor.enrolling_secret');

    $this->actingAs($user, 'office')
        ->post(officeUrl('/profil/guvenlik/ikinci-faktor'), [
            'code' => $authenticator->currentCode($secret),
        ])
        ->assertRedirect();

    expect($user->fresh()->two_factor_confirmed_at)->not->toBeNull();
});
```

- [ ] **Adım 2: Testi koştur, kırmızı gördüğünü doğrula**

- [ ] **Adım 3: `AppAuthenticator`'ı yaz**

```php
<?php

declare(strict_types=1);

namespace App\Support\Auth;

use App\Models\User;
use PragmaRX\Google2FA\Google2FA;
use PragmaRX\Google2FAQRCode\Google2FA as Google2FAQRCode;

/**
 * The authenticator app factor for the office identity.
 *
 * ⚠ Not Filament's provider. That one is bound to the backoffice panel and to
 * Filament's own contracts (App\Support\Auth\SecondFactor); the office panel is
 * Inertia and has no panel object to ask. The library underneath is the same
 * one Filament uses and is already installed as its dependency — no new
 * package enters the project.
 *
 * This class DECIDES NOTHING (AD-25): it generates, renders and verifies.
 * Whether a factor is required, and what happens when it is missing, is the
 * actions' and the policies' business.
 */
final class AppAuthenticator
{
    public function __construct(
        private readonly Google2FA $google2fa,
        private readonly Google2FAQRCode $qrCode,
    ) {}

    public function secret(): string
    {
        return $this->google2fa->generateSecretKey();
    }

    public function qrCodeSvg(User $user, string $secret): string
    {
        return $this->qrCode->getQRCodeInline(
            (string) config('app.name'),
            $user->email,
            $secret,
        );
    }

    public function verify(string $secret, string $code): bool
    {
        // A one step window either side: phone clocks drift, and refusing a
        // code that was correct two seconds ago teaches people to distrust the
        // screen rather than their clock.
        return $this->google2fa->verifyKey($secret, $code, window: 1);
    }

    /**
     * The code an authenticator app would be showing right now.
     *
     * ⚠ Test seam. Production never calls it — a server that can produce the
     * user's code has no second factor to speak of. Kept here rather than in a
     * fixture because it is the same library call, and a hand rolled TOTP in
     * the test suite would prove the fixture works, not the code.
     */
    public function currentCode(string $secret): string
    {
        return $this->google2fa->getCurrentOtp($secret);
    }

    /**
     * @return list<string>
     */
    public function recoveryCodes(int $count = 8): array
    {
        return collect(range(1, $count))
            ->map(fn (): string => strtoupper(bin2hex(random_bytes(2)).'-'.bin2hex(random_bytes(2))))
            ->all();
    }
}
```

- [ ] **Adım 4: Giriş akışına faktör adımını ekle**

`SignInOfficeUser` **değişmez** — kimlik doğrulamayı o yapar. Adım controller'da kurulur: `store()` başarılı girişten sonra hesabın faktörü varsa oturumu **kapatır**, kimliği oturumda bekletir ve faktör ekranına yönlendirir.

```php
    public function store(SignInRequest $request, SignInOfficeUser $signIn): RedirectResponse
    {
        $user = $signIn(
            email: (string) $request->validated('email'),
            password: (string) $request->validated('password'),
        );

        // ⚠ Enrolled means asked. A factor nobody is asked for is decoration:
        // the person sets it up, the login never uses it, and the account is
        // single factor while its owner believes otherwise (spec 5.2 v1.15
        // records exactly this failure on the backoffice side).
        //
        // Enrolment itself is NOT forced here — in the office the requirement
        // is permission based (ofis spec, karar 6). What is forced is the use
        // of a factor that exists.
        if ($user->hasCompletedMfa()) {
            Auth::guard(IdentityDomain::Office->guard())->logout();

            $request->session()->put('office_second_factor.pending_user', $user->getKey());
            // The half-state has a short life of its own: the password is
            // proven, the identity is not. Leaving it to the 60 minute idle
            // timeout would keep a proven-password window open far too long.
            $request->session()->put('office_second_factor.expires_at', now()->addMinutes(5)->toIso8601String());

            return redirect()->route('office.second-factor.show');
        }

        return redirect()->intended(route('office.dashboard'));
    }
```

⚠ `session()->regenerate()` `SignInOfficeUser` içinde zaten çağrıldı; `logout()` oturumu boşaltmaz, yalnız guard'ı düşürür — bekleyen kimlik anahtarı hayatta kalır.

- [ ] **Adım 5: `AnswerSecondFactorChallenge` action'ını yaz**

Kodu ya da kurtarma kodunu doğrular; kurtarma kodu kullanıldıysa listeden **düşürür**; başarılıysa guard'a giriş yapar ve `auth_method` ile `second_factor` oturum anahtarlarını yazar.

Üç kural, üçü de heyet bulgusu P-1'den:

**(a) Challenge ucu hız sınırına tabidir.** Görev 5'in `ThrottlesSignIn`'i burada da kullanılır — aynı anahtarlar, aynı sayaç. Gerekçe somut: TOTP altı hane ve `window: 1` her 30 saniyede ~3 geçerli kod demektir; sınırsız deneme, şifresi ele geçmiş bir hesapta ikinci faktörü çevrimiçi kaba kuvvete açar. Sayaç **başarılı** cevapta temizlenir (`clear()`) — şifre adımında değil, çünkü giriş orada bitmiyor.

**(b) Her deneme audit'lenir (B-5).** Şifre adımı `Failed` olayıyla iz bırakıyor; faktör reddi planın ilk sürümünde sessizdi. Sonuç: audit'te *"şifre doğru"* görünen ama hiç oturum açamamış denemeler hiç iz bırakmazdı — oysa görülmesi en anlamlı örüntü tam odur. Olay adı `auth.second_factor_failed` / `auth.second_factor_passed`; **aynı transaction'da** yazılır (kırmızı çizgi 13).

⚠ **Kurtarma kodu tüketimi ayrı bir olaydır** (`auth.recovery_code_used`), challenge sonucuyla birleştirilmez: biri *"faktör kanıtlandı"*, öbürü *"tek kullanımlık bir kaçış yolu harcandı"* demektir ve ikincisi tek başına incelemeye değer.

**(c) Bekleyen durumun ömrü sınırlıdır.** `office_second_factor.pending_user` şifresi kanıtlanmış bir yarı-durumdur; oturum ömrüne (ofiste 60 dk hareketsizlik) bırakılamaz. Yanına `office_second_factor.expires_at` yazılır ve **5 dakika** verilir; süresi geçmişse anahtarlar silinir ve kişi giriş ekranına döner.

```php
it('refuses the challenge after too many wrong codes', function (): void {
    $authenticator = app(AppAuthenticator::class);
    $user = User::factory()->create([
        'password' => 'Passw0rd!',
        'two_factor_secret' => $authenticator->secret(),
        'two_factor_confirmed_at' => now(),
    ]);

    $this->post(officeUrl('/giris'), ['email' => $user->email, 'password' => 'Passw0rd!']);

    foreach (range(1, (int) config('security.login_throttle.max_attempts')) as $attempt) {
        $this->from(officeUrl('/ikinci-faktor'))
            ->post(officeUrl('/ikinci-faktor'), ['code' => '000000']);
    }

    // The correct code now, and it must still be refused.
    $this->from(officeUrl('/ikinci-faktor'))
        ->post(officeUrl('/ikinci-faktor'), [
            'code' => $authenticator->currentCode($user->fresh()->two_factor_secret),
        ])
        ->assertSessionHasErrors('code');

    expect(auth('office')->check())->toBeFalse();
});

it('drops the pending sign in after its window closes', function (): void {
    $authenticator = app(AppAuthenticator::class);
    $user = User::factory()->create([
        'password' => 'Passw0rd!',
        'two_factor_secret' => $authenticator->secret(),
        'two_factor_confirmed_at' => now(),
    ]);

    $this->post(officeUrl('/giris'), ['email' => $user->email, 'password' => 'Passw0rd!']);

    $this->travel(6)->minutes();

    $this->get(officeUrl('/ikinci-faktor'))->assertRedirect(officeUrl('/giris'));
});

it('records both halves of the challenge in the audit trail', function (): void {
    // B-5: every attempt, successful and failed. A trail that only held the
    // password step would be silent about the pattern worth seeing — the
    // account whose password is known and whose factor is being guessed.
    // … assertions on the audit table, in the same transaction as the attempt
});
```

- [ ] **Adım 6: Kurulum akışını yaz**

`EnrolAppAuthentication` sırrı üretir ve **oturumda** tutar (`office_second_factor.enrolling_secret`) — veritabanına yazmaz. `ConfirmAppAuthentication` kodu doğrular, sırrı ve kurtarma kodlarını **tek transaction'da** yazar ve `AuditWriter::record()` çağırır.

⚠ Sır doğrulanmadan kaydedilmez: kaydedilseydi, QR'ı okumadan sayfayı kapatan kişinin hesabı kurulu görünür ve bir daha giremezdi.

- [ ] **Adım 7: Şifre modalını ekle**

Spec §5.3 (v1.16): ikinci faktör kurma ve kapatma **şifre adımı** ister ve bu adım **ayrı bir modaldır**, form alanı değil. `ConfirmModal` molekülü `04 Geri Bildirim`'in modal deseninden üretilir; şifre onayı bir varyantıdır.

⚠ Kapı sunucuda durur: modal tek kullanımlık bir oturum izni yazar, arkasındaki action onu **tüketir**. Butonu gizlemek kapı değildir — backoffice'te bu ders canlı öğrenildi.

⚠ **İzin işlemin ADINA yazılır** *(heyet bulgusu R3-2)*: `password_confirmed:<action>` — ör. `password_confirmed:change_password`, `password_confirmed:disable_second_factor`. **Yalnız o işlem tüketir** ve izin kısa bir süre sonra kendiliğinden düşer (5 dakika).

Kapsamsız tek bir bayrak *"tek kullanımlık izin"* cümlesini harfiyen karşılar ama şifre değiştirmek için verilen onayın **ikinci faktörü kapatmak** tarafından tüketilmesine izin verir — kullanıcı bir şeyi onaylar, başka bir şey olur. Backoffice'te bu ayrışma Filament'in `mountUsing` sarmalamasından **yapısal olarak** geliyordu; Inertia tarafında öyle bir yapı yok, o yüzden sözleşmeye yazılıyor.

- [ ] **Adım 8: Testleri yeşile al, kalite kapısı, commit**

```bash
./vendor/bin/pint --dirty && composer analyse && ./vendor/bin/pest --group=omurga
git commit -am "#EGT-54 In Progress work 4h

feat(ofis): ikinci faktor kurulumu, dogrulamasi ve kurtarma kodlari"
```

---

## Görev 7: Şifre sıfırlama

Ofis tarafında backend **yok** — `CustomerPasswordResetController` yalnız müşteri kimliğine bakıyor.

**Dosyalar:**
- Oluştur: `app/Http/Controllers/Auth/OfficePasswordResetController.php`, `app/Actions/Auth/{RequestOfficePasswordReset,ResetOfficePassword}.php`, `app/Notifications/OfficePasswordReset.php`, `resources/js/pages/Office/Auth/{ForgotPassword,ResetPassword}.vue`
- Test: `tests/Feature/Omurga/OfficePasswordResetTest.php`

- [ ] **Adım 1: Testi yaz** — bağlantı gönderiliyor · geçersiz token reddediliyor · şifre değişince **tüm oturumlar kapanıyor** (`TerminatesSessions`) · işlem audit'leniyor (spec A-4) · var olmayan e-posta için de aynı yanıt dönüyor (hesap varlığı sızdırılmıyor).

- [ ] **Adım 2: Testi koştur, kırmızı gördüğünü doğrula**

- [ ] **Adım 3: Action'ları ve controller'ı yaz** — `CustomerPasswordResetController`'ın desenini izle, ama broker `office` olsun (`config/auth.php`'de ofis için passwords broker'ı tanımlı mı kontrol et; yoksa ekle).

- [ ] **Adım 4: Bildirimi yaz** — Postmark üzerinden, metin `lang/` üzerinden.

- [ ] **Adım 5: Şifre güç göstergesini yaz** *(heyet bulgusu P-7a)*

Tasarımda var (`10 Ortak Auth`, üç çubuk + *"Orta — 1 sembol ekleyin"*), planın ilk sürümünde sahibi yoktu.

- Bileşen: `resources/js/molecules/PasswordStrength.vue` (üç çubuk + tek satır ipucu).
- Hesap: `resources/js/lib/password-strength.js` — **yeni bağımlılık yok**. Uzunluk ve karakter sınıfı sayan ~20 satırlık bir kural seti; `zxcvbn` sınıfı bir kitaplık 400 KB'lık bir sözlük indirir ve buradaki iş onu haklı çıkarmaz.

⚠ **Gösterge bir politika değildir.** Asıl kural sunucuda, Laravel'in `Password::defaults()` doğrulamasındadır; bu çubuklar yalnız kişiye ne yazdığını söyler. İkisi çelişirse **sunucu kazanır** ve ekran hatayı `FormField`'dan gösterir — göstergenin yeşil olması formun geçeceği anlamına gelmez.

- [ ] **Adım 6: Ekranları yaz** — `10 Ortak Auth`'un iki kartı: iste (e-posta) ve belirle (yeni şifre + tekrar + güç göstergesi). *"Güncelleme sonrası tüm aktif oturumlar sonlandırılır"* notu ekranda durur.

- [ ] **Adım 7: Testleri yeşile al, kalite kapısı, commit**

---

## Görev 8: `PanelLayout` — sidebar, topbar, sayfa başlığı

**Dosyalar:**
- Oluştur: `resources/js/templates/PanelLayout.vue`, `resources/js/organisms/{AppSidebar,AppTopbar}.vue`, `resources/js/molecules/{PageHeader,StatusBadge,LockNote,EmptyState}.vue`
- Değiştir: `resources/js/pages/Office/Dashboard.vue`
- Test: `tests/Feature/Omurga/OfficeNavigationTest.php`

**Arayüzler:**
- Tüketir: `navigation` prop'u (görev 1) — `[{ key, route }]`.
- Üretir: `PanelLayout` varsayılan layout olarak `office.js`'in `resolve`'unda atanır.

- [ ] **Adım 1: Testi yaz**

```php
it('renders only the menu items this person may see', function (): void {
    $user = User::factory()->create();
    // No permissions granted: the menu is empty and the screens are unreachable.

    $this->actingAs($user, 'office')
        ->get(officeUrl('/'))
        ->assertInertia(fn (AssertableInertia $page) => $page->where('navigation', []));

    // ⚠ Criterion 2f asks for both halves. Not offered is not enough.
    $this->actingAs($user, 'office')
        ->get(officeUrl('/kullanicilar'))
        ->assertForbidden();
});
```

- [ ] **Adım 2: `AppSidebar`'ı yaz** — `05 Navigasyon`: microcaps grup etiketi, gruplar arası hairline, grup başına ≤5 kalem, aktif durum, daraltılabilir. Yetkisiz kalem **hiç render edilmez** (liste zaten filtreli gelir).

- [ ] **Adım 3: `AppTopbar`'ı yaz** — breadcrumb, arama kutusu (T1'de pasif), bildirim çanı (T1'de yalnız yeri ayrılır — chunk 7 yazılmadı), profil menüsü (bilgilerim / güvenlik / bildirim tercihleri / çıkış).

- [ ] **Adım 4: `PageHeader`'ı yaz** — başlık + statü rozeti + künye satırı + birincil/ikincil aksiyon.

- [ ] **Adım 5: `StatusBadge`'i yaz** — statü adını semantik tona eşleyen **tek yer**. Atom (`Badge`) tonu alır, eşlemeyi bilmez.

- [ ] **Adım 6: `LockNote`'u yaz** — `06 Desen Ekleri`'nin kilit açıklama deseni: pasif aksiyonun yanında *neden* kilitli olduğunu söyleyen satır. ⚠ *"Yetkiniz yok"* ile *"bu kayıt olmaz"* ayrı cümlelerdir; bu bileşen ikincisidir.

- [ ] **Adım 7: Varsayılan layout'u bağla**

```javascript
    resolve: (name) => {
        const pages = import.meta.glob('./pages/Office/**/*.vue', { eager: true });
        const page = pages[`./pages/Office/${name}.vue`];

        // Auth screens carry their own layout; everything else lives in the
        // panel shell unless it says otherwise.
        page.default.layout = page.default.layout ?? (name.startsWith('Auth/') ? undefined : PanelLayout);

        return page;
    },
```

- [ ] **Adım 8: Testleri yeşile al, tarayıcıda gör, kalite kapısı, commit**

---

## Görev 9: Hata sayfaları ve ikinci faktör uyarı bandı

**Dosyalar:**
- Oluştur: `resources/js/templates/ErrorLayout.vue`, `resources/js/pages/Office/Errors/{Forbidden,NotFound,ServerError,Maintenance,SessionExpired}.vue`, `resources/js/molecules/Banner.vue`
- Değiştir: `bootstrap/app.php` (hata sayfası render'ı), `resources/js/templates/PanelLayout.vue`
- Test: `tests/Feature/Omurga/OfficeErrorPageTest.php`

- [ ] **Adım 1: Testi yaz** — 403 ofis host'unda Inertia `Errors/Forbidden` sayfası döner; audit satırı yine yazılır (`bootstrap/app.php`'nin mevcut davranışı bozulmamalı — testi bunu da ölçsün).

⚠ Test **iki isteği birden** ölçer: `X-Inertia` başlıklı olan (panel içi gezinme) ve **başlıksız** olan (adres çubuğuna yazılmış URL). İkincisi P-2'nin kapattığı senaryodur; yalnız birincisi ölçülürse bulgu geri döner.

```php
it('renders the office error page on a direct visit, not only inside Inertia', function (): void {
    $user = User::factory()->create();

    // No X-Inertia header: this is somebody pasting the address.
    $this->actingAs($user, 'office')
        ->get(officeUrl('/kullanicilar'))
        ->assertForbidden()
        ->assertSee('Errors/Forbidden', escape: false);
});
```

- [ ] **Adım 2: Hata sayfası render'ını ekle**

`bootstrap/app.php`'ye, mevcut 403 audit callback'ini **bozmadan**:

⚠ **Koşul `X-Inertia` DEĞİL, host'tur** *(heyet bulgusu P-2)*. İlk taslak `X-Inertia` başlığı yoksa dokunmuyordu; oysa adres çubuğuna yazılan URL, e-postadan gelen bağlantı ve yenilenen sayfa o başlığı **taşımaz** — yani tam ilk-yükleme senaryosunda kişi stok Laravel hata sayfasını görürdü ve spec §4.6 (*"hepsi Inertia sayfası"*) orada ihlal edilirdi. `Inertia::render()->toResponse()` zaten Inertia olmayan istekte tam HTML sayfası üretir; Inertia'nın kendi hata sayfası deseni bu yüzden guard'sızdır.

```php
        $exceptions->respond(function (Response $response, Throwable $e, Request $request) {
            // The office host serves Inertia; other hosts bind their own
            // components in their own tours.
            if ($request->getHost() !== config('app.hosts.office')) {
                return $response;
            }

            return match ($response->getStatusCode()) {
                403 => Inertia::render('Errors/Forbidden')->toResponse($request)->setStatusCode(403),
                404 => Inertia::render('Errors/NotFound')->toResponse($request)->setStatusCode(404),
                419 => Inertia::render('Errors/SessionExpired')->toResponse($request)->setStatusCode(419),
                // ⚠ Not in local or testing: replacing a 500 with a generic
                // page there hides the stack trace a developer is looking at.
                500, 503 => app()->environment(['local', 'testing'])
                    ? $response
                    : Inertia::render('Errors/ServerError')->toResponse($request)->setStatusCode($response->getStatusCode()),
                default => $response,
            };
        });
```

⚠ Mevcut `render(HttpException …)` callback'i `null` döndürüyor ve audit satırını yazıyor; `respond()` ondan **sonra** çalışır, ikisi çakışmaz.

- [ ] **Adım 2b: `Toast`'u yaz** *(heyet bulgusu P-7c)*

`flash` prop'u görev 1'de paylaşılıyor, görev 6, 7 ve 10'un aksiyonları doğal olarak flash üretiyor — ama çizicisi hiçbir görevde yoktu. Üretilen flash ekranda hiç görünmezdi.

`resources/js/molecules/Toast.vue`, `04 Geri Bildirim`'in deseni: sağ üst, 5 saniye, semantik dörtlüden renk. `PanelLayout` içinde `usePage().props.flash` izlenir.

⚠ **Toast kritik bilgi taşımaz.** Beş saniye sonra kaybolan bir şey, kaçıran için hiç olmamıştır. Kritik uyarı `Banner`'dır ve kapatılamaz (`HANDOFF` bağlayıcı kural: *"kritik bildirim çanla yetinmez, banner olarak da yaşar ve okundu işaretlenerek kapatılamaz; aksiyon alınınca düşer"*).

- [ ] **Adım 3: `Banner` molekülünü ve ikinci faktör bandını yaz**

`04 Geri Bildirim`'in banner deseni. `PanelLayout` içinde: `auth.has_second_factor` yanlış **ve** kişi MFA'lı bir izin tutuyorsa uyarı bandı görünür.

⚠ Bandın koşulu *"yetki bekliyor"* uyarısıyla **karışmamalı** (spec §5.5): bu bant kullanıcının kendisine gider ve çözümü ondadır. `has_mfa_gated_permission` prop'u `HandleInertiaRequests`'e eklenir.

- [ ] **Adım 4: Testleri yeşile al, kalite kapısı, commit**

---

## Görev 10: Aktif oturumlar (profil → güvenlik)

**Dosyalar:**
- Oluştur: `resources/js/pages/Office/Profile/Security.vue`, `resources/js/molecules/SessionRow.vue`
- Değiştir: `app/Http/Controllers/Auth/ActiveSessionController.php` (Inertia'ya çevir), `routes/office.php`
- Test: `tests/Feature/Omurga/OfficeActiveSessionTest.php`

- [ ] **Adım 1: Testi yaz**

**Aktif oturumlar:** kendi oturumları listeleniyor · başkasının oturumu görünmüyor · tek oturum sonlandırılıyor · mevcut oturumu sonlandırmak çıkış yaptırıyor · *"diğer tüm oturumları kapat"* çalışıyor.

**Şifre değiştirme** *(heyet bulgusu R3-3 — adım 3'te akış eklenmişti, test listesi v1.0'da kalmıştı)*:

- Modal izni **olmadan** endpoint reddediliyor; izin başka bir işlemin adına yazılmışsa da reddediliyor (R3-2'nin kapsam kuralı).
- Değişiklik audit satırı üretiyor — **aynı transaction'da** — ve kullanıcıya bildirim gidiyor (A-4).
- **Diğer** oturumlar kapanıyor, **mevcut oturum ayakta** kalıyor.
- Yeni şifre `Password::defaults()`'a takılınca hata `FormField`'da görünüyor; şifre değişmiyor.

⚠ Planın kendi ilkesi: kanıtı olmayan akış teslim edilmiş sayılmaz.

- [ ] **Adım 2: Controller'ı ikili yanıt verecek şekilde çevir** — Inertia isteği sayfa, diğerleri JSON. ⚠ `ActiveSessionController` üç kimlik alanı tarafından paylaşılıyor; müşteri tarafının JSON davranışı bozulmamalı.

- [ ] **Adım 3: Şifre değiştirme akışını yaz** *(heyet bulgusu P-7b)*

Güvenlik sayfasındaki *"Şifreyi Değiştir"* kartının arkası planın ilk sürümünde boştu.

- `app/Actions/Auth/ChangeOwnPassword.php` — yeni şifre; **A-4 gereği hassas işlem**: aynı transaction'da audit'lenir ve kullanıcıya bildirilir.
- `app/Http/Requests/Auth/ChangePasswordRequest.php` — `Password::defaults()`.
- Değişiklikten sonra **diğer** oturumlar kapanır (`TerminatesSessions`), mevcut oturum ayakta kalır — kişi kendi cihazından atılmaz.

⚠ **Şifre onayı ayrı modaldır, formun alanı değil** *(spec §5.3; Sets kararı, 28.07.2026 — heyet bulgusu R2-2)*. *"Şifreyi Değiştir"* butonu önce **şifre onay modalını** açar; modal geçilince yeni-şifre formu gelir. Formda **mevcut-şifre alanı yoktur** — modal onu zaten aldı.

Planın ilk sürümü buraya *"formun kendi alanı yeter"* diye muafiyet tanıyordu; spec §5.3 şifre değişimini **adıyla** sayan bir listede tutuyor ve muafiyet plan seviyesinde sessizce tanınamazdı.

⚠ Gerekçe tutarlılıktır: güvenlik sekmesindeki dört işlem — şifre değişimi, passkey ekleme/silme, ikinci faktör kurma/kapatma — **aynı kapıdan** geçer. *"Aynı kapı"* aynı **mekanizma** demektir, aynı **izin** değil: her işlem kendi adına yazılmış izni tüketir (görev 6 adım 7, R3-2). Kullanıcı tek bir şey öğrenir, ve kapı görev 6 adım 7'de yazılan `ConfirmModal` + tek kullanımlık oturum izni mekanizmasının aynısıdır; yeni bir mekanizma doğmaz.

- [ ] **Adım 4: Güvenlik sayfasını yaz** — `10 Ortak Auth`'un profil-güvenlik sekmesi: şifre kartı, iki adımlı doğrulama kartı (durum rozeti + cihaz değiştir + yedek kodlar), aktif oturumlar kartı.

- [ ] **Adım 5: Testleri yeşile al, kalite kapısı, commit**

---

## Görev 11: Playwright — responsive ve erişilebilirlik ölçümü

Spec kriter 7 ve 8'in kanıtı. Karar 10: zorunlu, ama **dar** — iş mantığı Playwright'a taşınmaz.

**Dosyalar:**
- Oluştur: `playwright.config.js`, `tests/Browser/office-accessibility.spec.js`, `tests/Browser/office-responsive.spec.js`
- Değiştir: `package.json` (`@playwright/test`, `test:browser` script'i), CI yapılandırması

- [ ] **Adım 1: Playwright'ı kur**

```bash
npm install --save-dev @playwright/test
npx playwright install chromium
```

- [ ] **Adım 2: Yapılandırmayı yaz** — dört viewport: 375, 768, 1024, 1440. `baseURL` ofis host'u, `.env.testing`'den.

- [ ] **Adım 3: Erişilebilirlik ölçümünü yaz**

⚠ İki ölçüm hatası baştan kapatılıyor *(heyet bulgusu P-4)*:

**(a) 44px kuralı dokunma HEDEFLERİ içindir, her bağlantı için değil.** Tasarımın kendi giriş ekranında *"Şifremi unuttum"* `text-caption` boyutunda bir satır içi bağlantıdır (~16px) ve HANDOFF onu böyle çizmiştir. Kuralı tüm `a` etiketlerine uygulamak, **tasarıma uygun** öğede kırmızı üretir. Satır içi metin bağlantısı WCAG'ın da adlandırılmış istisnasıdır. Seçici form kontrolleri ve butonlarla sınırlanır.

**(b) `element.focus()` programatik odaktır ve `:focus-visible` ile eşleşmeyebilir** — o zaman `outline: none` okunur ve focus-ring iddiası **sahte** düşer. Ölçüm klavyeyle yapılır.

```javascript
import { expect, test } from '@playwright/test';

// Touch targets, not every clickable thing. An inline caption link inside a
// sentence is designed to be small and WCAG names it as an exception.
const touchTargets = 'button, [role="button"], input:not([type="hidden"]), select, textarea';

test('every touch target is at least 44px tall', async ({ page }) => {
    await page.goto('/giris');

    for (const element of await page.locator(touchTargets).all()) {
        if (! await element.isVisible()) {
            continue;
        }

        const box = await element.boundingBox();

        expect(box.height, await element.evaluate((n) => n.outerHTML.slice(0, 80)))
            .toBeGreaterThanOrEqual(44);
    }
});

test('keyboard focus draws the accent ring', async ({ page }) => {
    await page.goto('/giris');

    // Tab, not focus(). :focus-visible is a keyboard-intent selector; a
    // programmatic focus can leave it unmatched and the assertion would then
    // pass or fail for the wrong reason.
    await page.keyboard.press('Tab');

    const outline = await page.evaluate(() => {
        const style = getComputedStyle(document.activeElement);

        return { width: style.outlineWidth, offset: style.outlineOffset, style: style.outlineStyle };
    });

    expect(outline.width).toBe('2px');
    expect(outline.offset).toBe('2px');
    expect(outline.style).not.toBe('none');
});
```

- [ ] **Adım 4: Responsive ölçümünü yaz** — 375px'te giriş akışı tamamlanıyor; hiçbir sayfada **yatay taşma** yok (`document.documentElement.scrollWidth <= clientWidth`).

- [ ] **Adım 5: CI'ya ekle** — `test:browser` script'i; TeamCity adımı. ⚠ Ofis host'u alan kısıtlı ve HTTPS gerektiriyor; CI'da hangi adres kullanılacağı `docs/infra/ortamlar.md`'den doğrulanır.

- [ ] **Adım 6: Kalite kapısı ve commit**

---

## Kapanış: tam koşu ve T1 raporu

- [ ] **Adım 1: Tam test koşusu**

```bash
./deployment/scripts/teamcity.sh trigger Egitim_Egitim main
./deployment/scripts/teamcity.sh wait <id>
```

⚠ Yerel tam koşu güvenilir değil — RLS grubu bağlantı yuvası tükenmesinden düşüyor (bilinen, ölçülmüş). **Otorite CI'dır.**

- [ ] **Adım 2: Kabul kriterlerini tek tek kanıtla**

Spec §9'un 13 kriterinden T1'e düşenler: 1, 2, 3, 4, 6, 7, 8. Her biri için **kanıt satırı** yaz: hangi test, hangi dosya, hangi satır. Kanıtı olmayan kriter kapanmış sayılmaz.

- [ ] **Adım 3: `EGT-54`'ü kapat, `current_todo.md`'yi güncelle, `EGT-55`'i In Progress yap**

---

## Öz-denetim notları

**Spec kapsaması.** §9'un 13 kriterinden T1'e düşen 7'si görevlere bağlı: kriter 1 → görev 5 · kriter 2 → görev 2 · kriter 3 → görev 8 · kriter 4 → görev 3 · kriter 6 → görev 2 · kriter 7, 8 → görev 11. Kriter 5, 12, 13 T2'nin (`EGT-55`), kriter 9, 10, 11 T3'ün (`EGT-56`).

**Bu planın açık bıraktıkları.**

1. **Ziggy kullanılmıyor.** `tightenco/ziggy` kurulu ama bu plan rota adlarını JS'e taşımıyor; formlar düz yol yazıyor (`/giris`). Gerekçe: Ziggy filtresiz kullanıldığında **üç panelin de rota adlarını** istemciye indirir ve ofis kullanıcısına backoffice rota isimlerini gösterir. Filtreli kullanmak mümkün ama T1'de ihtiyaç yok — rota sayısı bir elin parmakları kadar. İlk gerçek ihtiyaçta grup filtresiyle açılır.
2. **Görev 11'in CI adresi.** Playwright'ın CI'da hangi host'a bağlanacağı belirsiz: ofis host'u LAN'a bağlı ve HTTPS gerektiriyor. Görev 11 adım 5 bunu `docs/infra/ortamlar.md`'den çözecek; çözülemezse Playwright koşusu **yerelde zorunlu, CI'da opsiyonel** olarak konumlanır ve kapanış raporunda öyle yazılır.

3. **Müşteri girişinin hız sınırı** bu turda kapatılmıyor. `login_throttle` ayarı üç alanın hiçbirinde okunmuyordu; ofis yüzeyi burada kurulduğu için ofis tarafı burada kapanıyor. Müşteri tarafı `EGT-24`'e adreslidir ve kayıt `docs/issues.md`'ye düşülür — adressiz erteleme değildir.

**Plan yazılırken doğrulanıp kapanan iki kalem.**

- `config/auth.php:140` → `office_users` password broker'ı **zaten tanımlı**; görev 7 yeni broker açmayacak.
- CI'da `npm run build` adımı **yok** ve eklenmesi gerekiyor — görev 1 adım 14. Kontrol maddesiydi, iş maddesine dönüştü. *(28.07.2026: eklendi ve build #796 ile doğrulandı — 612 test SUCCESS, agent'ta `public/build/manifest.json` üretildi.)*

---

## Revizyon geçmişi

**1.3 — 28.07.2026.** Heyet plan re-review 152'nin (`docs/reviews/review-ofis-t1-plan-2026-07-28-r3.md`) üç bulgusu ve bir nit'i kapatıldı. R2-1 ve R2-2 kanıtla kapanmıştı; dört sorunun dördü de cevaplandı (`ip_max_attempts = 30` doğru büyüklük onaylandı).

⚠ **Tool özeti "0 bulgu" dedi, review'da üç bulgu vardı** — ayrıştırıcı `R3-` önekini tanımamış. Özete güvenilseydi üçü de kaçırılırdı; review dosyası okunarak bulundu.

| Kalem | Kapanış |
|---|---|
| **R3-1** `AnonymousRequestLimiter`'ın sınıf salonu riski kayıtsız | Kabul, **ve bulgu doğrulanınca büyüdü.** `RequestMagicLink:53-58` eşiği `password_reset_throttle`'dan okuyor: **3 istek / 60 dakika**, tek `$max` iki anahtara birden. 25 kişilik sınıf aynı salon wifi'sinden link isterse **4. kişiden itibaren** IP anahtarına takılır — ve `return 0` ile **sessizce**: kişi *"bağlantı gönderildi"* görür, posta gelmez. Ürünün çekirdek akışının kapısında mesaj üretmeyen bir tıkanma. `docs/issues.md`'ye senaryosu, `budgets()` desenine geçirilme ihtiyacı ve adresi (katılımcı turu + `EGT-51`) yazıldı; plandaki *"aynı kusur değil"* gerekçesi de düzeltildi — dokunmama gerekçesi **kapsamdır**, risk değerlendirmesi değil |
| **R3-2** Şifre onay izninin işlem kapsamı yazılmamış | Kabul. İzin artık işlemin adına yazılıyor (`password_confirmed:<action>`), yalnız o işlem tüketiyor, 5 dakikada düşüyor. Kapsamsız bayrak, şifre değiştirme onayının **ikinci faktörü kapatma** tarafından tüketilmesine izin verirdi — kullanıcı bir şeyi onaylar, başka bir şey olurdu. Backoffice'te ayrışma Filament'in yapısından geliyordu; Inertia'da sözleşmeye yazılmak zorunda |
| **R3-3** Görev 10'un test listesi şifre değiştirmeyi kapsamıyor | Kabul. Dört test eklendi: izinsiz (ve **yanlış işleme yazılmış izinle**) reddediliyor · audit aynı transaction'da + bildirim · diğer oturumlar kapanıyor, mevcut ayakta · `Password::defaults()` hatası `FormField`'da |
| **Nit** `ThrottlesSignIn` docblock'u hâlâ *"like AnonymousRequestLimiter"* diyor | Düzeltildi: iki sınıfın **farkı** belgelendi — tek eşik düşük frekanslı uçta çalışır, gerçek insanların adres paylaştığı her yerde kırılır (NAT ve salon wifi'si) |

**1.2 — 28.07.2026.** Heyet plan re-review 151'in (`docs/reviews/review-ofis-t1-plan-2026-07-28-r2.md`) iki bulgusu, bir açık sorusu ve iki nit'i kapatıldı. Önceki turun on iki kaleminin on ikisi de kanıtla kapanmıştı; bu ikisi **kapanışların içinden doğdu**.

| Kalem | Kapanış |
|---|---|
| **R2-1** IP anahtarı e-posta ile aynı eşiği paylaşıyor | Kabul, ve bulgu ofisin kendi ağ topolojisini görüyor: ekip tek NAT arkasında, paylaşılan 5 eşiği pazartesi sabahı bütün ofisi 15 dakika dışarı kilitlerdi — üstelik tek `max_attempts` alanıyla ayarlanamazdı. `budgets()` iki anahtara **ayrı eşik** veriyor; `login_throttle.ip_max_attempts` (tohum 30) eklendi. Gerekçe koda yazıldı: adres bir kişidir, IP bir **binadır**; IP sayacı unutulan şifreyi değil, tek makineden adres listesi yürümeyi yakalar |
| **R2-2** Şifre değişiminde modal muafiyeti spec'le çelişiyor | Kabul — **ve muafiyet kaldırıldı** *(Sets kararı)*. Spec §5.3 şifre değişimini adıyla sayıyor; plan ona uyar. Güvenlik sekmesindeki dört işlem aynı kapıdan geçer, formda mevcut-şifre alanı yok. Spec'e istisna **işlenmedi** |
| **Açık soru 1** müşteri hız sınırı kaydına EGT-51 bağlantısı | Eklendi. Uç bugün canlı; boşluk ekran turuna değil uca ait, dolayısıyla kayıt hem `EGT-24`'e (yüzey) hem `EGT-51`'e (prod kapısı) bağlı — hangisi önce gelirse orada kapanır |
| **Nit 1** faktörsüz yolda `clear()` çağrı yeri | Yazıldı: faktörlüde challenge başarısında, faktörsüzde `store()` yönlendirmeden hemen önce |
| **Nit 2** 500/503 eşlemesi local'de stack trace'i gizler | `app()->environment(['local', 'testing'])` istisnası eklendi; 403/404/419 her ortamda eşleniyor |

**1.1 — 28.07.2026.** Heyet plan review 150'nin (`docs/reviews/review-ofis-t1-plan-2026-07-28.md`) yedi bulgusu ve üç nit'i kapatıldı. Review verdict'i REJECTED'dı; hiçbir bulgu planın yapısını bozmadı.

| Bulgu | Kapanış |
|---|---|
| **P-1** İkinci faktör ucu A-1/B-5 dışında | Kabul, **ve bulgu genişledi.** Ölçtüm: `config/security.php:66`'daki `login_throttle` ayarını **hiçbir kod okumuyor** — A-1 üç kimlik alanının hiçbirinde uygulanmamış. Yani eksik olan yalnız challenge ucu değil, giriş akışının kendisiydi; ve ofis spec'i §8.1'in *"kısıtla = hız sınırı"* tanımı da olmayan bir mekanizmaya dayanıyordu. Görev 5 adım 2b `ThrottlesSignIn`'i yazıyor, görev 6 adım 5 challenge ucunu aynı sayaca bağlıyor, audit'i (`auth.second_factor_passed/failed`, kurtarma kodu ayrı olay) ve bekleyen durumun 5 dakikalık ömrünü tanımlıyor |
| **P-2** Hata sayfası guard'ı ilk yüklemeyi dışlıyor | Kabul. Koşul `X-Inertia` yerine **host** oldu; test iki isteği birden ölçüyor (başlıklı ve başlıksız) |
| **P-3** Kaldırılan `office.home` rotasının testi sahipsiz | Kabul, depoda doğrulandı (`HostRoutingTest.php:36`, `:44`). Görev 1 adım 10b iki assert'ün yeni hâlini yazıyor; dosya "Değiştir" listesine girdi |
| **P-4** Playwright'ın iki ölçümü yanlış kırmızı üretir | Kabul. 44px seçicisi dokunma hedefleriyle sınırlandı (tasarımın kendi caption bağlantısı istisnadır); odak ölçümü `focus()` yerine `Tab` ile yapılıyor — `:focus-visible` programatik odakta eşleşmeyebilir ve iddia sahte düşerdi |
| **P-5** `expectsJson` ölçümü gerçek isteği kurmuyor | Kabul. Test artık `X-Requested-With` ve gerçek `Accept` başlığını da gönderiyor; üretim kombinasyonu ölçülüyor |
| **P-6** `config/security.php` değişikliği görevsiz | Kabul. Görev 0 adım 7 |
| **P-7** Üç ekran öğesinin arkası boş | Kabul. (a) `PasswordStrength` molekülü + `lib/password-strength.js` — yeni bağımlılık yok, gösterge politika değil; (b) `ChangeOwnPassword` action'ı, A-4 audit'li; (c) `Toast` molekülü, görev 9 adım 2b |
| **Nit 1** iki ayrı "Adım 14" | Düzeltildi |
| **Nit 2** görev 6 haritası `SignInOfficeUser`'ı değiştiriyor diyordu | Düzeltildi — o dosyayı görev 5 değiştiriyor (hız sınırı), görev 6 değiştirmiyor |
| **Nit 3** çizilmeyen `remember` alanı | Düşürüldü — tasarımın ofis girişinde yok |
| **Açık soru 1** challenge audit'i ile kurtarma kodu audit'i birleşir mi | Ayrı kalır: biri *"faktör kanıtlandı"*, öbürü *"tek kullanımlık kaçış yolu harcandı"* — ikincisi tek başına incelemeye değer |

**1.0 — 28.07.2026.** İlk sürüm, 12 görev.
