diff --git a/addin/src/shared/api.ts b/addin/src/shared/api.ts index 3a3d5f3..8b8c079 100644 --- a/addin/src/shared/api.ts +++ b/addin/src/shared/api.ts @@ -5,7 +5,7 @@ export type ComposeContext = "new" | "reply" | "forward" | "appointment"; export interface SignatureResponse { html: string; - variant: "full" | "short"; + variant: "full" | "short" | "none"; template: string; generated_at: string; } @@ -40,3 +40,60 @@ export async function fetchSignature( clearTimeout(timer); } } + +// --------------------------------------------------------------------------- +// Profil de l'agent (champs ouverts par l'admin + préférence en réponse) + +export type ReplyVariant = "short" | "full" | "none"; + +export interface ProfileField { + key: string; + label: string; + type: "text" | "phone"; + directory_value: string | null; + override: string | null; + overridden: boolean; +} + +export interface Profile { + fields: ProfileField[]; + reply_variant: ReplyVariant | null; + reply_variant_default: ReplyVariant; +} + +export interface ProfileUpdate { + /** null = revenir à l'annuaire */ + fields: Record; + /** champs à ne pas afficher */ + hidden: string[]; + /** null = réglage par défaut */ + reply_variant: ReplyVariant | null; +} + +export class ApiError extends Error { + constructor(message: string, public readonly status: number, public readonly errors: Record = {}) { + super(message); + } +} + +async function call(token: string, path: string, init: RequestInit = {}): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const res = await fetch(new URL(path, CONFIG.apiBaseUrl).toString(), { + ...init, + headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" }, + signal: controller.signal, + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new ApiError(body.message ?? `Erreur ${res.status}`, res.status, body.errors ?? {}); + return body as T; + } finally { + clearTimeout(timer); + } +} + +export const fetchProfile = (token: string) => call(token, "/api/v1/profile"); + +export const saveProfile = (token: string, update: ProfileUpdate) => + call(token, "/api/v1/profile", { method: "PUT", body: JSON.stringify(update) }); diff --git a/addin/src/taskpane/taskpane.html b/addin/src/taskpane/taskpane.html index e69ccbf..6de874c 100644 --- a/addin/src/taskpane/taskpane.html +++ b/addin/src/taskpane/taskpane.html @@ -6,15 +6,25 @@ Ma signature @@ -23,7 +33,24 @@
- Modifier mes informations
+ + + +

Ouvrir le portail « Ma signature »

diff --git a/addin/src/taskpane/taskpane.ts b/addin/src/taskpane/taskpane.ts index b09b0f8..bb1814b 100644 --- a/addin/src/taskpane/taskpane.ts +++ b/addin/src/taskpane/taskpane.ts @@ -1,8 +1,18 @@ /** - * Volet « Ma signature » : aperçu, réinsertion manuelle et lien vers le portail agent. + * Volet « Ma signature » : aperçu, réinsertion, et modification par l'agent des champs + * que l'admin a ouverts (+ choix de la signature en réponse). * C'est aussi le seul endroit où l'authentification interactive (popup) est possible. */ -import { fetchSignature } from "../shared/api"; +import { + ApiError, + fetchProfile, + fetchSignature, + saveProfile, + type ComposeContext, + type Profile, + type ReplyVariant, + type SignatureResponse, +} from "../shared/api"; import { getTokenInteractive } from "../shared/auth"; import { writeCache } from "../shared/cache"; import { CONFIG } from "../shared/config"; @@ -10,50 +20,183 @@ import { applySignature, currentItem, getComposeContext, getMailbox } from "../s const $ = (id: string) => document.getElementById(id) as T; -async function load(): Promise { - const status = $("status"); - const preview = $("preview"); - const apply = $("apply"); - $("portal").href = CONFIG.portalUrl; +const REPLY_LABELS: Record = { short: "Signature courte", full: "Signature complète", none: "Aucune signature" }; - const item = currentItem(); - const context = await getComposeContext(item); - const mailbox = await getMailbox(item); +let token: string | null = null; +let context: ComposeContext = "new"; +let mailbox: string | null = null; +let current: SignatureResponse | null = null; - const token = await getTokenInteractive(); - if (!token) { - status.textContent = "Connexion impossible. Contactez le support informatique."; - return; - } +function option(value: string, text: string): HTMLOptionElement { + const o = document.createElement("option"); + o.value = value; + o.textContent = text; + return o; +} + +function div(className: string, ...children: Node[]): HTMLDivElement { + const d = document.createElement("div"); + d.className = className; + d.append(...children); + return d; +} + +/** Aperçu isolé dans une iframe sandbox : le HTML d'un template n'accède ni au jeton ni à Office. */ +function showPreview(res: SignatureResponse): void { + const frame = document.createElement("iframe"); + frame.title = "Aperçu de la signature"; + frame.setAttribute("sandbox", ""); + frame.style.cssText = "width:100%;min-height:180px;border:0"; + frame.srcdoc = `${res.html || "Aucune signature pour ce contexte."}`; + $("preview").replaceChildren(frame); + $("status").textContent = `Modèle : ${res.template} · variante ${res.variant === "short" ? "courte" : res.variant === "none" ? "aucune" : "complète"}`; +} + +async function refreshSignature(): Promise { + if (!token) return; const res = await fetchSignature(token, context, mailbox); if (!res) { - status.textContent = "Le service de signature ne répond pas."; + $("status").textContent = "Le service de signature ne répond pas."; + return; + } + current = res; + await writeCache(context, mailbox, res.html); + showPreview(res); + $("apply").disabled = false; +} + +function renderProfile(profile: Profile): void { + const container = $("fields"); + container.replaceChildren(); + $("profile").hidden = false; + $("no-fields").hidden = profile.fields.length > 0; + + for (const f of profile.fields) { + const mode = !f.overridden ? "directory" : f.override === "" ? "hidden" : "custom"; + const select = document.createElement("select"); + select.id = `mode-${f.key}`; + select.append( + option("directory", `Annuaire : ${f.directory_value || "(vide)"}`), + option("custom", "Valeur personnalisée"), + option("hidden", "Ne pas afficher"), + ); + select.value = mode; + + const input = document.createElement("input"); + input.type = "text"; + input.id = `value-${f.key}`; + input.value = mode === "custom" ? f.override ?? "" : ""; + input.maxLength = 120; + input.placeholder = f.type === "phone" ? "ex. 06 12 34 56 78" : ""; + input.setAttribute("aria-label", `${f.label} personnalisé`); + if (f.type === "phone") input.inputMode = "tel"; + input.hidden = mode !== "custom"; + select.addEventListener("change", () => { + input.hidden = select.value !== "custom"; + if (!input.hidden) input.focus(); + }); + + const label = document.createElement("label"); + label.htmlFor = select.id; + label.textContent = f.label; + const error = div("error"); + error.id = `error-${f.key}`; + container.append(div("field", label, select, input, error)); + } + + const reply = $("reply_variant"); + reply.replaceChildren( + option("default", `Réglage par défaut (${REPLY_LABELS[profile.reply_variant_default].toLowerCase()})`), + ...(Object.keys(REPLY_LABELS) as ReplyVariant[]).map((v) => option(v, REPLY_LABELS[v])), + ); + reply.value = profile.reply_variant ?? "default"; +} + +async function onSave(ev: SubmitEvent, profile: Profile): Promise { + ev.preventDefault(); + if (!token) return; + const status = $("form-status"); + const save = $("save"); + document.querySelectorAll(".error").forEach((e) => (e.textContent = "")); + + const fields: Record = {}; + const hidden: string[] = []; + for (const f of profile.fields) { + const mode = $(`mode-${f.key}`).value; + const value = $(`value-${f.key}`).value.trim(); + if (mode === "hidden") hidden.push(f.key); + else fields[f.key] = mode === "custom" && value !== "" ? value : null; + } + const replyValue = $("reply_variant").value; + + save.disabled = true; + status.className = ""; + status.textContent = "Enregistrement…"; + try { + const updated = await saveProfile(token, { + fields, + hidden, + reply_variant: replyValue === "default" ? null : (replyValue as ReplyVariant), + }); + profile.fields = updated.fields; + profile.reply_variant = updated.reply_variant; + renderProfile(updated); + await refreshSignature(); + status.className = "ok"; + status.textContent = "Enregistré. Cliquez sur « Insérer dans ce message » pour l'appliquer ici."; + } catch (e) { + status.className = "ko"; + if (e instanceof ApiError && e.status === 422) { + status.textContent = "Certaines valeurs sont invalides."; + for (const [key, messages] of Object.entries(e.errors)) { + const target = document.getElementById(`error-${key.replace(/^fields\./, "")}`); + if (target) target.textContent = messages.join(" "); + } + } else { + status.textContent = "Enregistrement impossible. Réessayez plus tard."; + console.error("[signature] profil", e); + } + } finally { + save.disabled = false; + } +} + +async function load(): Promise { + $("portal").href = CONFIG.portalUrl; + const item = currentItem(); + context = await getComposeContext(item); + mailbox = await getMailbox(item); + + token = await getTokenInteractive(); + if (!token) { + $("status").textContent = "Connexion impossible. Contactez le support informatique."; return; } - await writeCache(context, mailbox, res.html); - status.textContent = `Modèle : ${res.template} · variante ${res.variant === "short" ? "courte" : "complète"}`; - // Aperçu isolé dans une iframe sandbox (aucun script, origine opaque) : le HTML d'un template - // ne peut pas accéder au jeton ni au contexte Office du volet. - const frame = document.createElement("iframe"); - frame.setAttribute("sandbox", ""); - frame.title = "Aperçu de la signature"; - frame.style.cssText = "width:100%;min-height:200px;border:0"; - frame.srcdoc = `${res.html || "Aucune signature pour ce contexte."}`; - preview.replaceChildren(frame); - apply.disabled = false; - apply.onclick = async () => { + $("apply").onclick = async () => { + if (!current) return; + const apply = $("apply"); apply.disabled = true; try { - await applySignature(item, res.html); - status.textContent = "Signature insérée."; + await applySignature(item, current.html); + $("status").textContent = "Signature insérée."; } catch (e) { - status.textContent = "Insertion impossible."; + $("status").textContent = "Insertion impossible."; console.error(e); } finally { apply.disabled = false; } }; + + await refreshSignature(); + + try { + const profile = await fetchProfile(token); + renderProfile(profile); + $("profile-form").addEventListener("submit", (ev) => onSave(ev, profile)); + } catch (e) { + console.warn("[signature] profil indisponible", e); + } } Office.onReady(() => { diff --git a/backend/app/Http/Controllers/Admin/EditableFieldController.php b/backend/app/Http/Controllers/Admin/EditableFieldController.php new file mode 100644 index 0000000..9ccd5ba --- /dev/null +++ b/backend/app/Http/Controllers/Admin/EditableFieldController.php @@ -0,0 +1,76 @@ +tenantId(); + foreach (array_diff(array_keys(VariableMap::FIELDS), self::EXCLUDED) as $key) { + EditableField::firstOrCreate( + ['tenant_id' => $tenant, 'field' => $key], + ['label' => VariableMap::FIELDS[$key]['label'], 'enabled' => false, 'validation_rule' => in_array($key, ['telephone', 'mobile'], true) ? 'phone' : 'text'], + ); + } + + $usage = UserOverride::query() + ->join('directory_users', 'directory_users.id', '=', 'user_overrides.directory_user_id') + ->where('directory_users.tenant_id', $tenant) + ->selectRaw('user_overrides.field, count(*) as n')->groupBy('user_overrides.field')->pluck('n', 'field'); + + return view('admin.fields.index', [ + 'fields' => EditableField::where('tenant_id', $tenant)->whereNotIn('field', self::EXCLUDED) + ->get()->sortBy(fn ($f) => array_search($f->field, array_keys(VariableMap::FIELDS)))->values(), + 'types' => ProfileService::TYPES, + 'usage' => $usage, + ]); + } + + public function update(Request $request): RedirectResponse + { + $tenant = $this->tenantId(); + $data = $request->validate([ + 'fields' => ['array'], + 'fields.*.enabled' => ['nullable', 'boolean'], + 'fields.*.label' => ['required', 'string', 'max:60'], + 'fields.*.type' => ['required', Rule::in(array_keys(ProfileService::TYPES))], + ]); + + $changes = []; + foreach ($data['fields'] ?? [] as $key => $row) { + $field = EditableField::where('tenant_id', $tenant)->where('field', $key)->whereNotIn('field', self::EXCLUDED)->first(); + if (! $field) { + continue; + } + $field->fill(['enabled' => (bool) ($row['enabled'] ?? false), 'label' => $row['label'], 'validation_rule' => $row['type']]); + if ($field->isDirty()) { + $changes[$key] = $field->getDirty(); + $field->save(); + } + } + if ($changes) { + AuditLog::record('admin', $this->actor(), 'fields.update', null, $changes, $tenant); + } + + return back()->with('status', $changes ? 'Champs modifiables enregistrés : effet immédiat dans l\'add-in et le portail.' : 'Aucun changement.'); + } +} diff --git a/backend/app/Http/Controllers/Api/ProfileController.php b/backend/app/Http/Controllers/Api/ProfileController.php new file mode 100644 index 0000000..da5184d --- /dev/null +++ b/backend/app/Http/Controllers/Api/ProfileController.php @@ -0,0 +1,46 @@ +json($profiles->profile($request->attributes->get('directory_user'))) + ->header('Cache-Control', 'private, no-store'); + } + + /** + * PUT { fields: { mobile: "06…" | null, … }, hidden: ["bureau"], reply_variant?: short|full|none|null } + * null = revenir à l'annuaire ; « hidden » = ne pas afficher le champ (les chaînes vides + * étant converties en null par Laravel, le masquage passe par cette liste explicite). + */ + public function update(Request $request, ProfileService $profiles): JsonResponse + { + $user = $request->attributes->get('directory_user'); + $fields = $request->input('fields', []); + $hidden = $request->input('hidden', []); + abort_unless(is_array($fields) && is_array($hidden), 422, 'fields doit être un objet et hidden une liste.'); + foreach ($hidden as $key) { + if (is_string($key)) { + $fields[$key] = ''; + } + } + + $profiles->update( + $user, + $fields, + $request->input('reply_variant'), + $request->exists('reply_variant'), + (string) ($user->mail ?? $user->user_principal_name), + ); + + return response()->json($profiles->profile($user->fresh())); + } +} diff --git a/backend/app/Http/Controllers/Portal/PortalController.php b/backend/app/Http/Controllers/Portal/PortalController.php index 3d445ff..2d0a4ba 100644 --- a/backend/app/Http/Controllers/Portal/PortalController.php +++ b/backend/app/Http/Controllers/Portal/PortalController.php @@ -4,22 +4,53 @@ namespace App\Http\Controllers\Portal; use App\Http\Controllers\Controller; use App\Models\DirectoryUser; +use App\Signature\ProfileService; use App\Signature\SignatureBuilder; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; -/** Portail agent : aperçu de la signature. Champs modifiables et préférences arrivent au lot 2. */ +/** Portail agent : aperçu de la signature et modification des champs ouverts par l'admin. */ class PortalController extends Controller { - public function __invoke(Request $request, SignatureBuilder $builder): View + public function show(Request $request, SignatureBuilder $builder, ProfileService $profiles): View { - $user = $request->user(); - $agent = DirectoryUser::where('tenant_id', $user->tenant_id)->where('entra_oid', $user->entra_oid)->firstOrFail(); + $agent = $this->agent($request); return view('portal.home', [ 'agent' => $agent, + 'profile' => $profiles->profile($agent), 'full' => $builder->build($agent, 'new'), 'reply' => $builder->build($agent, 'reply'), ]); } + + public function update(Request $request, ProfileService $profiles): RedirectResponse + { + $agent = $this->agent($request); + $values = []; + foreach ((array) $request->input('fields', []) as $key => $row) { + $mode = $row['mode'] ?? 'directory'; + $values[$key] = match ($mode) { + 'custom' => (string) ($row['value'] ?? ''), + 'hidden' => '', + default => null, + }; + if ($mode === 'custom' && trim((string) ($row['value'] ?? '')) === '') { + $values[$key] = null; // valeur perso vide : on revient à l'annuaire + } + } + $reply = $request->input('reply_variant'); + + $profiles->update($agent, $values, $reply === 'default' ? null : $reply, $request->has('reply_variant'), (string) $agent->mail); + + return back()->with('status', 'Vos informations sont enregistrées : elles s\'appliquent à vos prochains messages.'); + } + + private function agent(Request $request): DirectoryUser + { + $user = $request->user(); + + return DirectoryUser::where('tenant_id', $user->tenant_id)->where('entra_oid', $user->entra_oid)->firstOrFail(); + } } diff --git a/backend/app/Signature/ProfileService.php b/backend/app/Signature/ProfileService.php new file mode 100644 index 0000000..9b9db40 --- /dev/null +++ b/backend/app/Signature/ProfileService.php @@ -0,0 +1,127 @@ + ['label' => 'Texte libre', 'rule' => ['string', 'max:120']], + 'phone' => ['label' => 'Téléphone', 'rule' => ['string', 'max:25', 'regex:/^[0-9 +().\-]{6,25}$/']], + ]; + + /** @return array{fields: array, reply_variant: ?string, reply_variant_default: string} */ + public function profile(DirectoryUser $user): array + { + $editable = $this->editableFields($user); + $overrides = $user->overrides()->pluck('value', 'field'); + + $fields = []; + foreach ($editable as $f) { + $column = VariableMap::FIELDS[$f->field]['column'] ?? null; + if (! $column) { + continue; + } + $fields[] = [ + 'key' => $f->field, + 'label' => $f->label, + 'type' => $f->validation_rule ?: 'text', + 'directory_value' => $user->getAttribute($column), + 'override' => $overrides->has($f->field) ? $overrides[$f->field] : null, + 'overridden' => $overrides->has($f->field), + ]; + } + + return [ + 'fields' => $fields, + 'reply_variant' => $user->preference?->reply_variant, + 'reply_variant_default' => config('signature.default_reply_variant', 'short'), + ]; + } + + /** + * @param array $values clé de variable => valeur (null = rétablir l'annuaire) + * + * @throws \Illuminate\Validation\ValidationException + */ + public function update(DirectoryUser $user, array $values, ?string $replyVariant, bool $replyVariantProvided, string $actor): void + { + $editable = $this->editableFields($user)->keyBy('field'); + + $forbidden = array_diff(array_keys($values), $editable->keys()->all()); + if ($forbidden) { + throw ValidationException::withMessages( + collect($forbidden)->mapWithKeys(fn ($k) => ["fields.{$k}" => 'Ce champ n\'est pas modifiable.'])->all() + ); + } + + $rules = ['reply_variant' => ['nullable', Rule::in(['short', 'full', 'none'])]]; + foreach (array_keys($values) as $key) { + $type = $editable[$key]->validation_rule ?: 'text'; + $rules["fields.{$key}"] = array_merge(['nullable'], self::TYPES[$type]['rule'] ?? self::TYPES['text']['rule']); + } + // Chaîne vide = masquer le champ : rien à valider pour elle. + Validator::make( + ['fields' => array_filter($values, fn ($v) => $v !== ''), 'reply_variant' => $replyVariant], + $rules, + ['fields.*.regex' => 'Numéro invalide (chiffres, espaces, + . - ( ) uniquement).', 'fields.*.max' => 'Valeur trop longue.'], + )->validate(); + + DB::transaction(function () use ($user, $values, $replyVariant, $replyVariantProvided, $actor) { + $changes = []; + foreach ($values as $key => $value) { + $value = $value === null ? null : trim((string) $value); + $current = UserOverride::where('directory_user_id', $user->id)->where('field', $key)->first(); + + if ($value === null) { + if ($current) { + $changes[$key] = [$current->value, '(annuaire)']; + $current->delete(); + } + continue; + } + if ($current?->value === $value) { + continue; + } + $changes[$key] = [$current?->value ?? '(annuaire)', $value === '' ? '(masqué)' : $value]; + UserOverride::updateOrCreate(['directory_user_id' => $user->id, 'field' => $key], ['value' => $value]); + } + + if ($replyVariantProvided) { + $pref = UserPreference::firstOrNew(['directory_user_id' => $user->id]); + if ($pref->reply_variant !== $replyVariant) { + $changes['reply_variant'] = [$pref->reply_variant ?? '(défaut)', $replyVariant ?? '(défaut)']; + $pref->reply_variant = $replyVariant; + $pref->save(); + } + } + + if ($changes) { + AuditLog::record('agent', $actor, 'profile.update', $user, $changes); + } + }); + } + + private function editableFields(DirectoryUser $user) + { + return EditableField::where('tenant_id', $user->tenant_id)->where('enabled', true) + ->whereIn('field', array_keys(VariableMap::FIELDS)) + ->orderBy('id')->get(); + } +} diff --git a/backend/resources/views/admin/fields/index.blade.php b/backend/resources/views/admin/fields/index.blade.php new file mode 100644 index 0000000..19c9110 --- /dev/null +++ b/backend/resources/views/admin/fields/index.blade.php @@ -0,0 +1,40 @@ +@extends('layouts.app') +@section('title', 'Champs agents — Signatures CABM') +@section('content') +

Champs modifiables par les agents

+

+ Les agents modifient ces champs dans l'add-in (bouton « Ma signature ») ou sur le portail. + La valeur saisie remplace celle de l'annuaire dans la signature uniquement : l'AD reste la référence. + Publication immédiate, chaque modification est tracée. Fermer un champ fait revenir les signatures concernées à la valeur de l'annuaire. +

+
+ @csrf @method('PUT') + + + + @foreach ($fields as $f) + + + + + + + + @endforeach + +
OuvertVariableLibellé affiché à l'agentContrôleAgents ayant une valeur perso
+ + enabled) aria-label="Ouvrir {{ $f->label }}"> + {{ $f->field }} + + {{ $usage[$f->field] ?? 0 }}
+
+ + L'adresse e-mail n'est jamais modifiable. +
+
+@endsection diff --git a/backend/resources/views/layouts/app.blade.php b/backend/resources/views/layouts/app.blade.php index 147aa6b..06951a6 100644 --- a/backend/resources/views/layouts/app.blade.php +++ b/backend/resources/views/layouts/app.blade.php @@ -58,6 +58,7 @@ request()->routeIs('admin.images.*')])>Images @if (auth()->user()->hasRole('admin')) request()->routeIs('admin.assignments.*')])>Affectations + request()->routeIs('admin.fields.*')])>Champs agents @endif request()->routeIs('admin.agents.*')])>Agents request()->routeIs('portal.*')])>Ma signature diff --git a/backend/resources/views/portal/home.blade.php b/backend/resources/views/portal/home.blade.php index df3da67..52d41bc 100644 --- a/backend/resources/views/portal/home.blade.php +++ b/backend/resources/views/portal/home.blade.php @@ -1,13 +1,64 @@ @extends('layouts.app') @section('title', 'Ma signature — CABM') @section('content') -
-

Ma signature

-

Modèle : {{ $full['template'] ?? 'aucun' }}. Vos informations proviennent de l'annuaire de la collectivité ; - la modification de certains champs arrivera prochainement.

-

Nouveau message

- @include('partials.signature-frame', ['html' => $full['html'] ?? 'Aucune signature']) -

Réponse / transfert

- @include('partials.signature-frame', ['html' => ($reply['html'] ?? '') !== '' ? $reply['html'] : 'Aucune signature']) +

Ma signature

+
+
+
+

Nouveau message

+

Modèle : {{ $full['template'] ?? 'aucun' }}

+ @include('partials.signature-frame', ['html' => $full['html'] ?? 'Aucune signature']) +

Réponse / transfert

+ @include('partials.signature-frame', ['html' => ($reply['html'] ?? '') !== '' ? $reply['html'] : 'Aucune signature']) +
+
+ +
+ @csrf @method('PUT') +

Mes informations

+ @if (! $profile['fields']) +

Aucun champ n'est modifiable pour le moment. Vos informations viennent de l'annuaire de la collectivité : + pour les corriger, contactez le service des ressources humaines ou le support informatique.

+ @else +

Ces valeurs ne modifient que votre signature, pas l'annuaire.

+ @foreach ($profile['fields'] as $f) + @php + $mode = ! $f['overridden'] ? 'directory' : ($f['override'] === '' ? 'hidden' : 'custom'); + $id = 'f-'.$f['key']; + @endphp +
+ {{ $f['label'] }} + + + + +
+ @endforeach + @endif + +

En réponse et transfert

+ @php $rv = $profile['reply_variant'] ?? 'default'; $labels = ['short' => 'signature courte', 'full' => 'signature complète', 'none' => 'aucune signature']; @endphp + @foreach (['default' => 'Réglage par défaut ('.$labels[$profile['reply_variant_default']].')', 'short' => 'Signature courte', 'full' => 'Signature complète', 'none' => 'Aucune signature'] as $value => $label) + + @endforeach + +
+
@endsection + +@push('scripts') + +@endpush diff --git a/backend/routes/api.php b/backend/routes/api.php index 37d3635..fe0be23 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,9 +1,12 @@ middleware(['entra.token', 'throttle:signature'])->group(function () { Route::get('/signature', [SignatureController::class, 'show'])->name('api.signature'); + Route::get('/profile', [ProfileController::class, 'show'])->name('api.profile'); + Route::put('/profile', [ProfileController::class, 'update'])->name('api.profile.update'); }); diff --git a/backend/routes/web.php b/backend/routes/web.php index 389eda7..7123ca0 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Admin\AgentController; use App\Http\Controllers\Admin\AssignmentController; use App\Http\Controllers\Admin\DashboardController; +use App\Http\Controllers\Admin\EditableFieldController; use App\Http\Controllers\Admin\ImageController; use App\Http\Controllers\Admin\SyncController; use App\Http\Controllers\Admin\TemplateController; @@ -19,7 +20,8 @@ Route::post('/logout', [EntraLoginController::class, 'logout'])->name('logout')- // Portail agent (tout agent synchronisé) Route::middleware('auth')->group(function () { - Route::get('/me', PortalController::class)->name('portal.home'); + Route::get('/me', [PortalController::class, 'show'])->name('portal.home'); + Route::put('/me', [PortalController::class, 'update'])->name('portal.update'); }); // Back-office : réseau interne + rôle éditeur minimum @@ -50,6 +52,8 @@ Route::prefix('admin')->middleware(['admin.network', 'auth', 'role:editor'])->na Route::delete('/affectations/{assignment}', [AssignmentController::class, 'destroy'])->name('assignments.destroy'); Route::post('/synchro', SyncController::class)->name('sync'); + Route::get('/champs', [EditableFieldController::class, 'index'])->name('fields.index'); + Route::put('/champs', [EditableFieldController::class, 'update'])->name('fields.update'); Route::delete('/images/{name}', [ImageController::class, 'destroy'])->name('images.destroy')->where('name', '[A-Za-z0-9._-]+'); }); diff --git a/backend/tests/Feature/ProfileTest.php b/backend/tests/Feature/ProfileTest.php new file mode 100644 index 0000000..cceff88 --- /dev/null +++ b/backend/tests/Feature/ProfileTest.php @@ -0,0 +1,115 @@ + []]); + $this->tenant = Tenant::create(['name' => 'CABM', 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111']); + $this->agent = DirectoryUser::create([ + 'tenant_id' => $this->tenant->id, 'entra_oid' => '22222222-2222-2222-2222-222222222222', + 'given_name' => 'Jean', 'mobile_phone' => '06 00 00 00 00', 'office_location' => 'Bâtiment A', + 'mail' => 'j.martin@beziers-mediterranee.fr', 'account_enabled' => true, + ]); + EditableField::create(['tenant_id' => $this->tenant->id, 'field' => 'mobile', 'label' => 'Mobile', 'enabled' => true, 'validation_rule' => 'phone']); + EditableField::create(['tenant_id' => $this->tenant->id, 'field' => 'bureau', 'label' => 'Bureau', 'enabled' => true, 'validation_rule' => 'text']); + EditableField::create(['tenant_id' => $this->tenant->id, 'field' => 'fonction', 'label' => 'Fonction', 'enabled' => false]); + Template::create(['tenant_id' => $this->tenant->id, 'name' => 'D', 'is_default' => true, + 'draft_html_full' => '{{prenom}}{{#if mobile}} M:{{mobile}}{{/if}}{{#if bureau}} B:{{bureau}}{{/if}}'])->publish(); + + $this->app->instance(EntraTokenValidator::class, new class extends EntraTokenValidator { + public function validate(string $jwt): array + { + if ($jwt !== 'ok') { + throw new RuntimeException('bad'); + } + + return ['tid' => '11111111-1111-1111-1111-111111111111', 'oid' => '22222222-2222-2222-2222-222222222222']; + } + }); + } + + public function test_api_liste_seulement_les_champs_ouverts(): void + { + $this->withToken('ok')->getJson('/api/v1/profile')->assertOk() + ->assertJsonCount(2, 'fields') + ->assertJsonPath('fields.0.key', 'mobile') + ->assertJsonPath('fields.0.directory_value', '06 00 00 00 00') + ->assertJsonPath('fields.0.overridden', false) + ->assertJsonPath('reply_variant_default', 'short'); + } + + public function test_api_modifie_masque_puis_retablit_et_trace(): void + { + $this->withToken('ok')->putJson('/api/v1/profile', ['fields' => ['mobile' => '07 11 22 33 44'], 'hidden' => ['bureau'], 'reply_variant' => 'none']) + ->assertOk()->assertJsonPath('fields.0.override', '07 11 22 33 44')->assertJsonPath('fields.1.override', '')->assertJsonPath('reply_variant', 'none'); + + $this->withToken('ok')->getJson('/api/v1/signature')->assertJson(['html' => 'Jean M:07 11 22 33 44']); + $this->withToken('ok')->getJson('/api/v1/signature?context=reply')->assertJson(['html' => '', 'variant' => 'none']); + $this->assertTrue(AuditLog::where('action', 'profile.update')->where('actor', 'j.martin@beziers-mediterranee.fr')->exists()); + + $this->withToken('ok')->putJson('/api/v1/profile', ['fields' => ['mobile' => null, 'bureau' => null], 'reply_variant' => null])->assertOk(); + $this->assertSame(0, UserOverride::count()); + $this->withToken('ok')->getJson('/api/v1/signature')->assertJson(['html' => 'Jean M:06 00 00 00 00 B:Bâtiment A']); + } + + public function test_api_refuse_champ_ferme_et_numero_invalide(): void + { + $this->withToken('ok')->putJson('/api/v1/profile', ['fields' => ['fonction' => 'Directeur']]) + ->assertStatus(422)->assertJsonValidationErrors('fields.fonction'); + $this->withToken('ok')->putJson('/api/v1/profile', ['fields' => ['mobile' => '06']]) + ->assertStatus(422)->assertJsonValidationErrors('fields.mobile'); + $this->withToken('ok')->putJson('/api/v1/profile', ['fields' => []], )->assertOk(); + $this->assertSame(0, UserOverride::count()); + } + + public function test_portail_enregistre_les_trois_modes(): void + { + $user = User::create(['name' => 'Jean', 'email' => 'j.martin@beziers-mediterranee.fr', 'tenant_id' => $this->tenant->id, 'entra_oid' => '22222222-2222-2222-2222-222222222222']); + $this->actingAs($user)->get('/me')->assertOk()->assertSee('Mes informations')->assertSee('06 00 00 00 00'); + + $this->actingAs($user)->put('/me', [ + 'fields' => ['mobile' => ['mode' => 'custom', 'value' => '07 99 99 99 99'], 'bureau' => ['mode' => 'hidden']], + 'reply_variant' => 'full', + ])->assertRedirect()->assertSessionHasNoErrors(); + + $this->assertSame('07 99 99 99 99', UserOverride::where('field', 'mobile')->value('value')); + $this->assertSame('', UserOverride::where('field', 'bureau')->value('value')); + $this->assertSame('full', $this->agent->fresh()->preference->reply_variant); + } + + public function test_admin_ouvre_et_ferme_les_champs(): void + { + $admin = User::create(['name' => 'A', 'email' => 'a@x.fr', 'tenant_id' => $this->tenant->id, 'entra_oid' => '33333333-3333-3333-3333-333333333333', 'role' => 'admin']); + $this->actingAs($admin)->get('/admin/champs')->assertOk()->assertSee('telephone')->assertDontSee('email', false); + + $this->actingAs($admin)->put('/admin/champs', ['fields' => [ + 'mobile' => ['enabled' => '0', 'label' => 'Mobile', 'type' => 'phone'], + 'fonction' => ['enabled' => '1', 'label' => 'Ma fonction', 'type' => 'text'], + ]])->assertRedirect(); + + $this->assertFalse(EditableField::where('field', 'mobile')->value('enabled')); + $this->assertSame('Ma fonction', EditableField::where('field', 'fonction')->value('label')); + $this->withToken('ok')->getJson('/api/v1/profile')->assertJsonMissing(['key' => 'mobile'])->assertJsonFragment(['key' => 'fonction']); + } +}