Unverified Commit 01d88c43 authored by Kourser's avatar Kourser
Browse files

Annonce aux utilisateurs : un panneau à elle, comme les autres réglages

Elle était rédigée à l'intérieur du panneau de supervision, sous un
titre de second rang : deux sujets dans un même cadre, alors que la
supervision se lit et que l'annonce s'écrit.

Elle rejoint donc les autres panneaux de la console — adresse publique,
courriel, inscriptions — avec leur structure, et se place juste après la
supervision : on lit l'état de l'instance, puis on s'adresse à ceux qui
l'utilisent.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 443cd9f7
Loading
Loading
Loading
Loading
+156 −0
Original line number Diff line number Diff line
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import type {
  AnnouncementLevel,
  ApiAnnouncementState,
  ApiAppUrlState,
  ApiInstanceAudit,
  ApiMailSettings,
@@ -45,6 +47,7 @@ export function Instance(): JSX.Element {
    <div className="instance">
      {/* En tete : c'est la vue d'ensemble, on la lit avant les reglages. */}
      <InstanceMonitoring />
      <AnnouncementPanel />
      {/*
        AVANT les reglages de courriel, a dessein : tous les liens envoyes par
        courriel sont prefixes par cette adresse. Une adresse fausse rend l'envoi
@@ -81,6 +84,159 @@ export function Instance(): JSX.Element {
  );
}

/**
 * Rédaction de l'annonce diffusée à tous les utilisateurs.
 *
 * L'avertissement sur la portée publique est AU-DESSUS du champ, pas en note de
 * bas de panneau : quiconque écrit ici doit savoir avant de taper que ce texte est
 * lisible sans compte.
 */
function AnnouncementPanel(): JSX.Element {
  const { t } = useT();
  const [state, setState] = useState<ApiAnnouncementState | null>(null);
  const [message, setMessage] = useState('');
  const [level, setLevel] = useState<AnnouncementLevel>('INFO');
  const [until, setUntil] = useState('');
  const [notice, setNotice] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [busy, setBusy] = useState(false);

  const reload = useCallback(async () => {
    try {
      // Corps vide quand aucune annonce n'existe : ramene a `null`.
      const current = (await api.instanceAnnouncement()) ?? null;
      setState(current);
      setMessage(current?.message ?? '');
      setLevel(current?.level ?? 'INFO');
      // `datetime-local` attend `YYYY-MM-DDTHH:mm`, sans fuseau ni secondes.
      setUntil(current?.until ? current.until.slice(0, 16) : '');
    } catch (err) {
      setError(err instanceof ApiError ? err.message : t('Chargement impossible'));
    }
  }, [t]);

  useEffect(() => {
    void reload();
  }, [reload]);

  async function save(e: FormEvent): Promise<void> {
    e.preventDefault();
    setBusy(true);
    setError(null);
    setNotice(null);
    try {
      await api.setInstanceAnnouncement({
        message,
        level,
        // Champ vide = pas d'expiration. Le convertir en date invalide aurait
        // produit une annonce jamais servie, sans rien dire à personne.
        ...(until ? { until: new Date(until).toISOString() } : {}),
      });
      setNotice(t('Annonce publiée.'));
      await reload();
    } catch (err) {
      setError(err instanceof ApiError ? err.message : t('Enregistrement impossible'));
    } finally {
      setBusy(false);
    }
  }

  async function remove(): Promise<void> {
    setBusy(true);
    setError(null);
    setNotice(null);
    try {
      await api.clearInstanceAnnouncement();
      setNotice(t('Annonce retirée.'));
      await reload();
    } catch (err) {
      setError(err instanceof ApiError ? err.message : t('Retrait impossible'));
    } finally {
      setBusy(false);
    }
  }

  return (
    <section className="panel">
      <div className="panel-head">
        <h3>{t('Annonce aux utilisateurs')}</h3>
      </div>

      <form className="announce-editor" onSubmit={(e) => void save(e)}>
        <p className="warn-note">
          {t(
            'Ce message s’affiche à tous les utilisateurs, y compris AVANT connexion : il est lisible par quiconque atteint cette instance. N’y écrivez rien de confidentiel.',
          )}
        </p>

        {state && (
          <p className="muted small">
            {state.live ? t('Diffusée en ce moment.') : t('Enregistrée, mais expirée.')}
            {state.updatedAt &&
              ` · ${t('modifiée le')} ${new Date(state.updatedAt).toLocaleString()}`}
          </p>
        )}

        <label>
          {t('Message')}
          <textarea
            value={message}
            onChange={(e) => setMessage(e.target.value)}
            maxLength={500}
            rows={3}
            placeholder={t(
              'Maintenance prévue le … de 18 h à 19 h. Les exercices seront interrompus.',
            )}
          />
        </label>

        <div className="row">
          <label>
            {t('Niveau')}
            <select value={level} onChange={(e) => setLevel(e.target.value as AnnouncementLevel)}>
              <option value="INFO">{t('Information')}</option>
              <option value="WARNING">{t('Avertissement')}</option>
              <option value="CRITICAL">{t('Critique')}</option>
            </select>
          </label>
          <label>
            {t('Expire le')}
            <input type="datetime-local" value={until} onChange={(e) => setUntil(e.target.value)} />
          </label>
        </div>

        <p className="muted small">
          {t(
            'Sans expiration, l’annonce reste affichée jusqu’à son retrait. Un bandeau qu’on n’a plus raison de croire est un bandeau que personne ne lit.',
          )}
        </p>
        <p className="muted small">
          {t('Un niveau « critique » ne peut pas être masqué par l’utilisateur.')}
        </p>

        {notice && <p className="ok-msg">{notice}</p>}
        {error && <p className="err">{error}</p>}

        <div className="row">
          <button className="btn primary" disabled={busy || message.trim().length < 3}>
            {t('Publier')}
          </button>
          {state && (
            <button
              type="button"
              className="btn danger"
              disabled={busy}
              onClick={() => void remove()}
            >
              {t('Retirer l’annonce')}
            </button>
          )}
        </div>
      </form>
    </section>
  );
}

/**
 * Adresse publique de l'instance.
 *
+3 −157
Original line number Diff line number Diff line
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import type { AnnouncementLevel, ApiAnnouncementState, ApiInstanceMetrics } from '@cythin/shared';
import { useCallback, useEffect, useState } from 'react';
import type { ApiInstanceMetrics } from '@cythin/shared';
import { api, ApiError } from './api';
import { useT } from './i18n';
import { formatBytes, formatCount, formatUptime, percent } from './format';

/**
 * Supervision de l'instance, et rédaction de l'annonce.
 *
 * Deux sujets, un fichier : ils occupent la même place dans la console — la vue
 * d'ensemble de l'instance, en tête — et l'annonce n'a pas d'écran à elle seule.
 * Supervision de l'instance.
 *
 * Aucune donnée personnelle n'est affichée : que des comptages et des tailles.
 * Une page de supervision a besoin de savoir COMBIEN, pas QUI ; le journal
@@ -233,157 +230,6 @@ export function InstanceMonitoring(): JSX.Element {
      </div>

      {error && <p className="err">{error}</p>}

      <AnnouncementEditor />
    </section>
  );
}

/**
 * Rédaction de l'annonce diffusée à tous les utilisateurs.
 *
 * L'avertissement sur la portée publique est AU-DESSUS du champ, pas en note de
 * bas de panneau : quiconque écrit ici doit savoir avant de taper que ce texte est
 * lisible sans compte.
 */
function AnnouncementEditor(): JSX.Element {
  const { t } = useT();
  const [state, setState] = useState<ApiAnnouncementState | null>(null);
  const [message, setMessage] = useState('');
  const [level, setLevel] = useState<AnnouncementLevel>('INFO');
  const [until, setUntil] = useState('');
  const [notice, setNotice] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [busy, setBusy] = useState(false);

  const reload = useCallback(async () => {
    try {
      // Corps vide quand aucune annonce n'existe : ramene a `null`.
      const current = (await api.instanceAnnouncement()) ?? null;
      setState(current);
      setMessage(current?.message ?? '');
      setLevel(current?.level ?? 'INFO');
      // `datetime-local` attend `YYYY-MM-DDTHH:mm`, sans fuseau ni secondes.
      setUntil(current?.until ? current.until.slice(0, 16) : '');
    } catch (err) {
      setError(err instanceof ApiError ? err.message : t('Chargement impossible'));
    }
  }, [t]);

  useEffect(() => {
    void reload();
  }, [reload]);

  async function save(e: FormEvent): Promise<void> {
    e.preventDefault();
    setBusy(true);
    setError(null);
    setNotice(null);
    try {
      await api.setInstanceAnnouncement({
        message,
        level,
        // Champ vide = pas d'expiration. Le convertir en date invalide aurait
        // produit une annonce jamais servie, sans rien dire à personne.
        ...(until ? { until: new Date(until).toISOString() } : {}),
      });
      setNotice(t('Annonce publiée.'));
      await reload();
    } catch (err) {
      setError(err instanceof ApiError ? err.message : t('Enregistrement impossible'));
    } finally {
      setBusy(false);
    }
  }

  async function remove(): Promise<void> {
    setBusy(true);
    setError(null);
    setNotice(null);
    try {
      await api.clearInstanceAnnouncement();
      setNotice(t('Annonce retirée.'));
      await reload();
    } catch (err) {
      setError(err instanceof ApiError ? err.message : t('Retrait impossible'));
    } finally {
      setBusy(false);
    }
  }

  return (
    <form className="announce-editor card" onSubmit={(e) => void save(e)}>
      <h4>{t('Annonce aux utilisateurs')}</h4>

      <p className="warn-note">
        {t(
          'Ce message s’affiche à tous les utilisateurs, y compris AVANT connexion : il est lisible par quiconque atteint cette instance. N’y écrivez rien de confidentiel.',
        )}
      </p>

      {state && (
        <p className="muted small">
          {state.live ? t('Diffusée en ce moment.') : t('Enregistrée, mais expirée.')}
          {state.updatedAt &&
            ` · ${t('modifiée le')} ${new Date(state.updatedAt).toLocaleString()}`}
        </p>
      )}

      <label>
        {t('Message')}
        <textarea
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          maxLength={500}
          rows={3}
          placeholder={t(
            'Maintenance prévue le … de 18 h à 19 h. Les exercices seront interrompus.',
          )}
        />
      </label>

      <div className="row">
        <label>
          {t('Niveau')}
          <select value={level} onChange={(e) => setLevel(e.target.value as AnnouncementLevel)}>
            <option value="INFO">{t('Information')}</option>
            <option value="WARNING">{t('Avertissement')}</option>
            <option value="CRITICAL">{t('Critique')}</option>
          </select>
        </label>
        <label>
          {t('Expire le')}
          <input type="datetime-local" value={until} onChange={(e) => setUntil(e.target.value)} />
        </label>
      </div>

      <p className="muted small">
        {t(
          'Sans expiration, l’annonce reste affichée jusqu’à son retrait. Un bandeau qu’on n’a plus raison de croire est un bandeau que personne ne lit.',
        )}
      </p>
      <p className="muted small">
        {t('Un niveau « critique » ne peut pas être masqué par l’utilisateur.')}
      </p>

      {notice && <p className="ok-msg">{notice}</p>}
      {error && <p className="err">{error}</p>}

      <div className="row">
        <button className="btn primary" disabled={busy || message.trim().length < 3}>
          {t('Publier')}
        </button>
        {state && (
          <button
            type="button"
            className="btn danger"
            disabled={busy}
            onClick={() => void remove()}
          >
            {t('Retirer l’annonce')}
          </button>
        )}
      </div>
    </form>
  );
}
+1 −5
Original line number Diff line number Diff line
@@ -6875,16 +6875,12 @@ label.setting-row {
}

.announce-editor {
  margin-top: 20px;
  display: grid;
  gap: 10px;
}

.announce-editor h4 {
  margin: 0;
}

.announce-editor textarea {
  width: 100%;
  resize: vertical;
}