# Explorateur de partage Windows + viewer — Plan d'implémentation

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Donner accès depuis Lesstime à un partage de fichiers Windows (SMB) avec un explorateur façon Drive/SharePoint, un viewer de documents, et une config admin activable + testable.

**Architecture:** Connexion SMB en PHP via `icewind/smb` (config chiffrée en base, calquée sur Zimbra). Une interface `FileSource` (lister/lire) isole la source. Deux endpoints live (`browse`, `download`) + un endpoint `status`. Front : explorateur maison (Nuxt) + viewer réutilisant le pattern `TaskDocumentPreview`, PDF rendu via PDF.js. Le lien « Documents » n'apparaît que si la fonctionnalité est activée.

**Tech Stack:** Symfony 8 / API Platform 4 / Doctrine / PostgreSQL, `icewind/smb`, `smbclient` (paquet Debian), Nuxt 4 / Vue 3 / Pinia, `vue-pdf-embed`.

**Spec de référence :** `docs/superpowers/specs/2026-06-03-explorateur-partage-windows-design.md`

**Conventions projet rappel :** `declare(strict_types=1)` partout ; commits `<type>(<scope>) : <message>` (espaces autour du `:`) ; controllers sous `/api/` avec `priority: 1` ; 4 espaces d'indentation côté front ; ne jamais modifier `@malio/layer-ui`.

---

## Structure des fichiers

**Backend (créés) :**
- `src/Entity/ShareConfiguration.php` — entité de config singleton (host, partage, basePath, domaine, login, mot de passe chiffré, enabled).
- `src/Repository/ShareConfigurationRepository.php` — `findSingleton()`.
- `src/ApiResource/ShareSettings.php` — DTO Get/Put `/api/settings/share`.
- `src/ApiResource/ShareTestConnection.php` — DTO Post `/api/settings/share/test`.
- `src/State/ShareSettingsProvider.php` / `ShareSettingsProcessor.php`.
- `src/State/ShareTestConnectionProvider.php`.
- `src/Service/Share/FileSource.php` — interface.
- `src/Service/Share/SmbFileSource.php` — implémentation SMB.
- `src/Service/Share/SharePathResolver.php` — normalisation + anti path-traversal (unité testée).
- `src/Service/Share/FileEntry.php` — value object d'une entrée (fichier/dossier).
- `src/Service/Share/ShareTestResult.php` — résultat du test de connexion.
- `src/Service/Share/Exception/InvalidPathException.php`, `ShareNotConfiguredException.php`, `ShareConnectionException.php`.
- `src/Controller/Share/ShareBrowseController.php`, `ShareDownloadController.php`, `ShareStatusController.php`.
- `migrations/VersionYYYYMMDDHHMMSS.php` — table `share_configuration` (générée).

**Backend (modifiés) :**
- `composer.json` — dépendance `icewind/smb`.
- `infra/dev/Dockerfile` + `infra/prod/Dockerfile` — paquet `smbclient`.
- `config/services.yaml` — alias `FileSource` → `SmbFileSource`.

**Frontend (créés) :**
- `frontend/services/dto/share.ts` — types `FileEntry`, `ShareBrowseResult`, `ShareSettings(Write)`, `ShareTestResult`, `ShareStatus`.
- `frontend/services/share.ts` — `browse`, `getDownloadUrl`, `getStatus`.
- `frontend/services/share-settings.ts` — `getSettings`, `saveSettings`, `testConnection`.
- `frontend/composables/useShareStatus.ts` — état « activé » mis en cache.
- `frontend/components/admin/AdminShareTab.vue` — onglet config.
- `frontend/components/share/SharedFilePreview.vue` — viewer.
- `frontend/pages/documents.vue` — explorateur.

**Frontend (modifiés) :**
- `frontend/package.json` — dépendance `vue-pdf-embed`.
- `frontend/pages/admin.vue` — onglet « Partage ».
- `frontend/layouts/default.vue` — lien « Documents » conditionnel.
- `frontend/i18n/locales/fr.json` (+ autres locales) — clés `sharedFiles.*`, `adminShare.*`.

---

## Task 1: Dépendances (composer + Docker)

**Files:**
- Modify: `composer.json`
- Modify: `infra/dev/Dockerfile` (~ligne 9, liste `apt-get install`)
- Modify: `infra/prod/Dockerfile` (stage `production`, ~ligne 41)

- [ ] **Step 1: Ajouter `smbclient` au Dockerfile dev**

Dans `infra/dev/Dockerfile`, ajouter `smbclient` à la liste de paquets de la commande `RUN apt-get update && apt-get install -y \` (~ligne 9), par exemple en ajoutant une ligne `    smbclient \` parmi les autres paquets.

- [ ] **Step 2: Ajouter `smbclient` au Dockerfile prod (stage production uniquement)**

Dans `infra/prod/Dockerfile`, repérer le stage `FROM php:8.4-fpm AS production` (~ligne 39) et sa commande `RUN apt-get update && apt-get install -y \` (~ligne 41). Ajouter `smbclient \` à cette liste. **Ne pas** toucher au stage `backend-build`.

- [ ] **Step 3: Rebuild de l'image dev et vérifier `smbclient`**

Run :
```bash
docker compose -f infra/dev/docker-compose.yml build php   # ou: make build si défini
docker exec php-lesstime-fpm which smbclient
```
Expected : un chemin (`/usr/bin/smbclient`).

> Si la commande `make`/compose diffère, adapter ; l'objectif est que `which smbclient` réponde dans le conteneur `php-lesstime-fpm`.

- [ ] **Step 4: Installer `icewind/smb` via Composer (dans le conteneur)**

Run :
```bash
docker exec -u www-data php-lesstime-fpm composer require icewind/smb
```
Expected : `composer.json` et `composer.lock` mis à jour, dossier `vendor/icewind/smb` présent.

- [ ] **Step 5: Commit**

```bash
git add composer.json composer.lock infra/dev/Dockerfile infra/prod/Dockerfile
git commit -m "build(share) : ajout icewind/smb et paquet smbclient (dev + prod)"
```

> **Rappel déploiement (à ne pas oublier en prod)** : l'image prod `lesstime-app` devra être rebuildée et redéployée pour embarquer `smbclient`, et la migration de la Task 3 jouée en prod.

---

## Task 2: Entité `ShareConfiguration` + repository + migration

**Files:**
- Create: `src/Entity/ShareConfiguration.php`
- Create: `src/Repository/ShareConfigurationRepository.php`
- Create: `migrations/VersionYYYYMMDDHHMMSS.php` (générée)

- [ ] **Step 1: Créer le repository**

```php
<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\ShareConfiguration;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class ShareConfigurationRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, ShareConfiguration::class);
    }

    public function findSingleton(): ?ShareConfiguration
    {
        return $this->createQueryBuilder('s')
            ->setMaxResults(1)
            ->getQuery()
            ->getOneOrNullResult()
        ;
    }
}
```

- [ ] **Step 2: Créer l'entité**

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\ShareConfigurationRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: ShareConfigurationRepository::class)]
class ShareConfiguration
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $host = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $shareName = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $basePath = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $domain = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $username = null;

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $encryptedPassword = null;

    #[ORM\Column(type: 'boolean')]
    private bool $enabled = false;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getHost(): ?string
    {
        return $this->host;
    }

    public function setHost(?string $host): static
    {
        $this->host = $host;

        return $this;
    }

    public function getShareName(): ?string
    {
        return $this->shareName;
    }

    public function setShareName(?string $shareName): static
    {
        $this->shareName = $shareName;

        return $this;
    }

    public function getBasePath(): ?string
    {
        return $this->basePath;
    }

    public function setBasePath(?string $basePath): static
    {
        $this->basePath = $basePath;

        return $this;
    }

    public function getDomain(): ?string
    {
        return $this->domain;
    }

    public function setDomain(?string $domain): static
    {
        $this->domain = $domain;

        return $this;
    }

    public function getUsername(): ?string
    {
        return $this->username;
    }

    public function setUsername(?string $username): static
    {
        $this->username = $username;

        return $this;
    }

    public function getEncryptedPassword(): ?string
    {
        return $this->encryptedPassword;
    }

    public function setEncryptedPassword(?string $encryptedPassword): static
    {
        $this->encryptedPassword = $encryptedPassword;

        return $this;
    }

    public function isEnabled(): bool
    {
        return $this->enabled;
    }

    public function setEnabled(bool $enabled): static
    {
        $this->enabled = $enabled;

        return $this;
    }

    public function hasPassword(): bool
    {
        return null !== $this->encryptedPassword;
    }

    public function isUsable(): bool
    {
        return $this->enabled
            && null !== $this->host && '' !== $this->host
            && null !== $this->shareName && '' !== $this->shareName;
    }
}
```

- [ ] **Step 3: Générer la migration**

Run :
```bash
docker exec -u www-data php-lesstime-fpm php bin/console make:migration --no-interaction
```
Expected : un fichier `migrations/VersionYYYYMMDDHHMMSS.php` créé, contenant un `CREATE TABLE share_configuration` (colonnes en minuscules : `host`, `share_name`, `base_path`, `domain`, `username`, `encrypted_password`, `enabled`).

- [ ] **Step 4: Jouer la migration**

Run :
```bash
docker exec -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction
```
Expected : `migrated` sans erreur. Vérifier : `docker exec php-lesstime-fpm php bin/console dbal:run-sql "SELECT * FROM share_configuration"` → 0 ligne, pas d'erreur de table.

- [ ] **Step 5: Commit**

```bash
git add src/Entity/ShareConfiguration.php src/Repository/ShareConfigurationRepository.php migrations/
git commit -m "feat(share) : entité ShareConfiguration + migration"
```

---

## Task 3: Résolution de chemin (anti path-traversal) — unité testée

**Files:**
- Create: `src/Service/Share/Exception/InvalidPathException.php`
- Create: `src/Service/Share/SharePathResolver.php`
- Test: `tests/Unit/Service/SharePathResolverTest.php`

- [ ] **Step 1: Écrire le test (qui échoue)**

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use App\Service\Share\Exception\InvalidPathException;
use App\Service\Share\SharePathResolver;
use PHPUnit\Framework\TestCase;

final class SharePathResolverTest extends TestCase
{
    private SharePathResolver $resolver;

    protected function setUp(): void
    {
        $this->resolver = new SharePathResolver();
    }

    public function testNormalizeRelativeKeepsSimplePath(): void
    {
        self::assertSame('a/b', $this->resolver->normalizeRelative('a/b'));
    }

    public function testNormalizeRelativeStripsDotsAndSlashes(): void
    {
        self::assertSame('a/b', $this->resolver->normalizeRelative('/a/./b/'));
    }

    public function testNormalizeRelativeConvertsBackslashes(): void
    {
        self::assertSame('a/b', $this->resolver->normalizeRelative('a\\b'));
    }

    public function testNormalizeRelativeRejectsParentTraversal(): void
    {
        $this->expectException(InvalidPathException::class);
        $this->resolver->normalizeRelative('a/../b');
    }

    public function testNormalizeRelativeRejectsLeadingParent(): void
    {
        $this->expectException(InvalidPathException::class);
        $this->resolver->normalizeRelative('../etc/passwd');
    }

    public function testFullPathJoinsBaseAndRelative(): void
    {
        self::assertSame('/Projets/a/b', $this->resolver->fullPath('/Projets', 'a/b'));
    }

    public function testFullPathWithEmptyBaseAndEmptyRelativeIsRoot(): void
    {
        self::assertSame('/', $this->resolver->fullPath('', ''));
    }

    public function testFullPathTrimsBaseSlashes(): void
    {
        self::assertSame('/Projets/a', $this->resolver->fullPath('/Projets/', 'a'));
    }
}
```

- [ ] **Step 2: Lancer le test → échec attendu**

Run :
```bash
docker exec -u www-data php-lesstime-fpm vendor/bin/phpunit tests/Unit/Service/SharePathResolverTest.php
```
Expected : FAIL (classes `SharePathResolver` / `InvalidPathException` introuvables).

- [ ] **Step 3: Créer l'exception**

```php
<?php

declare(strict_types=1);

namespace App\Service\Share\Exception;

use RuntimeException;

final class InvalidPathException extends RuntimeException
{
}
```

- [ ] **Step 4: Implémenter le resolver**

```php
<?php

declare(strict_types=1);

namespace App\Service\Share;

use App\Service\Share\Exception\InvalidPathException;

final class SharePathResolver
{
    /**
     * Normalise un chemin relatif et rejette toute tentative de sortie de racine.
     */
    public function normalizeRelative(string $path): string
    {
        $path     = str_replace('\\', '/', $path);
        $segments = [];

        foreach (explode('/', $path) as $segment) {
            if ('' === $segment || '.' === $segment) {
                continue;
            }
            if ('..' === $segment) {
                throw new InvalidPathException('Path traversal is not allowed.');
            }
            $segments[] = $segment;
        }

        return implode('/', $segments);
    }

    /**
     * Construit le chemin SMB absolu (toujours sous basePath).
     */
    public function fullPath(string $basePath, string $relativePath): string
    {
        $base     = trim(str_replace('\\', '/', $basePath), '/');
        $relative = $this->normalizeRelative($relativePath);

        $parts = array_values(array_filter([$base, $relative], static fn (string $p): bool => '' !== $p));

        return '/'.implode('/', $parts);
    }
}
```

- [ ] **Step 5: Lancer le test → succès attendu**

Run :
```bash
docker exec -u www-data php-lesstime-fpm vendor/bin/phpunit tests/Unit/Service/SharePathResolverTest.php
```
Expected : PASS (8 tests).

- [ ] **Step 6: Commit**

```bash
git add src/Service/Share/SharePathResolver.php src/Service/Share/Exception/InvalidPathException.php tests/Unit/Service/SharePathResolverTest.php
git commit -m "feat(share) : résolution de chemin SMB anti path-traversal"
```

---

## Task 4: `FileSource` + `SmbFileSource` + value objects

**Files:**
- Create: `src/Service/Share/FileEntry.php`
- Create: `src/Service/Share/ShareTestResult.php`
- Create: `src/Service/Share/Exception/ShareNotConfiguredException.php`
- Create: `src/Service/Share/Exception/ShareConnectionException.php`
- Create: `src/Service/Share/FileSource.php`
- Create: `src/Service/Share/SmbFileSource.php`
- Modify: `config/services.yaml`

- [ ] **Step 1: Value object `FileEntry`**

```php
<?php

declare(strict_types=1);

namespace App\Service\Share;

final readonly class FileEntry
{
    public function __construct(
        public string $name,
        public string $path,
        public bool $isDir,
        public int $size,
        public ?int $modifiedAt,
        public string $mimeType,
    ) {}
}
```

- [ ] **Step 2: Value object `ShareTestResult`**

```php
<?php

declare(strict_types=1);

namespace App\Service\Share;

final readonly class ShareTestResult
{
    public function __construct(
        public bool $success,
        public ?string $message = null,
    ) {}
}
```

- [ ] **Step 3: Exceptions de source**

`src/Service/Share/Exception/ShareNotConfiguredException.php` :
```php
<?php

declare(strict_types=1);

namespace App\Service\Share\Exception;

use RuntimeException;

final class ShareNotConfiguredException extends RuntimeException
{
}
```

`src/Service/Share/Exception/ShareConnectionException.php` :
```php
<?php

declare(strict_types=1);

namespace App\Service\Share\Exception;

use RuntimeException;

final class ShareConnectionException extends RuntimeException
{
}
```

- [ ] **Step 4: Interface `FileSource`**

```php
<?php

declare(strict_types=1);

namespace App\Service\Share;

interface FileSource
{
    /**
     * @return FileEntry[] dossiers d'abord, puis fichiers, triés par nom
     */
    public function dir(string $relativePath): array;

    /**
     * @return resource flux binaire en lecture
     */
    public function read(string $relativePath);

    public function test(): ShareTestResult;
}
```

- [ ] **Step 5: Implémentation `SmbFileSource`**

```php
<?php

declare(strict_types=1);

namespace App\Service\Share;

use App\Entity\ShareConfiguration;
use App\Repository\ShareConfigurationRepository;
use App\Service\Share\Exception\ShareConnectionException;
use App\Service\Share\Exception\ShareNotConfiguredException;
use App\Service\TokenEncryptor;
use Icewind\SMB\BasicAuth;
use Icewind\SMB\IFileInfo;
use Icewind\SMB\IShare;
use Icewind\SMB\ServerFactory;
use Symfony\Component\Mime\MimeTypes;
use Throwable;

final class SmbFileSource implements FileSource
{
    public function __construct(
        private readonly ShareConfigurationRepository $configRepository,
        private readonly TokenEncryptor $tokenEncryptor,
        private readonly SharePathResolver $pathResolver,
    ) {}

    public function dir(string $relativePath): array
    {
        $config = $this->requireUsableConfig();
        $share  = $this->connect($config);
        $full   = $this->pathResolver->fullPath((string) $config->getBasePath(), $relativePath);

        try {
            $infos = $share->dir($full);
        } catch (Throwable $e) {
            throw new ShareConnectionException($e->getMessage(), 0, $e);
        }

        $entries = array_map(fn (IFileInfo $i): FileEntry => $this->toEntry($i, $relativePath), $infos);

        usort($entries, static function (FileEntry $a, FileEntry $b): int {
            if ($a->isDir !== $b->isDir) {
                return $a->isDir ? -1 : 1;
            }

            return strcasecmp($a->name, $b->name);
        });

        return $entries;
    }

    public function read(string $relativePath)
    {
        $config = $this->requireUsableConfig();
        $share  = $this->connect($config);
        $full   = $this->pathResolver->fullPath((string) $config->getBasePath(), $relativePath);

        try {
            return $share->read($full);
        } catch (Throwable $e) {
            throw new ShareConnectionException($e->getMessage(), 0, $e);
        }
    }

    public function test(): ShareTestResult
    {
        try {
            $config = $this->requireUsableConfig();
            $share  = $this->connect($config);
            $share->dir($this->pathResolver->fullPath((string) $config->getBasePath(), ''));

            return new ShareTestResult(true);
        } catch (ShareNotConfiguredException $e) {
            return new ShareTestResult(false, 'Configuration incomplète ou désactivée.');
        } catch (Throwable $e) {
            return new ShareTestResult(false, $e->getMessage());
        }
    }

    private function requireUsableConfig(): ShareConfiguration
    {
        $config = $this->configRepository->findSingleton();

        if (null === $config || !$config->isUsable()) {
            throw new ShareNotConfiguredException('Share is not configured or disabled.');
        }

        return $config;
    }

    private function connect(ShareConfiguration $config): IShare
    {
        $password = null !== $config->getEncryptedPassword()
            ? $this->tokenEncryptor->decrypt($config->getEncryptedPassword())
            : '';

        $auth   = new BasicAuth(
            (string) $config->getUsername(),
            $config->getDomain() ?: 'WORKGROUP',
            $password,
        );
        $server = (new ServerFactory())->createServer((string) $config->getHost(), $auth);

        try {
            return $server->getShare((string) $config->getShareName());
        } catch (Throwable $e) {
            throw new ShareConnectionException($e->getMessage(), 0, $e);
        }
    }

    private function toEntry(IFileInfo $info, string $parentRelative): FileEntry
    {
        $parent = '' === $parentRelative ? '' : rtrim($parentRelative, '/').'/';
        $path   = $parent.$info->getName();
        $isDir  = $info->isDirectory();

        $mime = 'application/octet-stream';
        if (!$isDir) {
            $guessed = MimeTypes::getDefault()->getMimeTypes(pathinfo($info->getName(), PATHINFO_EXTENSION));
            $mime    = $guessed[0] ?? 'application/octet-stream';
        }

        return new FileEntry(
            name: $info->getName(),
            path: $path,
            isDir: $isDir,
            size: $isDir ? 0 : $info->getSize(),
            modifiedAt: $info->getMTime(),
            mimeType: $mime,
        );
    }
}
```

- [ ] **Step 6: Alias `FileSource` dans `services.yaml`**

Dans `config/services.yaml`, sous la clé `services:`, ajouter (après le bloc `_defaults` / `App\:` existant) :
```yaml
    App\Service\Share\FileSource: '@App\Service\Share\SmbFileSource'
```

- [ ] **Step 7: Vérifier que le conteneur compile**

Run :
```bash
docker exec -u www-data php-lesstime-fpm php bin/console cache:clear
docker exec -u www-data php-lesstime-fpm php bin/console debug:container App\\Service\\Share\\SmbFileSource
```
Expected : pas d'erreur, le service est listé.

- [ ] **Step 8: Commit**

```bash
git add src/Service/Share/ config/services.yaml
git commit -m "feat(share) : source de fichiers SMB (FileSource + SmbFileSource)"
```

---

## Task 5: Config admin — `ShareSettings` (Get/Put) + provider/processor

**Files:**
- Create: `src/ApiResource/ShareSettings.php`
- Create: `src/State/ShareSettingsProvider.php`
- Create: `src/State/ShareSettingsProcessor.php`
- Test: `tests/Functional/Controller/ShareSettingsTest.php`

- [ ] **Step 1: DTO `ShareSettings`**

```php
<?php

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Put;
use App\State\ShareSettingsProcessor;
use App\State\ShareSettingsProvider;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new Get(
            uriTemplate: '/settings/share',
            normalizationContext: ['groups' => ['share_settings:read']],
            provider: ShareSettingsProvider::class,
            security: "is_granted('ROLE_ADMIN')",
        ),
        new Put(
            uriTemplate: '/settings/share',
            denormalizationContext: ['groups' => ['share_settings:write']],
            normalizationContext: ['groups' => ['share_settings:read']],
            provider: ShareSettingsProvider::class,
            processor: ShareSettingsProcessor::class,
            security: "is_granted('ROLE_ADMIN')",
        ),
    ],
)]
final class ShareSettings
{
    #[Groups(['share_settings:read', 'share_settings:write'])]
    public ?string $host = null;

    #[Groups(['share_settings:read', 'share_settings:write'])]
    public ?string $shareName = null;

    #[Groups(['share_settings:read', 'share_settings:write'])]
    public ?string $basePath = null;

    #[Groups(['share_settings:read', 'share_settings:write'])]
    public ?string $domain = null;

    #[Groups(['share_settings:read', 'share_settings:write'])]
    public ?string $username = null;

    #[Groups(['share_settings:write'])]
    public ?string $password = null;

    #[Groups(['share_settings:read', 'share_settings:write'])]
    public bool $enabled = false;

    #[Groups(['share_settings:read'])]
    public bool $hasPassword = false;
}
```

- [ ] **Step 2: Provider**

```php
<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\ShareSettings;
use App\Repository\ShareConfigurationRepository;

final readonly class ShareSettingsProvider implements ProviderInterface
{
    public function __construct(
        private ShareConfigurationRepository $configRepository,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): ShareSettings
    {
        $config = $this->configRepository->findSingleton();
        $dto    = new ShareSettings();

        if (null !== $config) {
            $dto->host        = $config->getHost();
            $dto->shareName   = $config->getShareName();
            $dto->basePath    = $config->getBasePath();
            $dto->domain      = $config->getDomain();
            $dto->username    = $config->getUsername();
            $dto->enabled     = $config->isEnabled();
            $dto->hasPassword = $config->hasPassword();
        }

        return $dto;
    }
}
```

- [ ] **Step 3: Processor**

```php
<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\ShareSettings;
use App\Entity\ShareConfiguration;
use App\Repository\ShareConfigurationRepository;
use App\Service\TokenEncryptor;
use Doctrine\ORM\EntityManagerInterface;

final readonly class ShareSettingsProcessor implements ProcessorInterface
{
    public function __construct(
        private EntityManagerInterface $em,
        private ShareConfigurationRepository $configRepository,
        private TokenEncryptor $tokenEncryptor,
    ) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ShareSettings
    {
        assert($data instanceof ShareSettings);

        $config = $this->configRepository->findSingleton() ?? new ShareConfiguration();

        $config->setHost($data->host);
        $config->setShareName($data->shareName);
        $config->setBasePath($data->basePath);
        $config->setDomain($data->domain);
        $config->setUsername($data->username);
        $config->setEnabled($data->enabled);

        if (null !== $data->password && '' !== $data->password) {
            $config->setEncryptedPassword($this->tokenEncryptor->encrypt($data->password));
        }

        $this->em->persist($config);
        $this->em->flush();

        $result              = new ShareSettings();
        $result->host        = $config->getHost();
        $result->shareName   = $config->getShareName();
        $result->basePath    = $config->getBasePath();
        $result->domain      = $config->getDomain();
        $result->username    = $config->getUsername();
        $result->enabled     = $config->isEnabled();
        $result->hasPassword = $config->hasPassword();

        return $result;
    }
}
```

- [ ] **Step 4: Test fonctionnel (sécurité + non-exposition du mot de passe)**

```php
<?php

declare(strict_types=1);

namespace App\Tests\Functional\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class ShareSettingsTest extends WebTestCase
{
    public function testGetSettingsRequiresAdmin(): void
    {
        $client = static::createClient();
        $client->request('GET', '/api/settings/share');

        self::assertSame(401, $client->getResponse()->getStatusCode());
    }

    public function testAdminCanReadSettingsWithoutPasswordLeak(): void
    {
        $client = static::createClient();
        $this->loginAsAdmin($client);

        $client->request('GET', '/api/settings/share');

        self::assertResponseIsSuccessful();
        $data = json_decode($client->getResponse()->getContent(), true);
        self::assertArrayHasKey('hasPassword', $data);
        self::assertArrayNotHasKey('password', $data);
        self::assertArrayNotHasKey('encryptedPassword', $data);
    }

    private function loginAsAdmin(\Symfony\Bundle\FrameworkBundle\KernelBrowser $client): void
    {
        $client->request('POST', '/login_check', server: [
            'CONTENT_TYPE' => 'application/json',
        ], content: json_encode(['username' => 'admin', 'password' => 'admin']));
    }
}
```

> Si l'authentification de test des autres `tests/Functional/Controller` utilise un helper différent (regarder un test existant, ex. un test Gitea/Zimbra s'il existe, sinon le pattern de login JWT), aligner `loginAsAdmin` dessus.

- [ ] **Step 5: Lancer les tests**

Run :
```bash
docker exec -u www-data php-lesstime-fpm vendor/bin/phpunit tests/Functional/Controller/ShareSettingsTest.php
```
Expected : PASS.

- [ ] **Step 6: Commit**

```bash
git add src/ApiResource/ShareSettings.php src/State/ShareSettingsProvider.php src/State/ShareSettingsProcessor.php tests/Functional/Controller/ShareSettingsTest.php
git commit -m "feat(share) : endpoints de configuration admin (GET/PUT settings/share)"
```

---

## Task 6: Test de connexion — `ShareTestConnection` (Post)

**Files:**
- Create: `src/ApiResource/ShareTestConnection.php`
- Create: `src/State/ShareTestConnectionProvider.php`

- [ ] **Step 1: DTO `ShareTestConnection`**

```php
<?php

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\State\ShareTestConnectionProvider;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new Post(
            uriTemplate: '/settings/share/test',
            input: false,
            normalizationContext: ['groups' => ['share_test:read']],
            provider: ShareTestConnectionProvider::class,
            processor: ShareTestConnectionProvider::class,
            security: "is_granted('ROLE_ADMIN')",
        ),
    ],
)]
final class ShareTestConnection
{
    #[Groups(['share_test:read'])]
    public bool $success = false;

    #[Groups(['share_test:read'])]
    public ?string $message = null;
}
```

- [ ] **Step 2: Provider/Processor**

```php
<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\ShareTestConnection;
use App\Service\Share\FileSource;

final readonly class ShareTestConnectionProvider implements ProviderInterface, ProcessorInterface
{
    public function __construct(
        private FileSource $fileSource,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): ShareTestConnection
    {
        return new ShareTestConnection();
    }

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ShareTestConnection
    {
        $result = $this->fileSource->test();

        $dto          = new ShareTestConnection();
        $dto->success = $result->success;
        $dto->message = $result->message;

        return $dto;
    }
}
```

- [ ] **Step 3: Vérifier la route**

Run :
```bash
docker exec -u www-data php-lesstime-fpm php bin/console debug:router | grep "settings/share"
```
Expected : voir `/api/settings/share` (GET, PUT) et `/api/settings/share/test` (POST).

- [ ] **Step 4: Commit**

```bash
git add src/ApiResource/ShareTestConnection.php src/State/ShareTestConnectionProvider.php
git commit -m "feat(share) : endpoint test de connexion (POST settings/share/test)"
```

---

## Task 7: Controllers de navigation — `status`, `browse`, `download`

**Files:**
- Create: `src/Controller/Share/ShareStatusController.php`
- Create: `src/Controller/Share/ShareBrowseController.php`
- Create: `src/Controller/Share/ShareDownloadController.php`
- Test: `tests/Functional/Controller/ShareBrowseTest.php`

- [ ] **Step 1: `ShareStatusController`**

```php
<?php

declare(strict_types=1);

namespace App\Controller\Share;

use App\Repository\ShareConfigurationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

class ShareStatusController extends AbstractController
{
    public function __construct(
        private readonly ShareConfigurationRepository $configRepository,
    ) {}

    #[Route('/api/share/status', name: 'share_status', methods: ['GET'], priority: 1)]
    #[IsGranted('IS_AUTHENTICATED_FULLY')]
    public function __invoke(): JsonResponse
    {
        $config = $this->configRepository->findSingleton();

        return new JsonResponse(['enabled' => null !== $config && $config->isUsable()]);
    }
}
```

- [ ] **Step 2: `ShareBrowseController`**

```php
<?php

declare(strict_types=1);

namespace App\Controller\Share;

use App\Service\Share\Exception\InvalidPathException;
use App\Service\Share\Exception\ShareConnectionException;
use App\Service\Share\Exception\ShareNotConfiguredException;
use App\Service\Share\FileEntry;
use App\Service\Share\FileSource;
use App\Service\Share\SharePathResolver;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

class ShareBrowseController extends AbstractController
{
    public function __construct(
        private readonly FileSource $fileSource,
        private readonly SharePathResolver $pathResolver,
    ) {}

    #[Route('/api/share/browse', name: 'share_browse', methods: ['GET'], priority: 1)]
    #[IsGranted('IS_AUTHENTICATED_FULLY')]
    public function __invoke(Request $request): JsonResponse
    {
        $rawPath = (string) $request->query->get('path', '');

        try {
            $path = $this->pathResolver->normalizeRelative($rawPath);
        } catch (InvalidPathException) {
            return new JsonResponse(['error' => 'Invalid path.'], 400);
        }

        try {
            $entries = $this->fileSource->dir($path);
        } catch (ShareNotConfiguredException) {
            return new JsonResponse(['error' => 'Share not configured.'], 409);
        } catch (ShareConnectionException $e) {
            return new JsonResponse(['error' => $e->getMessage()], 502);
        }

        return new JsonResponse([
            'path'       => $path,
            'breadcrumb' => $this->breadcrumb($path),
            'entries'    => array_map(static fn (FileEntry $e): array => [
                'name'       => $e->name,
                'path'       => $e->path,
                'isDir'      => $e->isDir,
                'size'       => $e->size,
                'modifiedAt' => $e->modifiedAt,
                'mimeType'   => $e->mimeType,
            ], $entries),
        ]);
    }

    /**
     * @return array<int, array{name: string, path: string}>
     */
    private function breadcrumb(string $path): array
    {
        if ('' === $path) {
            return [];
        }

        $crumbs = [];
        $acc    = '';
        foreach (explode('/', $path) as $segment) {
            $acc      = '' === $acc ? $segment : $acc.'/'.$segment;
            $crumbs[] = ['name' => $segment, 'path' => $acc];
        }

        return $crumbs;
    }
}
```

- [ ] **Step 3: `ShareDownloadController`**

```php
<?php

declare(strict_types=1);

namespace App\Controller\Share;

use App\Service\Share\Exception\InvalidPathException;
use App\Service\Share\Exception\ShareConnectionException;
use App\Service\Share\Exception\ShareNotConfiguredException;
use App\Service\Share\FileSource;
use App\Service\Share\SharePathResolver;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Mime\MimeTypes;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

class ShareDownloadController extends AbstractController
{
    public function __construct(
        private readonly FileSource $fileSource,
        private readonly SharePathResolver $pathResolver,
    ) {}

    #[Route('/api/share/download', name: 'share_download', methods: ['GET'], priority: 1)]
    #[IsGranted('IS_AUTHENTICATED_FULLY')]
    public function __invoke(Request $request): Response
    {
        $rawPath = (string) $request->query->get('path', '');

        try {
            $path = $this->pathResolver->normalizeRelative($rawPath);
        } catch (InvalidPathException) {
            return new Response('Invalid path.', 400);
        }

        if ('' === $path) {
            throw new NotFoundHttpException('No file requested.');
        }

        try {
            $stream = $this->fileSource->read($path);
        } catch (ShareNotConfiguredException) {
            return new Response('Share not configured.', 409);
        } catch (ShareConnectionException $e) {
            throw new NotFoundHttpException($e->getMessage());
        }

        $name      = basename($path);
        $extension = pathinfo($name, PATHINFO_EXTENSION);
        $mime      = MimeTypes::getDefault()->getMimeTypes($extension)[0] ?? 'application/octet-stream';

        // SVG toujours en attachment (anti-XSS) ; sinon respecte le paramètre demandé.
        $requested   = 'attachment' === $request->query->get('disposition') ? 'attachment' : 'inline';
        $disposition = 'image/svg+xml' === $mime ? HeaderUtils::DISPOSITION_ATTACHMENT : $requested;

        $response = new StreamedResponse(function () use ($stream): void {
            if (\is_resource($stream)) {
                fpassthru($stream);
                fclose($stream);
            }
        });
        $response->headers->set('Content-Type', $mime);
        $response->headers->set('Content-Disposition', HeaderUtils::makeDisposition($disposition, $name));

        return $response;
    }
}
```

- [ ] **Step 4: Test fonctionnel (auth + path-traversal + config absente)**

```php
<?php

declare(strict_types=1);

namespace App\Tests\Functional\Controller;

use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class ShareBrowseTest extends WebTestCase
{
    public function testBrowseRequiresAuthentication(): void
    {
        $client = static::createClient();
        $client->request('GET', '/api/share/browse?path=/');

        self::assertSame(401, $client->getResponse()->getStatusCode());
    }

    public function testBrowseRejectsPathTraversal(): void
    {
        $client = static::createClient();
        $this->login($client);

        $client->request('GET', '/api/share/browse?path='.urlencode('../etc'));

        self::assertSame(400, $client->getResponse()->getStatusCode());
    }

    public function testBrowseReturns409WhenNotConfigured(): void
    {
        $client = static::createClient();
        $this->login($client);

        $client->request('GET', '/api/share/browse?path=');

        self::assertSame(409, $client->getResponse()->getStatusCode());
    }

    public function testStatusReturnsDisabledByDefault(): void
    {
        $client = static::createClient();
        $this->login($client);

        $client->request('GET', '/api/share/status');

        self::assertResponseIsSuccessful();
        $data = json_decode($client->getResponse()->getContent(), true);
        self::assertFalse($data['enabled']);
    }

    private function login(KernelBrowser $client): void
    {
        $client->request('POST', '/login_check', server: [
            'CONTENT_TYPE' => 'application/json',
        ], content: json_encode(['username' => 'admin', 'password' => 'admin']));
    }
}
```

> `testBrowseRejectsPathTraversal` valide le chemin **avant** toute connexion SMB (donc passe sans serveur réel). `testBrowseReturns409WhenNotConfigured` suppose une base de test sans `share_configuration` activée (fixtures par défaut). Si les fixtures venaient à créer une config activée, adapter l'assertion.

- [ ] **Step 5: Lancer les tests**

Run :
```bash
docker exec -u www-data php-lesstime-fpm vendor/bin/phpunit tests/Functional/Controller/ShareBrowseTest.php
```
Expected : PASS (4 tests).

- [ ] **Step 6: Lancer toute la suite back (non-régression)**

Run :
```bash
docker exec -u www-data php-lesstime-fpm vendor/bin/phpunit
```
Expected : suite verte (mêmes notices qu'avant, pas de nouvel échec).

- [ ] **Step 7: php-cs-fixer + commit**

Run :
```bash
make php-cs-fixer-allow-risky
git add src/Controller/Share/ tests/Functional/Controller/ShareBrowseTest.php
git commit -m "feat(share) : controllers status/browse/download du partage"
```

---

## Task 8: Front — dépendance PDF + services + DTO

**Files:**
- Modify: `frontend/package.json`
- Create: `frontend/services/dto/share.ts`
- Create: `frontend/services/share.ts`
- Create: `frontend/services/share-settings.ts`

- [ ] **Step 1: Installer `vue-pdf-embed`**

Run (dans le conteneur ou via make dev-nuxt env) :
```bash
docker exec -i php-lesstime-fpm sh -lc 'cd frontend && yarn add vue-pdf-embed'
```
Expected : `frontend/package.json` + lockfile mis à jour, paquet présent dans `frontend/node_modules/vue-pdf-embed`.

> Si l'install Node se fait hors conteneur (selon ton workflow `make dev-nuxt`), lancer `yarn add vue-pdf-embed` depuis `frontend/` sur l'hôte. L'important : le paquet est dans `frontend/package.json`.

- [ ] **Step 2: DTO `share.ts`**

```typescript
export type FileEntry = {
    name: string
    path: string
    isDir: boolean
    size: number
    modifiedAt: number | null
    mimeType: string
}

export type Breadcrumb = {
    name: string
    path: string
}

export type ShareBrowseResult = {
    path: string
    breadcrumb: Breadcrumb[]
    entries: FileEntry[]
}

export type ShareStatus = {
    enabled: boolean
}

export type ShareSettings = {
    host: string | null
    shareName: string | null
    basePath: string | null
    domain: string | null
    username: string | null
    enabled: boolean
    hasPassword: boolean
}

export type ShareSettingsWrite = {
    host: string | null
    shareName: string | null
    basePath: string | null
    domain: string | null
    username: string | null
    password?: string | null
    enabled: boolean
}

export type ShareTestResult = {
    success: boolean
    message: string | null
}
```

- [ ] **Step 3: Service `share.ts`**

```typescript
import type { ShareBrowseResult, ShareStatus } from './dto/share'

export function useShareService() {
    const api = useApi()
    const config = useRuntimeConfig()

    async function browse(path: string): Promise<ShareBrowseResult> {
        const query = path ? `?path=${encodeURIComponent(path)}` : ''
        return api.get<ShareBrowseResult>(`/share/browse${query}`)
    }

    async function getStatus(): Promise<ShareStatus> {
        return api.get<ShareStatus>('/share/status')
    }

    function getDownloadUrl(path: string, disposition: 'inline' | 'attachment' = 'inline'): string {
        const base = config.public.apiBase ?? '/api'
        return `${base}/share/download?path=${encodeURIComponent(path)}&disposition=${disposition}`
    }

    return { browse, getStatus, getDownloadUrl }
}
```

> Vérifier le nom réel de la base API publique : regarder comment `useTaskDocumentService().getDownloadUrl` construit son URL dans `frontend/services/task-documents.ts` et **réutiliser exactement la même base** (clé runtimeConfig ou helper). Aligner `getDownloadUrl` dessus plutôt que d'inventer `config.public.apiBase`.

- [ ] **Step 4: Service `share-settings.ts`**

```typescript
import type { ShareSettings, ShareSettingsWrite, ShareTestResult } from './dto/share'

export function useShareSettingsService() {
    const api = useApi()

    async function getSettings(): Promise<ShareSettings> {
        return api.get<ShareSettings>('/settings/share')
    }

    async function saveSettings(payload: ShareSettingsWrite): Promise<ShareSettings> {
        return api.put<ShareSettings>('/settings/share', payload as Record<string, unknown>, {
            toastSuccessKey: 'adminShare.saved',
        })
    }

    async function testConnection(): Promise<ShareTestResult> {
        return api.post<ShareTestResult>('/settings/share/test', {})
    }

    return { getSettings, saveSettings, testConnection }
}
```

- [ ] **Step 5: Commit**

```bash
git add frontend/package.json frontend/yarn.lock frontend/services/dto/share.ts frontend/services/share.ts frontend/services/share-settings.ts
git commit -m "feat(share) : services et DTO front (browse, settings, status) + dépendance pdf"
```

---

## Task 9: Front — onglet admin `AdminShareTab`

**Files:**
- Create: `frontend/components/admin/AdminShareTab.vue`
- Modify: `frontend/pages/admin.vue`

- [ ] **Step 1: Composant `AdminShareTab.vue`**

```vue
<template>
    <div>
        <h2 class="text-lg font-bold text-neutral-900">{{ $t('adminShare.title') }}</h2>

        <form class="mt-6 max-w-lg space-y-4" @submit.prevent="handleSave">
            <MalioInputText
                v-model="form.host"
                :label="$t('adminShare.host')"
                :placeholder="$t('adminShare.hostPlaceholder')"
                input-class="w-full"
            />
            <MalioInputText
                v-model="form.shareName"
                :label="$t('adminShare.shareName')"
                :placeholder="$t('adminShare.shareNamePlaceholder')"
                input-class="w-full"
            />
            <MalioInputText
                v-model="form.basePath"
                :label="$t('adminShare.basePath')"
                :placeholder="$t('adminShare.basePathPlaceholder')"
                input-class="w-full"
            />
            <MalioInputText
                v-model="form.domain"
                :label="$t('adminShare.domain')"
                :placeholder="$t('adminShare.domainPlaceholder')"
                input-class="w-full"
            />
            <MalioInputText
                v-model="form.username"
                :label="$t('adminShare.username')"
                :placeholder="$t('adminShare.usernamePlaceholder')"
                input-class="w-full"
            />
            <div>
                <MalioInputPassword
                    v-model="form.password"
                    :label="$t('adminShare.password')"
                    input-class="w-full"
                />
                <p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
                    {{ $t('adminShare.passwordConfigured') }}
                </p>
            </div>
            <label class="flex cursor-pointer items-center gap-2">
                <input v-model="form.enabled" type="checkbox" class="rounded border-neutral-300" />
                <span class="text-sm">{{ $t('adminShare.enabled') }}</span>
            </label>
            <div class="flex gap-3">
                <MalioButton
                    :label="$t('adminShare.save')"
                    button-class="w-auto px-4"
                    :disabled="isSaving"
                    @click="handleSave"
                />
                <MalioButton
                    variant="tertiary"
                    :label="$t('adminShare.testConnection')"
                    button-class="w-auto px-4"
                    :disabled="isTesting"
                    @click="handleTest"
                />
            </div>
            <p v-if="testMessage !== null" class="text-sm font-medium" :class="testSuccess ? 'text-green-600' : 'text-red-600'">
                {{ testMessage }}
            </p>
        </form>
    </div>
</template>

<script setup lang="ts">
import { useShareSettingsService } from '~/services/share-settings'

const { getSettings, saveSettings, testConnection } = useShareSettingsService()
const { t } = useI18n()

const form = reactive({
    host: '',
    shareName: '',
    basePath: '',
    domain: '',
    username: '',
    password: '',
    enabled: false,
})

const hasPassword = ref(false)
const isSaving = ref(false)
const isTesting = ref(false)
const testSuccess = ref(false)
const testMessage = ref<string | null>(null)

async function loadSettings() {
    const settings = await getSettings()
    form.host = settings.host ?? ''
    form.shareName = settings.shareName ?? ''
    form.basePath = settings.basePath ?? ''
    form.domain = settings.domain ?? ''
    form.username = settings.username ?? ''
    form.enabled = settings.enabled
    hasPassword.value = settings.hasPassword
}

async function handleSave() {
    isSaving.value = true
    try {
        const result = await saveSettings({
            host: form.host.trim() || null,
            shareName: form.shareName.trim() || null,
            basePath: form.basePath.trim() || null,
            domain: form.domain.trim() || null,
            username: form.username.trim() || null,
            password: form.password || null,
            enabled: form.enabled,
        })
        hasPassword.value = result.hasPassword
        form.password = ''
        testMessage.value = null
    } finally {
        isSaving.value = false
    }
}

async function handleTest() {
    isTesting.value = true
    testMessage.value = null
    try {
        const result = await testConnection()
        testSuccess.value = result.success
        testMessage.value = result.success
            ? t('adminShare.testSuccess')
            : (result.message ?? t('adminShare.testFailed'))
    } catch {
        testSuccess.value = false
        testMessage.value = t('adminShare.testFailed')
    } finally {
        isTesting.value = false
    }
}

onMounted(() => {
    loadSettings()
})
</script>
```

- [ ] **Step 2: Câbler l'onglet dans `admin.vue`**

Dans `frontend/pages/admin.vue` :
1. Ajouter dans le template, après la ligne `<AdminZimbraTab v-if="activeTab === 'zimbra'" />` (ligne 32) :
```vue
            <AdminShareTab v-if="activeTab === 'share'" />
```
2. Ajouter dans le tableau `tabs` (après `{ key: 'zimbra', label: 'Zimbra' },`) :
```typescript
    { key: 'share', label: 'Partage' },
```

- [ ] **Step 3: Vérifier visuellement**

Run : `make dev-nuxt` puis ouvrir `http://localhost:3002/admin`, onglet « Partage ».
Expected : le formulaire s'affiche, « Enregistrer » persiste, « Tester » renvoie un message (échec attendu tant qu'aucun vrai serveur n'est configuré).

- [ ] **Step 4: Commit**

```bash
git add frontend/components/admin/AdminShareTab.vue frontend/pages/admin.vue
git commit -m "feat(share) : onglet admin de configuration du partage"
```

---

## Task 10: Front — composable de visibilité + lien sidebar

**Files:**
- Create: `frontend/composables/useShareStatus.ts`
- Modify: `frontend/layouts/default.vue`

- [ ] **Step 1: Composable `useShareStatus`**

```typescript
import { useShareService } from '~/services/share'

const enabled = ref<boolean | null>(null)

export function useShareStatus() {
    const { getStatus } = useShareService()

    async function refresh() {
        try {
            const status = await getStatus()
            enabled.value = status.enabled
        } catch {
            enabled.value = false
        }
    }

    async function ensureLoaded() {
        if (enabled.value === null) {
            await refresh()
        }
    }

    return { enabled, refresh, ensureLoaded }
}
```

> Le `ref` est déclaré au niveau module → état partagé/mis en cache pour toute l'app (pattern singleton, comme certains composables existants). Vérifier qu'aucun composable du projet n'impose un autre pattern (ex. `useState` de Nuxt) ; si oui, utiliser `useState('shareEnabled', () => null)` à la place.

- [ ] **Step 2: Lien « Documents » conditionnel dans `default.vue`**

Dans `frontend/layouts/default.vue`, template — après le bloc « Suivi de temps » (le `SidebarLink to="/time-tracking"`, ~ligne 102) et avant le bloc mail, ajouter :
```vue
                    <SidebarLink
                        v-if="isDocumentsVisible"
                        to="/documents"
                        icon="mdi:folder-network-outline"
                        :label="$t('sharedFiles.sidebar.title')"
                        :collapsed="sidebarIsCollapsed"
                        @click="ui.closeMobileSidebar()"
                    />
```

Dans le `<script setup>` de `default.vue`, ajouter après `const isMailVisible = ...` (~ligne 223) :
```typescript
const { enabled: shareEnabled, ensureLoaded: ensureShareStatus } = useShareStatus()
const isDocumentsVisible = computed(() => shareEnabled.value === true)
```

Et dans le `onMounted` existant (~ligne 265), ajouter l'appel :
```typescript
    ensureShareStatus()
```

Et dans le `watch(() => auth.user, ...)` existant, après un login on rafraîchit aussi : ajouter dans la branche `else if (...)` ou juste appeler `ensureShareStatus()` quand `user` devient non nul.

- [ ] **Step 3: Vérifier visuellement**

Run : `make dev-nuxt`. Avec le partage **désactivé** en admin → pas de lien « Documents ». Active-le (host+shareName + enabled) → après rechargement, le lien apparaît.
Expected : visibilité conditionnée par `enabled`.

- [ ] **Step 4: Commit**

```bash
git add frontend/composables/useShareStatus.ts frontend/layouts/default.vue
git commit -m "feat(share) : lien Documents conditionné à l'activation du partage"
```

---

## Task 11: Front — viewer `SharedFilePreview`

**Files:**
- Create: `frontend/components/share/SharedFilePreview.vue`

- [ ] **Step 1: Composant viewer**

Adapté de `frontend/components/task/TaskDocumentPreview.vue`, mais piloté par un `FileEntry` du partage et l'URL `download`. PDF rendu via `vue-pdf-embed`.

```vue
<template>
    <Teleport to="body">
        <Transition name="fade" appear>
            <div
                v-if="entry"
                class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80"
                @click.self="$emit('close')"
                @keydown.escape="$emit('close')"
                @keydown.left="$emit('prev')"
                @keydown.right="$emit('next')"
                tabindex="0"
                ref="overlayRef"
            >
                <MalioButtonIcon
                    icon="heroicons:x-mark"
                    aria-label="Fermer"
                    variant="ghost"
                    icon-size="24"
                    button-class="absolute right-4 top-4 rounded-full bg-black/50 text-white hover:bg-black/70"
                    @click="$emit('close')"
                />
                <MalioButtonIcon
                    v-if="hasPrev"
                    icon="heroicons:chevron-left"
                    aria-label="Précédent"
                    variant="ghost"
                    icon-size="24"
                    button-class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
                    @click="$emit('prev')"
                />
                <MalioButtonIcon
                    v-if="hasNext"
                    icon="heroicons:chevron-right"
                    aria-label="Suivant"
                    variant="ghost"
                    icon-size="24"
                    button-class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
                    @click="$emit('next')"
                />

                <div class="flex max-h-[90vh] max-w-[90vw] flex-col items-center">
                    <img
                        v-if="isImage"
                        :src="inlineUrl"
                        :alt="entry.name"
                        class="max-h-[85vh] max-w-[90vw] object-contain"
                    />

                    <div
                        v-else-if="isPdf"
                        class="h-[85vh] w-[80vw] overflow-auto rounded-lg bg-white"
                    >
                        <VuePdfEmbed :source="inlineUrl" />
                    </div>

                    <div
                        v-else-if="isText"
                        class="flex max-h-[85vh] w-[85vw] max-w-3xl flex-col overflow-hidden rounded-xl bg-white"
                    >
                        <div class="flex items-center justify-between gap-2 border-b border-neutral-200 px-4 py-3">
                            <p class="truncate text-sm font-medium text-neutral-700">{{ entry.name }}</p>
                            <a
                                :href="downloadUrl"
                                class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
                            >
                                {{ $t('sharedFiles.download') }}
                            </a>
                        </div>
                        <div class="overflow-auto p-4">
                            <div v-if="loadingText" class="flex justify-center py-10">
                                <Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
                            </div>
                            <pre v-else class="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-neutral-800">{{ textContent }}</pre>
                        </div>
                    </div>

                    <div v-else class="flex flex-col items-center gap-4 rounded-xl bg-white p-10">
                        <Icon name="heroicons:document" class="h-16 w-16 text-neutral-400" />
                        <p class="max-w-xs truncate text-lg font-medium text-neutral-700">{{ entry.name }}</p>
                        <p class="text-sm text-neutral-400">{{ formatFileSize(entry.size) }}</p>
                        <a
                            :href="downloadUrl"
                            class="mt-2 rounded-lg bg-blue-600 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
                        >
                            {{ $t('sharedFiles.download') }}
                        </a>
                    </div>

                    <p v-if="!isText" class="mt-3 text-sm text-white/70">{{ entry.name }}</p>
                </div>
            </div>
        </Transition>
    </Teleport>
</template>

<script setup lang="ts">
import VuePdfEmbed from 'vue-pdf-embed'
import type { FileEntry } from '~/services/dto/share'
import { useShareService } from '~/services/share'
import { formatFileSize } from '~/utils/format'

const props = defineProps<{
    entry: FileEntry | null
    hasPrev: boolean
    hasNext: boolean
}>()

defineEmits<{
    close: []
    prev: []
    next: []
}>()

const overlayRef = ref<HTMLElement | null>(null)
const textContent = ref('')
const loadingText = ref(false)

const { getDownloadUrl } = useShareService()

const TEXT_RE = /\.(md|markdown|txt|csv|json|xml|log)$/i

const inlineUrl = computed(() => props.entry ? getDownloadUrl(props.entry.path, 'inline') : '')
const downloadUrl = computed(() => props.entry ? getDownloadUrl(props.entry.path, 'attachment') : '')
const isImage = computed(() => props.entry?.mimeType.startsWith('image/') ?? false)
const isPdf = computed(() => props.entry?.mimeType === 'application/pdf')
const isText = computed(() => props.entry ? (props.entry.mimeType.startsWith('text/') || TEXT_RE.test(props.entry.name)) : false)

watch(() => props.entry, async (entry) => {
    textContent.value = ''
    if (!entry) return
    nextTick(() => overlayRef.value?.focus())

    if (isText.value) {
        loadingText.value = true
        try {
            textContent.value = await $fetch<string>(inlineUrl.value, { credentials: 'include', responseType: 'text' as never })
        } catch {
            textContent.value = ''
        } finally {
            loadingText.value = false
        }
    }
}, { immediate: true })
</script>

<style scoped>
.fade-enter-active,
.fade-leave-active {
    transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
    opacity: 0;
}
</style>
```

> Pour le chargement du texte, aligner sur la façon dont `TaskDocumentPreview.vue` récupère son contenu (`getContent`) si un helper équivalent existe ; sinon le `$fetch` ci-dessus avec `credentials: 'include'` convient. Vérifier que `vue-pdf-embed` s'importe correctement en SPA Nuxt (si erreur SSR/worker, importer en `defineAsyncComponent` ou configurer le worker pdf.js — voir doc context7 `vue-pdf-embed`).

- [ ] **Step 2: Commit**

```bash
git add frontend/components/share/SharedFilePreview.vue
git commit -m "feat(share) : viewer de documents du partage (image/pdf/texte)"
```

---

## Task 12: Front — page explorateur `documents.vue`

**Files:**
- Create: `frontend/pages/documents.vue`

- [ ] **Step 1: Page explorateur**

```vue
<template>
    <div>
        <h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('sharedFiles.title') }}</h1>

        <!-- Fil d'Ariane -->
        <nav class="mt-4 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
            <button class="hover:text-primary-500" @click="navigateTo('')">{{ $t('sharedFiles.root') }}</button>
            <template v-for="crumb in breadcrumb" :key="crumb.path">
                <span>/</span>
                <button class="hover:text-primary-500" @click="navigateTo(crumb.path)">{{ crumb.name }}</button>
            </template>
        </nav>

        <!-- Filtre local -->
        <div class="mt-4 max-w-sm">
            <MalioInputText
                v-model="filter"
                :placeholder="$t('sharedFiles.filterPlaceholder')"
                input-class="w-full"
            />
        </div>

        <!-- États -->
        <div v-if="loading" class="mt-10 flex justify-center">
            <Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
        </div>
        <p v-else-if="error" class="mt-10 text-sm text-red-600">{{ error }}</p>
        <p v-else-if="visibleEntries.length === 0" class="mt-10 text-sm text-neutral-400">{{ $t('sharedFiles.empty') }}</p>

        <!-- Tableau -->
        <table v-else class="mt-6 w-full text-sm">
            <thead class="border-b border-neutral-200 text-left text-xs uppercase tracking-wider text-neutral-400">
                <tr>
                    <th class="py-2">{{ $t('sharedFiles.colName') }}</th>
                    <th class="py-2">{{ $t('sharedFiles.colSize') }}</th>
                    <th class="py-2">{{ $t('sharedFiles.colModified') }}</th>
                </tr>
            </thead>
            <tbody>
                <tr
                    v-for="entry in visibleEntries"
                    :key="entry.path"
                    class="cursor-pointer border-b border-neutral-100 hover:bg-neutral-50"
                    @click="onEntryClick(entry)"
                >
                    <td class="flex items-center gap-2 py-2">
                        <Icon :name="entry.isDir ? 'mdi:folder-outline' : iconForMime(entry.mimeType)" class="h-5 w-5 text-neutral-400" />
                        <span class="truncate">{{ entry.name }}</span>
                    </td>
                    <td class="py-2 text-neutral-500">{{ entry.isDir ? '—' : formatFileSize(entry.size) }}</td>
                    <td class="py-2 text-neutral-500">{{ formatDate(entry.modifiedAt) }}</td>
                </tr>
            </tbody>
        </table>

        <SharedFilePreview
            :entry="previewEntry"
            :has-prev="previewIndex > 0"
            :has-next="previewIndex >= 0 && previewIndex < fileEntries.length - 1"
            @close="previewEntry = null"
            @prev="stepPreview(-1)"
            @next="stepPreview(1)"
        />
    </div>
</template>

<script setup lang="ts">
import type { Breadcrumb, FileEntry } from '~/services/dto/share'
import { useShareService } from '~/services/share'
import { formatFileSize } from '~/utils/format'

useHead({ title: 'Documents' })

const { browse } = useShareService()
const { enabled, ensureLoaded } = useShareStatus()

const currentPath = ref('')
const breadcrumb = ref<Breadcrumb[]>([])
const entries = ref<FileEntry[]>([])
const filter = ref('')
const loading = ref(false)
const error = ref<string | null>(null)

const previewEntry = ref<FileEntry | null>(null)

const visibleEntries = computed(() => {
    const f = filter.value.trim().toLowerCase()
    if (!f) return entries.value
    return entries.value.filter((e) => e.name.toLowerCase().includes(f))
})

const fileEntries = computed(() => visibleEntries.value.filter((e) => !e.isDir))
const previewIndex = computed(() => previewEntry.value ? fileEntries.value.findIndex((e) => e.path === previewEntry.value!.path) : -1)

async function load(path: string) {
    loading.value = true
    error.value = null
    try {
        const result = await browse(path)
        currentPath.value = result.path
        breadcrumb.value = result.breadcrumb
        entries.value = result.entries
    } catch (e: unknown) {
        error.value = (e as Error)?.message ?? 'Erreur'
        entries.value = []
    } finally {
        loading.value = false
    }
}

function navigateTo(path: string) {
    filter.value = ''
    load(path)
}

function onEntryClick(entry: FileEntry) {
    if (entry.isDir) {
        navigateTo(entry.path)
    } else {
        previewEntry.value = entry
    }
}

function stepPreview(delta: number) {
    const idx = previewIndex.value + delta
    if (idx >= 0 && idx < fileEntries.value.length) {
        previewEntry.value = fileEntries.value[idx]
    }
}

function iconForMime(mime: string): string {
    if (mime.startsWith('image/')) return 'mdi:file-image-outline'
    if (mime === 'application/pdf') return 'mdi:file-pdf-box'
    if (mime.startsWith('text/')) return 'mdi:file-document-outline'
    return 'mdi:file-outline'
}

function formatDate(ts: number | null): string {
    if (!ts) return '—'
    return new Date(ts * 1000).toLocaleString()
}

onMounted(async () => {
    await ensureLoaded()
    if (enabled.value === false) {
        await navigateTo('/')
        return
    }
    load('')
})
</script>
```

> Garde-fou : si `enabled === false`, on redirige vers l'accueil (défense en profondeur en plus du `409` backend). Adapter `formatDate`/`iconForMime` aux helpers existants dans `frontend/utils/` s'il y en a (éviter la duplication).

- [ ] **Step 2: Build front (vérif compilation)**

Run :
```bash
docker exec -i php-lesstime-fpm sh -lc 'cd frontend && yarn build'
```
Expected : build OK, pas d'erreur TypeScript.

- [ ] **Step 3: Vérif manuelle bout-en-bout**

Avec une config SMB valide saisie en admin (host, shareName, identifiants, enabled) + un vrai partage accessible :
- le lien « Documents » apparaît ;
- `/documents` liste le contenu, on navigue dans les dossiers via le fil d'Ariane ;
- un clic sur un PDF l'ouvre dans le viewer (PDF.js), une image s'affiche, un fichier inconnu propose le téléchargement ;
- le filtre restreint le dossier courant.

> Sans serveur SMB de test sous la main, vérifier au minimum que `/documents` affiche l'état d'erreur proprement (502/409) et ne plante pas.

- [ ] **Step 4: Commit**

```bash
git add frontend/pages/documents.vue
git commit -m "feat(share) : page explorateur de fichiers du partage"
```

---

## Task 13: i18n

**Files:**
- Modify: `frontend/i18n/locales/fr.json` (+ toute autre locale présente, ex. `en.json`)

- [ ] **Step 1: Ajouter les clés**

Repérer la structure JSON existante (ex. bloc `zimbra`) et ajouter deux blocs cohérents. Valeurs FR :

```json
"sharedFiles": {
    "title": "Documents",
    "root": "Racine",
    "empty": "Ce dossier est vide.",
    "filterPlaceholder": "Filtrer ce dossier…",
    "download": "Télécharger",
    "colName": "Nom",
    "colSize": "Taille",
    "colModified": "Modifié le",
    "sidebar": { "title": "Documents" }
},
"adminShare": {
    "title": "Partage réseau (SMB)",
    "host": "Serveur",
    "hostPlaceholder": "ex. WIN-SRV ou 192.168.1.10",
    "shareName": "Nom du partage",
    "shareNamePlaceholder": "ex. Documents",
    "basePath": "Sous-dossier racine (optionnel)",
    "basePathPlaceholder": "ex. /Projets",
    "domain": "Domaine / groupe de travail",
    "domainPlaceholder": "WORKGROUP",
    "username": "Identifiant",
    "usernamePlaceholder": "ex. lesstime",
    "password": "Mot de passe",
    "passwordConfigured": "Un mot de passe est déjà enregistré.",
    "enabled": "Activer l'accès au partage",
    "save": "Enregistrer",
    "saved": "Configuration enregistrée.",
    "testConnection": "Tester la connexion",
    "testSuccess": "Connexion réussie.",
    "testFailed": "Échec de la connexion."
}
```

Pour chaque autre locale présente (ex. `en.json`), ajouter les mêmes clés traduites.

- [ ] **Step 2: Vérifier qu'aucune clé n'est manquante**

Run : `make dev-nuxt`, parcourir `/admin` (onglet Partage) et `/documents`.
Expected : aucun libellé brut `sharedFiles.*` / `adminShare.*` affiché.

- [ ] **Step 3: Commit**

```bash
git add frontend/i18n/locales/
git commit -m "feat(share) : traductions explorateur et config partage"
```

---

## Task 14: Vérification finale & non-régression

- [ ] **Step 1: Suite back complète**

Run : `docker exec -u www-data php-lesstime-fpm vendor/bin/phpunit`
Expected : verte (pas de nouvel échec vs baseline).

- [ ] **Step 2: php-cs-fixer**

Run : `make php-cs-fixer-allow-risky`
Expected : aucune violation restante (commiter si des fichiers sont retouchés).

- [ ] **Step 3: Build front**

Run : `docker exec -i php-lesstime-fpm sh -lc 'cd frontend && yarn build'`
Expected : build OK.

- [ ] **Step 4: Récap manuel**

Vérifier la checklist de la spec §6 (erreurs) : SMB injoignable → message ; config désactivée → pas de lien + 409 ; path-traversal → 400 ; fichier absent → 404.

- [ ] **Step 5: (Optionnel) Préparer la PR**

```bash
git push -u origin feat/share-explorer
```
Puis ouvrir la MR vers `develop` (sans mention d'IA dans la description).

---

## Notes de déploiement prod (à exécuter le moment venu)

1. Rebuild de l'image prod `lesstime-app` **avec `smbclient`** (Task 1) — sinon `icewind/smb` échoue en prod.
2. Jouer la migration `share_configuration` en prod (`doctrine:migrations:migrate`).
3. Saisir la config SMB en admin prod et activer.
4. Vérifier que `ENCRYPTION_KEY` est bien défini en prod (réutilisé pour chiffrer le mot de passe SMB — déjà requis par l'intégration Zimbra).
```
