v4: an instance that takes no users of others still blocks instances its own users may not sign in to — with GERBEUR_FEDERATION_INCOMING=0, /manage hid its list of instances, though a blocked one still kept the instance's users from signing in there, and no admin could block another. The list now stays while federated sign-in goes either way: without incoming sign-ins it shows and adds only blocked instances, the trusted ones kept for when they're back, and says what blocking does there. Where both ways are on, its note now says that users here can't sign in to a blocked instance either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-09 16:33:08 +00:00
parent 83dc44050e
commit 52a23db504
6 changed files with 195 additions and 65 deletions

View File

@@ -237,10 +237,11 @@ export const FEDERATION_ALLOW_PRIVATE: boolean =
*
* - `incoming` (`GERBEUR_FEDERATION_INCOMING`): users of other instances sign
* in here. Off, the login and register pages offer no other instance,
* `/manage` no list of instances to trust or block,
* `federation-service.ts` refuses their sign-ins, and the accounts that came
* from one are signed out (`getSessionState`) until it is on again. Their
* homes' events are still applied, so that none is lost meanwhile.
* `/manage` lists only the blocked ones, which this instance's users can't
* sign in to (and none with `outgoing` off too), `federation-service.ts`
* refuses their sign-ins, and the accounts that came from one are signed
* out (`getSessionState`) until it is on again. Their homes' events are
* still applied, so that none is lost meanwhile.
* - `outgoing` (`GERBEUR_FEDERATION_OUTGOING`): this instance's users sign in
* to others. Off, `identity-provider-service.ts` lets no instance sign them
* in, and its metadata says so, which the others check before sending

View File

@@ -354,7 +354,7 @@ msgstr "Already dumped by {0} {1}"
msgid "Already have an account? <0>Log in</0>"
msgstr "Already have an account? <0>Log in</0>"
#: src/components/federation/PeerManager.tsx:149
#: src/components/federation/PeerManager.tsx:167
msgid "An instance's address, like music.example"
msgstr "An instance's address, like music.example"
@@ -395,11 +395,12 @@ msgstr "Back to login"
msgid "Back to the login page"
msgstr "Back to the login page"
#: src/components/federation/PeerManager.tsx:163
#: src/components/federation/PeerManager.tsx:184
#: src/components/federation/PeerManager.tsx:190
msgid "Block"
msgstr "Block"
#: src/components/federation/PeerManager.tsx:33
#: src/components/federation/PeerManager.tsx:39
msgid "Blocked"
msgstr "Blocked"
@@ -453,7 +454,7 @@ msgid "Cancel search"
msgstr "Cancel search"
#: src/components/category/CategorySelect.tsx:33
#: src/pages/Manage.tsx:38
#: src/pages/Manage.tsx:42
msgid "Categories"
msgstr "Categories"
@@ -502,7 +503,8 @@ msgstr "Chat ({unreadChatCount} unread)"
msgid "Checking invite…"
msgstr "Checking invite…"
#: src/components/federation/PeerManager.tsx:154
#: src/components/federation/PeerManager.tsx:175
#: src/components/federation/PeerManager.tsx:189
msgid "Checking…"
msgstr "Checking…"
@@ -590,7 +592,7 @@ msgstr "Could not rename"
msgid "Could not save"
msgstr "Could not save"
#: src/components/federation/PeerManager.tsx:146
#: src/components/federation/PeerManager.tsx:164
msgid "Couldn't add that instance"
msgstr "Couldn't add that instance"
@@ -885,7 +887,7 @@ msgid "Failed to post reply"
msgstr "Failed to post reply"
#: src/components/federation/GrantList.tsx:98
#: src/components/federation/PeerManager.tsx:143
#: src/components/federation/PeerManager.tsx:158
#: src/components/playlist/PlaylistEditForm.tsx:185
#: src/components/profile/DescriptionEditor.tsx:99
#: src/components/profile/EmailEditor.tsx:86
@@ -1029,7 +1031,7 @@ msgstr "In the chat"
msgid "Insert emoji"
msgstr "Insert emoji"
#: src/pages/Manage.tsx:45
#: src/pages/Manage.tsx:49
msgid "Instances"
msgstr "Instances"
@@ -1145,7 +1147,7 @@ msgstr "Loading profile…"
#: src/components/category/CategoryManager.tsx:60
#: src/components/chat/ChatModal.tsx:763
#: src/components/editor/EmojiPickerPopup.tsx:125
#: src/components/federation/PeerManager.tsx:108
#: src/components/federation/PeerManager.tsx:120
#: src/components/playlist/PlaylistMembershipPanel.tsx:45
#: src/components/shared/ListState.tsx:38
#: src/components/shared/LoadMoreButton.tsx:32
@@ -1194,8 +1196,8 @@ msgid "Lowercase letters, numbers and single hyphens only"
msgstr "Lowercase letters, numbers and single hyphens only"
#: src/components/layout/UserMenu.tsx:114
#: src/pages/Manage.tsx:29
#: src/pages/Manage.tsx:36
#: src/pages/Manage.tsx:32
#: src/pages/Manage.tsx:40
#: src/pages/UserPublicProfile.tsx:164
msgid "Manage"
msgstr "Manage"
@@ -1538,8 +1540,8 @@ msgid "Related"
msgstr "Related"
#. placeholder {0}: peer.host
#: src/components/federation/PeerManager.tsx:133
#: src/components/federation/PeerManager.tsx:134
#: src/components/federation/PeerManager.tsx:148
#: src/components/federation/PeerManager.tsx:149
msgid "Remove {0}"
msgstr "Remove {0}"
@@ -1893,11 +1895,11 @@ msgstr "Top this week"
msgid "Track {0}"
msgstr "Track {0}"
#: src/components/federation/PeerManager.tsx:155
#: src/components/federation/PeerManager.tsx:176
msgid "Trust"
msgstr "Trust"
#: src/components/federation/PeerManager.tsx:32
#: src/components/federation/PeerManager.tsx:38
msgid "Trusted"
msgstr "Trusted"
@@ -2032,7 +2034,15 @@ msgstr "Username"
msgid "Users"
msgstr "Users"
#: src/components/federation/PeerManager.tsx:169
#: src/components/federation/PeerManager.tsx:198
msgid "Users here can't sign in to a blocked instance."
msgstr "Users here can't sign in to a blocked instance."
#: src/components/federation/PeerManager.tsx:201
msgid "Users of a trusted instance sign in here with their account there. Those of a blocked one can't, and users here can't sign in to it. Users of any other instance need an invite."
msgstr "Users of a trusted instance sign in here with their account there. Those of a blocked one can't, and users here can't sign in to it. Users of any other instance need an invite."
#: src/components/federation/PeerManager.tsx:208
msgid "Users of a trusted instance sign in here with their account there. Those of a blocked one can't. Users of any other instance need an invite."
msgstr "Users of a trusted instance sign in here with their account there. Those of a blocked one can't. Users of any other instance need an invite."

View File

@@ -354,7 +354,7 @@ msgstr "Déjà recommandé par {0} {1}"
msgid "Already have an account? <0>Log in</0>"
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
#: src/components/federation/PeerManager.tsx:149
#: src/components/federation/PeerManager.tsx:167
msgid "An instance's address, like music.example"
msgstr "L'adresse d'une instance, comme music.example"
@@ -395,11 +395,12 @@ msgstr "Retour à la connexion"
msgid "Back to the login page"
msgstr "Retour à la page de connexion"
#: src/components/federation/PeerManager.tsx:163
#: src/components/federation/PeerManager.tsx:184
#: src/components/federation/PeerManager.tsx:190
msgid "Block"
msgstr "Bloquer"
#: src/components/federation/PeerManager.tsx:33
#: src/components/federation/PeerManager.tsx:39
msgid "Blocked"
msgstr "Bloquée"
@@ -453,7 +454,7 @@ msgid "Cancel search"
msgstr "Annuler la recherche"
#: src/components/category/CategorySelect.tsx:33
#: src/pages/Manage.tsx:38
#: src/pages/Manage.tsx:42
msgid "Categories"
msgstr "Catégories"
@@ -502,7 +503,8 @@ msgstr "{unreadChatCount} messages non lus"
msgid "Checking invite…"
msgstr "Vérification de l'invitation…"
#: src/components/federation/PeerManager.tsx:154
#: src/components/federation/PeerManager.tsx:175
#: src/components/federation/PeerManager.tsx:189
msgid "Checking…"
msgstr "Vérification…"
@@ -590,7 +592,7 @@ msgstr "Renommage impossible"
msgid "Could not save"
msgstr "Sauvegarde impossible"
#: src/components/federation/PeerManager.tsx:146
#: src/components/federation/PeerManager.tsx:164
msgid "Couldn't add that instance"
msgstr "Impossible d'ajouter cette instance"
@@ -885,7 +887,7 @@ msgid "Failed to post reply"
msgstr "Impossible de publier la réponse"
#: src/components/federation/GrantList.tsx:98
#: src/components/federation/PeerManager.tsx:143
#: src/components/federation/PeerManager.tsx:158
#: src/components/playlist/PlaylistEditForm.tsx:185
#: src/components/profile/DescriptionEditor.tsx:99
#: src/components/profile/EmailEditor.tsx:86
@@ -1029,7 +1031,7 @@ msgstr "Dans la tribune"
msgid "Insert emoji"
msgstr "Insérer un emoji"
#: src/pages/Manage.tsx:45
#: src/pages/Manage.tsx:49
msgid "Instances"
msgstr "Instances"
@@ -1145,7 +1147,7 @@ msgstr "Chargement du profil…"
#: src/components/category/CategoryManager.tsx:60
#: src/components/chat/ChatModal.tsx:763
#: src/components/editor/EmojiPickerPopup.tsx:125
#: src/components/federation/PeerManager.tsx:108
#: src/components/federation/PeerManager.tsx:120
#: src/components/playlist/PlaylistMembershipPanel.tsx:45
#: src/components/shared/ListState.tsx:38
#: src/components/shared/LoadMoreButton.tsx:32
@@ -1194,8 +1196,8 @@ msgid "Lowercase letters, numbers and single hyphens only"
msgstr "Minuscules, chiffres et tirets simples seulement"
#: src/components/layout/UserMenu.tsx:114
#: src/pages/Manage.tsx:29
#: src/pages/Manage.tsx:36
#: src/pages/Manage.tsx:32
#: src/pages/Manage.tsx:40
#: src/pages/UserPublicProfile.tsx:164
msgid "Manage"
msgstr "Administration"
@@ -1538,8 +1540,8 @@ msgid "Related"
msgstr "Connexe"
#. placeholder {0}: peer.host
#: src/components/federation/PeerManager.tsx:133
#: src/components/federation/PeerManager.tsx:134
#: src/components/federation/PeerManager.tsx:148
#: src/components/federation/PeerManager.tsx:149
msgid "Remove {0}"
msgstr "Retirer {0}"
@@ -1893,11 +1895,11 @@ msgstr "Les plus votées de la semaine"
msgid "Track {0}"
msgstr "Piste {0}"
#: src/components/federation/PeerManager.tsx:155
#: src/components/federation/PeerManager.tsx:176
msgid "Trust"
msgstr "Approuver"
#: src/components/federation/PeerManager.tsx:32
#: src/components/federation/PeerManager.tsx:38
msgid "Trusted"
msgstr "Approuvée"
@@ -2032,7 +2034,15 @@ msgstr "Nom d'utilisateur"
msgid "Users"
msgstr "Utilisateurs"
#: src/components/federation/PeerManager.tsx:169
#: src/components/federation/PeerManager.tsx:198
msgid "Users here can't sign in to a blocked instance."
msgstr "Les utilisateurs d'ici ne peuvent pas se connecter à une instance bloquée."
#: src/components/federation/PeerManager.tsx:201
msgid "Users of a trusted instance sign in here with their account there. Those of a blocked one can't, and users here can't sign in to it. Users of any other instance need an invite."
msgstr "Les utilisateurs d'une instance approuvée se connectent ici avec leur compte de là-bas. Ceux d'une instance bloquée ne le peuvent pas, et les utilisateurs d'ici ne peuvent pas s'y connecter. Ceux de toute autre instance ont besoin d'une invitation."
#: src/components/federation/PeerManager.tsx:208
msgid "Users of a trusted instance sign in here with their account there. Those of a blocked one can't. Users of any other instance need an invite."
msgstr "Les utilisateurs d'une instance approuvée se connectent ici avec leur compte de là-bas. Ceux d'une instance bloquée ne le peuvent pas. Ceux de toute autre instance ont besoin d'une invitation."

View File

@@ -1,6 +1,8 @@
/**
* Renders the list of the other gerbeur instances an admin trusts or blocks,
* on `/manage`, for those who hold `federation:manage`.
* on `/manage`, for those who hold `federation:manage`. Where users of other
* instances don't sign in, only blocking is left: it keeps this instance's
* users from signing in to one.
*
* @module
*/
@@ -11,6 +13,10 @@ import { Trans } from "@lingui/react/macro";
import { FormProvider } from "react-hook-form";
import { API_URL } from "../../config/api.ts";
import {
FEDERATION_INCOMING,
FEDERATION_OUTGOING,
} from "../../config/instance.ts";
import type { Wire } from "../../../shared/api.ts";
import type {
FederationPeer,
@@ -42,6 +48,8 @@ interface Values {
* (`PUT /api/federation/peers`), and a button that takes it off the lists
* (`DELETE /api/federation/peers/:host`), then a field that adds one. The
* server checks that it answers as a gerbeur, and the permission again.
* Without {@link FEDERATION_INCOMING} it lists and adds only blocked
* instances; the trusted ones are kept for when it is back.
*/
export function PeerManager(): React.JSX.Element {
const { authFetch } = useAuth();
@@ -101,6 +109,10 @@ export function PeerManager(): React.JSX.Element {
form.reset();
});
const shown = FEDERATION_INCOMING
? peers
: peers?.filter((peer) => peer.status === "blocked") ?? null;
return (
<div className="peer-manager">
{peers === null && !error && (
@@ -108,9 +120,9 @@ export function PeerManager(): React.JSX.Element {
<Trans>Loading…</Trans>
</p>
)}
{peers && peers.length > 0 && (
{shown && shown.length > 0 && (
<div className="profile-appearance-grid">
{peers.map((peer) => (
{shown.map((peer) => (
<SettingRow
key={peer.issuer}
label={
@@ -121,11 +133,14 @@ export function PeerManager(): React.JSX.Element {
}
>
<span className="peer-controls">
<Segmented
options={STATUS_OPTIONS}
value={peer.status}
onChange={(status) => change(() => save(peer.issuer, status))}
/>
{FEDERATION_INCOMING && (
<Segmented
options={STATUS_OPTIONS}
value={peer.status}
onChange={(status) =>
change(() => save(peer.issuer, status))}
/>
)}
<button
type="button"
className="btn--ghost peer-remove"
@@ -142,7 +157,10 @@ export function PeerManager(): React.JSX.Element {
)}
{error && <ErrorCard title={t`Failed to save`} message={error} />}
<FormProvider {...form}>
<form className="form peer-add" onSubmit={add("trusted")}>
<form
className="form peer-add"
onSubmit={add(FEDERATION_INCOMING ? "trusted" : "blocked")}
>
<FormError title={t`Couldn't add that instance`} />
<TextField<Values>
name="instance"
@@ -151,26 +169,48 @@ export function PeerManager(): React.JSX.Element {
rules={{ required: true }}
/>
<div className="peer-add-actions">
<SubmitButton pendingLabel={<Trans>Checking…</Trans>}>
<Trans>Trust</Trans>
</SubmitButton>
<button
type="button"
className="btn-secondary"
disabled={form.formState.isSubmitting}
onClick={add("blocked")}
>
<Trans>Block</Trans>
</button>
{FEDERATION_INCOMING
? (
<>
<SubmitButton pendingLabel={<Trans>Checking…</Trans>}>
<Trans>Trust</Trans>
</SubmitButton>
<button
type="button"
className="btn-secondary"
disabled={form.formState.isSubmitting}
onClick={add("blocked")}
>
<Trans>Block</Trans>
</button>
</>
)
: (
<SubmitButton pendingLabel={<Trans>Checking…</Trans>}>
<Trans>Block</Trans>
</SubmitButton>
)}
</div>
</form>
</FormProvider>
<p className="form-hint">
<Trans>
Users of a trusted instance sign in here with their account there.
Those of a blocked one can't. Users of any other instance need an
invite.
</Trans>
{!FEDERATION_INCOMING
? <Trans>Users here can't sign in to a blocked instance.</Trans>
: FEDERATION_OUTGOING
? (
<Trans>
Users of a trusted instance sign in here with their account there.
Those of a blocked one can't, and users here can't sign in to it.
Users of any other instance need an invite.
</Trans>
)
: (
<Trans>
Users of a trusted instance sign in here with their account there.
Those of a blocked one can't. Users of any other instance need an
invite.
</Trans>
)}
</p>
</div>
);

View File

@@ -16,20 +16,24 @@ import { PageHeader } from "../components/layout/PageHeader.tsx";
import { PageGroup } from "../components/layout/PageGroup.tsx";
import { CategoryManager } from "../components/category/CategoryManager.tsx";
import { PeerManager } from "../components/federation/PeerManager.tsx";
import { FEDERATION_INCOMING } from "../config/instance.ts";
import {
FEDERATION_INCOMING,
FEDERATION_OUTGOING,
} from "../config/instance.ts";
import { NotFound } from "./NotFound.tsx";
/**
* Renders the category manager and the instances the site trusts or blocks,
* each to those who hold its permission, which the API checks again, the
* instances only while users of other instances may sign in here; without
* instances only while federated sign-in goes at least one way; without
* either, the page isn't found.
*/
export function Manage(): React.JSX.Element {
useDocumentTitle(t`Manage`);
const { user } = useRequiredAuth();
const categories = can(user, "category:manage");
const instances = FEDERATION_INCOMING && can(user, "federation:manage");
const instances = (FEDERATION_INCOMING || FEDERATION_OUTGOING) &&
can(user, "federation:manage");
if (!categories && !instances) return <NotFound />;
return (
<PageShell narrow>

View File

@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Manage } from "../../pages/Manage.tsx";
@@ -6,6 +6,20 @@ import { calls, fail, route } from "../fetch.ts";
import { makeCategory, makeSession, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
const instance = vi.hoisted(() => ({
federationIncoming: true,
federationOutgoing: true,
}));
vi.mock("../../config/instance.ts", async (importOriginal) => ({
...await importOriginal<typeof import("../../config/instance.ts")>(),
get FEDERATION_INCOMING() {
return instance.federationIncoming;
},
get FEDERATION_OUTGOING() {
return instance.federationOutgoing;
},
}));
const admin = makeSession({ id: "ann", username: "ann", role: "admin" });
function open(session = admin) {
@@ -16,6 +30,11 @@ function open(session = admin) {
}
describe("Manage", () => {
afterEach(() => {
instance.federationIncoming = true;
instance.federationOutgoing = true;
});
test("an admin manages categories", async () => {
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
let list = [music];
@@ -128,6 +147,52 @@ describe("Manage", () => {
);
});
test("where users of other instances don't sign in, an admin only blocks instances", async () => {
instance.federationIncoming = false;
route("GET /api/categories", () => []);
const peer = (name: string, host: string, status: string) => ({
issuer: `https://${host}`,
host,
name,
status,
createdAt: new Date().toISOString(),
});
route("GET /api/federation/peers", () => [
peer("Films", "films.example", "trusted"),
peer("Spam", "spam.example", "blocked"),
]);
route(
"PUT /api/federation/peers",
() => peer("Music", "music.example", "blocked"),
);
const user = open();
expect(await screen.findByText("Spam")).toBeInTheDocument();
expect(screen.queryByText("Films")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Trust" })).not
.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Blocked" })).not
.toBeInTheDocument();
await user.type(
screen.getByPlaceholderText("An instance's address, like music.example"),
"music.example{Enter}",
);
await waitFor(() =>
expect(calls("PUT /api/federation/peers")[0]?.body).toEqual({
instance: "music.example",
status: "blocked",
})
);
});
test("without federated sign-in either way, there are no instances to manage", async () => {
instance.federationIncoming = false;
instance.federationOutgoing = false;
route("GET /api/categories", () => []);
open();
expect(await screen.findByText("Categories")).toBeInTheDocument();
expect(screen.queryByText("Instances")).not.toBeInTheDocument();
});
test("without category:manage, the page isn't found", async () => {
open(makeSession({ id: "mod", role: "moderator" }));
expect(await screen.findByText("This page does not exist."))