diff --git a/docs/superpowers/plans/2026-07-03-admin-ui-plan.md b/docs/superpowers/plans/2026-07-03-admin-ui-plan.md new file mode 100644 index 0000000..37b7a06 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-admin-ui-plan.md @@ -0,0 +1,1362 @@ +# Panneau admin interne (`/admin`) Implementation Plan + +> **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:** Build an internal admin panel at `/admin` for support/debugging — list users, view a user's full subscription history, correct a subscription status manually, all restricted to a single `ADMIN_EMAIL` and logged to an `admin_actions` audit table also viewable at `/admin/journal`. + +**Architecture:** Dash pages (`register_page`) for reads, one Flask blueprint (`src/admin/routes.py`) for the single write action, both guarded by a shared `is_admin()` check. Mirrors the existing `/compte/*` (Dash) + `/auth/*` (Flask blueprint) split. + +**Tech Stack:** Dash 3.4, dash-bootstrap-components, Flask, flask-login, flask-wtf (CSRFProtect, already global), sqlite3 (raw, no ORM). + +**Spec:** `docs/superpowers/specs/2026-07-03-admin-ui-design.md` + +## Global Constraints + +- Valid subscription statuses (from `src/subscriptions/webhooks.py:map_subscription`): `active`, `trial`, `cancelled`, `expired`, `pending`. +- `ADMIN_EMAIL` is a single email, compared case-insensitively. No multi-admin support in this lot. +- Access guard (`is_admin()` false) always returns/aborts **404**, never a redirect — applies uniformly to anonymous and authenticated-non-admin. +- CSRF protection is already global (`CSRFProtect(app)`, `src/auth/setup.py:53`) — no custom CSRF code, just the existing hidden `csrf_token` input pattern. +- `dash_table.DataTable` uses `filter_action="native"`, `sort_action="native"`, `page_action="native"`, `page_size=20` everywhere in this feature (no custom/server-side filtering). +- Run tests with `uv run pytest` (venv activation via the Bash tool doesn't reliably update `PATH` in this environment). +- `sqlite3` connections here are autocommit (`isolation_level=None`, see `src/auth/db.py:56`) — new DB functions must NOT call `conn.commit()`, matching every existing function in `src/auth/db.py` and `src/subscriptions/db.py`. + +--- + +### Task 1: `list_users()` in the auth DB layer + +**Files:** + +- Modify: `src/auth/db.py` (add after `get_user_by_id`, around line 148) +- Test: `tests/auth/test_db.py` + +**Interfaces:** + +- Produces: `list_users(limit: int = 1000) -> list[sqlite3.Row]` — all users, most recently created first, capped at `limit`. + +- [ ] **Step 1: Write the failing test** + +Add to `tests/auth/test_db.py`: + +```python +def test_list_users_orders_by_created_at_desc(users_db_path): + from src.auth import db + + db.init_schema() + db.create_user("first@ex.fr", "hash") + db.create_user("second@ex.fr", "hash") + + rows = db.list_users() + + assert [r["email"] for r in rows] == ["second@ex.fr", "first@ex.fr"] + + +def test_list_users_respects_limit(users_db_path): + from src.auth import db + + db.init_schema() + for i in range(3): + db.create_user(f"user{i}@ex.fr", "hash") + + rows = db.list_users(limit=2) + + assert len(rows) == 2 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/auth/test_db.py -k list_users -v` +Expected: FAIL with `AttributeError: module 'src.auth.db' has no attribute 'list_users'` + +- [ ] **Step 3: Implement `list_users`** + +In `src/auth/db.py`, immediately after `get_user_by_id` (line 148): + +```python +def list_users(limit: int = 1000) -> list[sqlite3.Row]: + return ( + get_conn() + .execute("SELECT * FROM users ORDER BY created_at DESC LIMIT ?", (limit,)) + .fetchall() + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/auth/test_db.py -k list_users -v` +Expected: 2 passed + +- [ ] **Step 5: Commit** + +```bash +git add src/auth/db.py tests/auth/test_db.py +git commit -m "feat(admin): add list_users() to auth DB layer" +``` + +--- + +### Task 2: Subscription DB additions — history, status override, statuses constant + +**Files:** + +- Modify: `src/subscriptions/db.py` (add after `get_current`, around line 124, and after `_get_state`, around line 210) +- Test: `tests/subscriptions/test_db.py` + +**Interfaces:** + +- Produces: `SUBSCRIPTION_STATUSES: tuple[str, ...]` = `("active", "trial", "cancelled", "expired", "pending")` +- Produces: `list_by_user(user_id: int) -> list[sqlite3.Row]` — all subscriptions for a user, most recent (`id DESC`) first. +- Produces: `set_status(subscription_id: int, status: str) -> None` — overwrites `status` and `updated_at`. +- Produces: `get_subscriber_state(user_id: int) -> sqlite3.Row | None` — public wrapper around the existing private `_get_state`. +- Consumes: `get_conn()` from `src.auth.db` (already imported), `_now()` (already defined in this module), `create_pending(user_id, customer_handle, plan, prix_ht=None) -> (handle, subscription_id)` (already exists, used by the test). + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/subscriptions/test_db.py`: + +```python +def test_list_by_user_returns_most_recent_first(users_db_path): + uid = _make_user() + db.init_schema() + _handle1, sub_id1 = db.create_pending(uid, "cust-1", "simple") + _handle2, sub_id2 = db.create_pending(uid, "cust-1", "soutien") + + rows = db.list_by_user(uid) + + assert [r["id"] for r in rows] == [sub_id2, sub_id1] + + +def test_set_status_updates_status(users_db_path): + uid = _make_user() + db.init_schema() + _handle, sub_id = db.create_pending(uid, "cust-1", "simple") + + db.set_status(sub_id, "active") + + row = db.get_current(uid) + assert row["status"] == "active" + + +def test_get_subscriber_state_returns_row_after_create_pending(users_db_path): + uid = _make_user() + db.init_schema() + db.create_pending(uid, "cust-1", "simple") + + state = db.get_subscriber_state(uid) + + assert state is not None + assert state["user_id"] == uid + + +def test_get_subscriber_state_returns_none_for_unknown_user(users_db_path): + db.init_schema() + assert db.get_subscriber_state(999999) is None + + +def test_subscription_statuses_constant(): + assert db.SUBSCRIPTION_STATUSES == ( + "active", + "trial", + "cancelled", + "expired", + "pending", + ) +``` + +(`_make_user` and the `users_db_path` fixture already exist in this file/its `conftest.py` — see `tests/subscriptions/test_db.py:16`.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/subscriptions/test_db.py -k "list_by_user or set_status or get_subscriber_state or subscription_statuses" -v` +Expected: FAIL with `AttributeError` for each missing symbol + +- [ ] **Step 3: Implement the additions** + +In `src/subscriptions/db.py`, immediately after `get_current` (after line 123, before `get_by_handle`): + +```python +SUBSCRIPTION_STATUSES = ("active", "trial", "cancelled", "expired", "pending") + + +def list_by_user(user_id: int) -> list[sqlite3.Row]: + return ( + get_conn() + .execute( + "SELECT * FROM subscriptions WHERE user_id = ? ORDER BY id DESC", + (user_id,), + ) + .fetchall() + ) + + +def set_status(subscription_id: int, status: str) -> None: + get_conn().execute( + "UPDATE subscriptions SET status = ?, updated_at = ? WHERE id = ?", + (status, _now(), subscription_id), + ) +``` + +Then, immediately after `_get_state` (after line 210, before `freeze_votes_cursor`): + +```python +def get_subscriber_state(user_id: int) -> sqlite3.Row | None: + return _get_state(user_id) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/subscriptions/test_db.py -k "list_by_user or set_status or get_subscriber_state or subscription_statuses" -v` +Expected: 5 passed + +- [ ] **Step 5: Commit** + +```bash +git add src/subscriptions/db.py tests/subscriptions/test_db.py +git commit -m "feat(admin): add subscription history, status override, and statuses constant" +``` + +--- + +### Task 3: Audit log table + `src/admin/db.py` + +**Files:** + +- Modify: `src/migrations.py` (append to `_MIGRATIONS`) +- Create: `src/admin/__init__.py` (empty) +- Create: `src/admin/db.py` +- Create: `tests/admin/__init__.py` (empty) +- Create: `tests/admin/conftest.py` +- Create: `tests/admin/test_db.py` + +**Interfaces:** + +- Produces: table `admin_actions(id, admin_email, action, target_user_id, details, created_at)`. +- Produces: `log_action(admin_email: str, action: str, target_user_id: int | None, details: str | None) -> None` +- Produces: `list_actions(limit: int = 200) -> list[sqlite3.Row]` — most recent (`id DESC`) first. +- Consumes: `get_conn()` from `src.auth.db`, `apply_pending()` from `src.migrations`. + +- [ ] **Step 1: Add the migration** + +In `src/migrations.py`, append to `_MIGRATIONS` (after the `0005_...` entry): + +```python + ( + "0006_create_admin_actions", + "CREATE TABLE IF NOT EXISTS admin_actions (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "admin_email TEXT NOT NULL, " + "action TEXT NOT NULL, " + "target_user_id INTEGER, " + "details TEXT, " + "created_at TEXT NOT NULL)", + ), +``` + +- [ ] **Step 2: Create the test conftest** + +Create `tests/admin/__init__.py` (empty file). + +Create `tests/admin/conftest.py`: + +```python +import pytest + + +@pytest.fixture +def users_db_path(monkeypatch, tmp_path): + from src.auth.db import reset_conn_for_tests + + db_path = tmp_path / "users.test.sqlite" + monkeypatch.setenv("USERS_DB_PATH", str(db_path)) + reset_conn_for_tests() + yield db_path + reset_conn_for_tests() +``` + +- [ ] **Step 3: Write the failing test** + +Create `tests/admin/test_db.py`: + +```python +from src.admin import db as admin_db +from src.auth.db import init_schema +from src.migrations import apply_pending + + +def _setup(): + init_schema() + apply_pending() + + +def test_log_action_then_list_actions_returns_it(users_db_path): + _setup() + + admin_db.log_action("admin@ex.fr", "subscription_status_change", 42, "active → cancelled") + + rows = admin_db.list_actions() + assert len(rows) == 1 + assert rows[0]["admin_email"] == "admin@ex.fr" + assert rows[0]["action"] == "subscription_status_change" + assert rows[0]["target_user_id"] == 42 + assert rows[0]["details"] == "active → cancelled" + + +def test_list_actions_most_recent_first(users_db_path): + _setup() + + admin_db.log_action("admin@ex.fr", "action_one", None, None) + admin_db.log_action("admin@ex.fr", "action_two", None, None) + + rows = admin_db.list_actions() + assert [r["action"] for r in rows] == ["action_two", "action_one"] + + +def test_list_actions_respects_limit(users_db_path): + _setup() + + for i in range(3): + admin_db.log_action("admin@ex.fr", f"action_{i}", None, None) + + rows = admin_db.list_actions(limit=2) + assert len(rows) == 2 +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `uv run pytest tests/admin/test_db.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'src.admin'` + +- [ ] **Step 5: Implement `src/admin/db.py`** + +Create `src/admin/__init__.py` (empty file). + +Create `src/admin/db.py`: + +```python +import sqlite3 +from datetime import datetime, timezone + +from src.auth.db import get_conn + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def log_action( + admin_email: str, action: str, target_user_id: int | None, details: str | None +) -> None: + get_conn().execute( + "INSERT INTO admin_actions (admin_email, action, target_user_id, details, created_at) " + "VALUES (?, ?, ?, ?, ?)", + (admin_email, action, target_user_id, details, _now()), + ) + + +def list_actions(limit: int = 200) -> list[sqlite3.Row]: + return ( + get_conn() + .execute("SELECT * FROM admin_actions ORDER BY id DESC LIMIT ?", (limit,)) + .fetchall() + ) +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `uv run pytest tests/admin/test_db.py -v` +Expected: 3 passed + +- [ ] **Step 7: Commit** + +```bash +git add src/migrations.py src/admin/__init__.py src/admin/db.py tests/admin/__init__.py tests/admin/conftest.py tests/admin/test_db.py +git commit -m "feat(admin): add admin_actions audit table and log/list functions" +``` + +--- + +### Task 4: Access guard — `is_admin()` + +**Files:** + +- Create: `src/admin/guard.py` +- Create: `tests/admin/test_guard.py` + +**Interfaces:** + +- Produces: `is_admin() -> bool` — `True` only if `ADMIN_EMAIL` is set, `current_user.is_authenticated`, and `current_user.email` matches case-insensitively. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/admin/test_guard.py`: + +```python +from unittest.mock import patch + +from src.admin import guard + + +def _fake_user(email: str, authenticated: bool = True): + user = type("U", (), {})() + user.is_authenticated = authenticated + user.email = email + return user + + +def test_is_admin_true_for_matching_email(monkeypatch): + monkeypatch.setenv("ADMIN_EMAIL", "admin@ex.fr") + with patch("src.admin.guard.current_user", _fake_user("admin@ex.fr")): + assert guard.is_admin() is True + + +def test_is_admin_true_case_insensitive(monkeypatch): + monkeypatch.setenv("ADMIN_EMAIL", "Admin@Ex.fr") + with patch("src.admin.guard.current_user", _fake_user("admin@ex.fr")): + assert guard.is_admin() is True + + +def test_is_admin_false_for_different_email(monkeypatch): + monkeypatch.setenv("ADMIN_EMAIL", "admin@ex.fr") + with patch("src.admin.guard.current_user", _fake_user("someone@ex.fr")): + assert guard.is_admin() is False + + +def test_is_admin_false_for_anonymous(monkeypatch): + monkeypatch.setenv("ADMIN_EMAIL", "admin@ex.fr") + with patch( + "src.admin.guard.current_user", _fake_user("admin@ex.fr", authenticated=False) + ): + assert guard.is_admin() is False + + +def test_is_admin_false_when_admin_email_unset(monkeypatch): + monkeypatch.delenv("ADMIN_EMAIL", raising=False) + with patch("src.admin.guard.current_user", _fake_user("admin@ex.fr")): + assert guard.is_admin() is False +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/admin/test_guard.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'src.admin.guard'` + +- [ ] **Step 3: Implement `src/admin/guard.py`** + +```python +import os + +from flask_login import current_user + + +def is_admin() -> bool: + admin_email = os.getenv("ADMIN_EMAIL") + return bool( + admin_email + and current_user.is_authenticated + and current_user.email.lower() == admin_email.lower() + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/admin/test_guard.py -v` +Expected: 5 passed + +- [ ] **Step 5: Commit** + +```bash +git add src/admin/guard.py tests/admin/test_guard.py +git commit -m "feat(admin): add is_admin() access guard" +``` + +--- + +### Task 5: Mutation route — `POST /admin/actions/subscription-status` + +**Files:** + +- Create: `src/admin/routes.py` +- Modify: `src/auth/setup.py` (register blueprint) +- Modify: `.template.env` (add `ADMIN_EMAIL`) +- Modify: `tests/admin/conftest.py` (add Flask app/client fixtures) +- Create: `tests/admin/test_routes.py` + +**Interfaces:** + +- Produces: Flask blueprint `admin_bp` (`url_prefix="/admin/actions"`), route `POST /subscription-status`. +- Consumes: `is_admin()` (Task 4), `SUBSCRIPTION_STATUSES`, `get_current`, `set_status` (Task 2), `log_action` (Task 3). + +- [ ] **Step 1: Extend the test conftest** + +In `tests/admin/conftest.py`, add (after the existing `users_db_path` fixture): + +```python +@pytest.fixture +def admin_app(users_db_path, monkeypatch): + from flask import Flask + + from src.auth.setup import init_auth + from src.subscriptions.setup import init_subscriptions + + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050") + monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple") + monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien") + monkeypatch.setenv("FRISBII_WEBHOOK_SECRET", "s3cr3t") + flask_app = Flask(__name__) + flask_app.config["WTF_CSRF_ENABLED"] = False + init_auth(flask_app) + init_subscriptions(flask_app) + return flask_app + + +@pytest.fixture +def admin_client(admin_app): + return admin_app.test_client() + + +@pytest.fixture +def logged_in_admin_client(admin_app, monkeypatch): + from src.auth import db as auth_db + + monkeypatch.setenv("ADMIN_EMAIL", "admin@ex.fr") + uid = auth_db.create_user("admin@ex.fr", "hash") + auth_db.set_email_verified(uid) + client = admin_app.test_client() + with client.session_transaction() as sess: + sess["_user_id"] = str(uid) + sess["_fresh"] = True + return client, uid +``` + +This mirrors `tests/subscriptions/conftest.py:62-106` (`sub_app`/`logged_in_client`). + +- [ ] **Step 2: Write the failing tests** + +Create `tests/admin/test_routes.py`: + +```python +from src.auth import db as auth_db +from src.subscriptions import db as sub_db + + +def _make_target_with_subscription(): + uid = auth_db.create_user("target@ex.fr", "hash") + _handle, sub_id = sub_db.create_pending(uid, "cust-1", "simple") + return uid, sub_id + + +def test_subscription_status_requires_admin(admin_client): + resp = admin_client.post( + "/admin/actions/subscription-status", + data={"user_id": "1", "subscription_id": "1", "status": "active"}, + ) + assert resp.status_code == 404 + + +def test_subscription_status_rejects_invalid_status(logged_in_admin_client): + client, _admin_uid = logged_in_admin_client + uid, sub_id = _make_target_with_subscription() + + resp = client.post( + "/admin/actions/subscription-status", + data={"user_id": str(uid), "subscription_id": str(sub_id), "status": "bogus"}, + ) + + assert resp.status_code == 302 + assert resp.headers["Location"] == f"/admin/user/{uid}?error=invalid_status" + assert sub_db.get_current(uid)["status"] == "pending" + + +def test_subscription_status_rejects_mismatched_subscription(logged_in_admin_client): + client, _admin_uid = logged_in_admin_client + uid, _sub_id = _make_target_with_subscription() + other_uid, other_sub_id = _make_target_with_subscription() + + resp = client.post( + "/admin/actions/subscription-status", + data={ + "user_id": str(uid), + "subscription_id": str(other_sub_id), + "status": "active", + }, + ) + + assert resp.status_code == 302 + assert resp.headers["Location"] == f"/admin/user/{uid}?error=invalid_status" + assert sub_db.get_current(other_uid)["status"] == "pending" + + +def test_subscription_status_success_updates_and_logs(logged_in_admin_client): + from src.admin.db import list_actions + + client, _admin_uid = logged_in_admin_client + uid, sub_id = _make_target_with_subscription() + + resp = client.post( + "/admin/actions/subscription-status", + data={"user_id": str(uid), "subscription_id": str(sub_id), "status": "active"}, + ) + + assert resp.status_code == 302 + assert resp.headers["Location"] == f"/admin/user/{uid}?status_changed=1" + assert sub_db.get_current(uid)["status"] == "active" + + actions = list_actions() + assert len(actions) == 1 + assert actions[0]["action"] == "subscription_status_change" + assert actions[0]["target_user_id"] == uid + assert actions[0]["details"] == "pending → active" + assert actions[0]["admin_email"] == "admin@ex.fr" +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `uv run pytest tests/admin/test_routes.py -v` +Expected: FAIL — `admin_client`/`logged_in_admin_client` fixtures raise `ModuleNotFoundError: No module named 'src.admin.routes'` (via `init_auth` once it tries to import it in Step 5) or `404`/`ImportError` depending on order; before Step 5, `test_subscription_status_requires_admin` will fail with a connection/404-mismatch since the blueprint doesn't exist yet (real 404 from Flask's default routing, not from `is_admin()` — acceptable coincidence, but the other three tests will fail). + +- [ ] **Step 4: Implement `src/admin/routes.py`** + +```python +from flask import Blueprint, abort, redirect, request +from flask_login import current_user + +from src.admin.db import log_action +from src.admin.guard import is_admin +from src.subscriptions.db import SUBSCRIPTION_STATUSES, get_current, set_status + +admin_bp = Blueprint("admin", __name__, url_prefix="/admin/actions") + + +@admin_bp.before_request +def _require_admin(): + if not is_admin(): + abort(404) + + +@admin_bp.route("/subscription-status", methods=["POST"]) +def subscription_status(): + user_id = request.form.get("user_id", type=int) + subscription_id = request.form.get("subscription_id", type=int) + status = request.form.get("status", "") + + if user_id is None or subscription_id is None: + abort(400) + + current = get_current(user_id) + if ( + status not in SUBSCRIPTION_STATUSES + or current is None + or current["id"] != subscription_id + ): + return redirect(f"/admin/user/{user_id}?error=invalid_status") + + old_status = current["status"] + set_status(subscription_id, status) + log_action( + current_user.email, + "subscription_status_change", + user_id, + f"{old_status} → {status}", + ) + return redirect(f"/admin/user/{user_id}?status_changed=1") +``` + +- [ ] **Step 5: Register the blueprint** + +In `src/auth/setup.py`, immediately after `app.register_blueprint(auth_bp)` (currently the line right after the `from src.auth.routes import auth_bp` block): + +```python + from src.auth.routes import auth_bp + + app.register_blueprint(auth_bp) + + from src.admin.routes import admin_bp + + app.register_blueprint(admin_bp) +``` + +- [ ] **Step 6: Add `ADMIN_EMAIL` to `.template.env`** + +In `.template.env`, add a new section after the "Comptes utilisateurs" block (after `APP_BASE_URL=http://localhost:8050`): + +``` +# Panneau admin interne (accès à /admin, protégé par cette adresse) +ADMIN_EMAIL= +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `uv run pytest tests/admin/test_routes.py -v` +Expected: 4 passed + +- [ ] **Step 8: Commit** + +```bash +git add src/admin/routes.py src/auth/setup.py .template.env tests/admin/conftest.py tests/admin/test_routes.py +git commit -m "feat(admin): add subscription-status mutation route" +``` + +--- + +### Task 6: Shared page chrome + `/admin` list page + +**Files:** + +- Create: `src/pages/admin/__init__.py` (empty) +- Create: `src/pages/admin/_shell.py` +- Create: `src/pages/admin/liste.py` + +**Interfaces:** + +- Produces: `not_admin() -> Component`, `admin_nav(active: str) -> Component` (in `_shell.py`), reused by Tasks 7 and 8. +- Consumes: `is_admin()` (Task 4), `list_users()` (Task 1), `get_current()` (existing). + +- [ ] **Step 1: Create the package and shared shell** + +Create `src/pages/admin/__init__.py` (empty file). + +Create `src/pages/admin/_shell.py`: + +```python +import dash_bootstrap_components as dbc +from dash import html + + +def not_admin(): + return html.Div( + html.H2("404", id="admin-404-heading"), className="py-5 text-center" + ) + + +def admin_nav(active: str): + return dbc.Nav( + [ + dbc.NavLink("Utilisateurs", href="/admin", active=(active == "liste")), + dbc.NavLink( + "Journal", href="/admin/journal", active=(active == "journal") + ), + ], + pills=True, + class_name="mb-4", + ) +``` + +(`html.H2` here, not `H1` — the navbar already renders a global `