diff --git a/.env.example b/.env.example index 11bd7f60..7270bc08 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,12 @@ GERBEUR_DEFAULT_STYLE=smooth # How often, in minutes, the /community page's figures are recomputed. GERBEUR_COMMUNITY_REFRESH_MINUTES=15 +# Set to 0 to keep users of other gerbeur instances from signing in here, which +# also signs out those already in until it is set back, or to keep this +# instance's users from signing in to others, which signs them out there. +# GERBEUR_FEDERATION_INCOMING=1 +# GERBEUR_FEDERATION_OUTGOING=1 + # Set to 1 to let federated sign-in reach other instances on private addresses # (localhost included) and over plain http, so that two instances on one # machine can trust each other in development. Never in production. diff --git a/API.md b/API.md index fdbd4e48..4974a1a6 100644 --- a/API.md +++ b/API.md @@ -211,7 +211,9 @@ there: those endpoints answer it 400. Federated sign-in (`shared/federation.ts`): a user signs in here with their account on another gerbeur instance, their home, by OpenID Connect's authorization code flow with PKCE, with tokens signed by each instance's Ed25519 -key. +key. With `GERBEUR_FEDERATION_INCOMING=0`, `POST /api/federation/logins` and +`/sessions` answer 403; with `GERBEUR_FEDERATION_OUTGOING=0`, +`GET /api/oauth/clients` and `POST /api/oauth/codes` do. - `GET /.well-known/openid-configuration`, `/.well-known/jwks.json` — anyone. The instance's metadata and public keys, bare JSON: the one exception to the diff --git a/README.md b/README.md index edeb425f..de349913 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ variables: | `GERBEUR_EMAIL_LOCALE` | Language of every email: `en` or `fr` | `en` | | `GERBEUR_DEFAULT_STYLE` | The instance's style (`smooth`, `brutalist`, `geocities`, `nyt`, `kanagawa`, `melange`): the site's until one is picked, and the welcome and reset emails' | `smooth` | | `GERBEUR_WELCOME_EMAIL_BODY` | Markdown body for the account-creation welcome email; supports `{{username}}` and `{{site_name}}` | built-in template | +| `GERBEUR_FEDERATION_INCOMING` | `0` keeps users of other instances from signing in here, and signs out those already in | `1` | +| `GERBEUR_FEDERATION_OUTGOING` | `0` keeps this instance's users from signing in to other instances, and signs them out there | `1` | | `GERBEUR_FEDERATION_ALLOW_PRIVATE` | `1` lets federated sign-in reach instances on private addresses and over http — for two instances on one machine, never in production | unset | | `GERBEUR_COMMUNITY_REFRESH_MINUTES` | How often, in minutes, the `/community` page's figures are recomputed | `15` | | `VITE_API_PROTOCOL` | API protocol baked into the frontend bundle (see [Production](#production)) | `http` | @@ -150,7 +152,10 @@ home instance, and the others follow its changes. Every instance is ready for it: nothing to configure, as long as `GERBEUR_PUBLIC_URL` is the instance's https origin (no path) and the instances -can reach each other. An admin decides who comes in, on `/manage`: +can reach each other. Either way can be turned off: +`GERBEUR_FEDERATION_INCOMING=0` takes no users of other instances, and +`GERBEUR_FEDERATION_OUTGOING=0` lets no user sign in elsewhere. An admin decides +who comes in, on `/manage`: - users of a **trusted** instance sign in directly, from a button on the login page; @@ -160,7 +165,8 @@ can reach each other. An admin decides who comes in, on `/manage`: `deno task dev` runs several instances on one machine (see [Run](#run)), with `GERBEUR_FEDERATION_ALLOW_PRIVATE=1`, which lets them reach each other over -plain http on localhost. Never set it in production. +plain http on localhost. Never set it in production. The first, `gerbeur`, takes +no users of the others, whose users sign in to each other. ## Project structure diff --git a/api/config.ts b/api/config.ts index 12f576d6..7bb9d3d4 100644 --- a/api/config.ts +++ b/api/config.ts @@ -232,3 +232,26 @@ export const ALLOWED_ORIGINS: string[] = Array.from( */ export const FEDERATION_ALLOW_PRIVATE: boolean = Deno.env.get("GERBEUR_FEDERATION_ALLOW_PRIVATE")?.trim() === "1"; +/** + * Which ways federated sign-in goes, each on unless set to `0`. + * + * - `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. + * - `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 + * anyone here to sign in. The settings page lists no instances, since at + * startup `main.ts` withdraws every grant they gave, which signs them out + * there. + * + * index.html tells the client both. An object, so that the tests can turn one + * off for a while. + */ +export const FEDERATION: { incoming: boolean; outgoing: boolean } = { + incoming: Deno.env.get("GERBEUR_FEDERATION_INCOMING")?.trim() !== "0", + outgoing: Deno.env.get("GERBEUR_FEDERATION_OUTGOING")?.trim() !== "0", +}; diff --git a/api/lib/index-html.ts b/api/lib/index-html.ts index c211554e..785b045d 100644 --- a/api/lib/index-html.ts +++ b/api/lib/index-html.ts @@ -10,6 +10,7 @@ import { BANDCAMP_PLAYER, DEFAULT_STYLE, EMAIL_ENABLED, + FEDERATION, OG_SITE_NAME, SITE_EMOJI, } from "../config.ts"; @@ -28,7 +29,8 @@ const indexHtml = new Map(); /** * Returns `${root}/index.html` with the site name, emoji, icon version, - * Bandcamp player mode, default style and whether email is on filled in. + * Bandcamp player mode, default style, and whether email and each way of + * federated sign-in are on filled in. * * @throws {Error} When the file can't be read. */ @@ -43,7 +45,9 @@ export async function loadIndexHtml(root: string): Promise { .replaceAll("__ICON_VERSION__", ICON_VERSION) .replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER) .replaceAll("__DEFAULT_STYLE__", DEFAULT_STYLE) - .replaceAll("__EMAIL_ENABLED__", EMAIL_ENABLED ? "on" : "off"); + .replaceAll("__EMAIL_ENABLED__", EMAIL_ENABLED ? "on" : "off") + .replaceAll("__FEDERATION_INCOMING__", FEDERATION.incoming ? "on" : "off") + .replaceAll("__FEDERATION_OUTGOING__", FEDERATION.outgoing ? "on" : "off"); indexHtml.set(root, { mtime, html }); return html; } diff --git a/api/main.ts b/api/main.ts index a72708eb..11eb1b20 100644 --- a/api/main.ts +++ b/api/main.ts @@ -1,6 +1,8 @@ /** * Builds the Oak application and, when run directly, starts the recurring - * jobs and listens on {@link LISTEN_HOST}:{@link PORT}. A request goes + * jobs and listens on {@link LISTEN_HOST}:{@link PORT}, having first signed + * the users out of other instances if they may no longer sign in there + * (`FEDERATION.outgoing`). A request goes * through the error middleware, the OG tags and CORS, then the routers, one * per resource of the API (`API.md` lists them), and last the built frontend, * which answers every path no router did. @@ -37,7 +39,13 @@ import usersRouter from "./routes/users.ts"; import wellKnownRouter from "./routes/well-known.ts"; import wsRouter from "./routes/ws.ts"; -import { ALLOWED_ORIGINS, LISTEN_HOST, PORT, PUBLIC_URL } from "./config.ts"; +import { + ALLOWED_ORIGINS, + FEDERATION, + LISTEN_HOST, + PORT, + PUBLIC_URL, +} from "./config.ts"; import { errorMiddleware } from "./middleware/error.ts"; import { ogMiddleware } from "./middleware/og.ts"; import { routeStaticFilesFrom } from "./lib/static.ts"; @@ -46,7 +54,10 @@ import { rateLimitSweepJob } from "./lib/rate-limit.ts"; import { digestJob } from "./services/digest-service.ts"; import { communityJob } from "./services/community-service.ts"; import { pingJob, typingExpiryJob } from "./services/ws-service.ts"; -import { outboxJob } from "./services/identity-provider-service.ts"; +import { + outboxJob, + withdrawAllGrants, +} from "./services/identity-provider-service.ts"; /** * The whole server, exported so the tests' helpers can send it requests @@ -115,6 +126,12 @@ app.addEventListener( ); if (import.meta.main) { + if (!FEDERATION.outgoing) { + const withdrawn = await withdrawAllGrants(); + if (withdrawn > 0) { + console.log(`Signed users out of other instances (${withdrawn} grants)`); + } + } startJobs([ digestJob, communityJob, diff --git a/api/services/federation-service.ts b/api/services/federation-service.ts index 48ae0875..75a99a37 100644 --- a/api/services/federation-service.ts +++ b/api/services/federation-service.ts @@ -16,7 +16,8 @@ * Whether a user of another instance may join is the admin's call * (`federation-peer-service.ts`): from a trusted instance they may, from a * blocked one they may not, and from any other only with an invite, which the - * register page carries. Once here, an account works like any other, but its + * register page carries. None may while the deployment turns sign-ins from + * other instances off (`FEDERATION.incoming`). Once here, an account works like any other, but its * password, email, avatar and name are its home's (`assertLocalUser`). * * @module @@ -25,6 +26,7 @@ import { decodeJwt, type JSONWebKeySet } from "@panva/jose"; import { createHash, randomBytes } from "node:crypto"; import { APIException } from "../lib/api-exception.ts"; +import { FEDERATION } from "../config.ts"; import { db, transaction } from "../db/connection.ts"; import { verifyInstanceToken } from "../lib/jwt.ts"; import { type RateLimit, takeToken } from "../lib/rate-limit.ts"; @@ -33,6 +35,7 @@ import { type FederationSessionRequest, type IdentityClaims, instanceHost, + type InstanceMetadata, isFederationEvent, isIdentityClaims, parseInstance, @@ -73,6 +76,22 @@ const LOGIN_TTL_MS = 10 * 60_000; */ const EVENT_LIMIT: RateLimit = { capacity: 120, refill: 1 }; +/** + * Refuses a sign-in from another instance while the deployment turns them off + * (`FEDERATION.incoming`), as {@link startLogin} and {@link completeLogin} + * must first. + * + * @throws {APIException} 403 when they are off. + */ +function assertIncoming(): void { + if (!FEDERATION.incoming) { + throw new APIException( + 403, + "This instance doesn't take accounts from other instances", + ); + } +} + /** * Returns the origin of the instance `instance` names, checked as a home: not * this instance, nor a blocked one. @@ -98,13 +117,15 @@ function homeOf(instance: string): string { * claimed only once the account is made; {@link completeLogin} makes no * account without one. * - * @throws {APIException} 400 when the instance isn't one; 403 when it is - * blocked, or neither trusted nor invited; 404 or 409 when the invite doesn't - * work; 502 when it doesn't answer as a gerbeur. + * @throws {APIException} 400 when the instance isn't one; 403 when sign-ins + * from other instances are off, or it is blocked, neither trusted nor + * invited, or keeps its users from signing in elsewhere; 404 or 409 when the + * invite doesn't work; 502 when it doesn't answer as a gerbeur. */ export async function startLogin( request: FederationLoginRequest, ): Promise { + assertIncoming(); const issuer = homeOf(request.instance); if (request.inviteToken) { validateInvite(request.inviteToken); @@ -121,14 +142,19 @@ export async function startLogin( ); } - let authorizationEndpoint: string; + let metadata: InstanceMetadata; try { - authorizationEndpoint = (await fetchMetadata(issuer)) - .authorization_endpoint; + metadata = await fetchMetadata(issuer); } catch (err) { console.error(`[federation] ${issuer} unreachable:`, err); throw new APIException(502, "That instance doesn't answer as a gerbeur"); } + if (metadata.gerbeur_outgoing === false) { + throw new APIException( + 403, + "Accounts on that instance don't sign in to other instances", + ); + } const state = randomBytes(24).toString("base64url"); const verifier = randomBytes(48).toString("base64url"); @@ -151,7 +177,7 @@ export async function startLogin( ); }); - const url = new URL(authorizationEndpoint); + const url = new URL(metadata.authorization_endpoint); url.search = new URLSearchParams({ response_type: "code", scope: "openid profile email", @@ -244,12 +270,14 @@ function checkClaims(claims: unknown, issuer: string): IdentityClaims | null { * login was started with, claimed now. * * @throws {APIException} 400 when the login is unknown or expired; 401 when - * the identity token doesn't verify; 403 when the account may not be created; - * 409 when its handle is someone's; 502 when the home fails. + * the identity token doesn't verify; 403 when sign-ins from other instances + * are off, or the account may not be created; 409 when its handle is + * someone's; 502 when the home fails. */ export async function completeLogin( request: FederationSessionRequest, ): Promise { + assertIncoming(); const login = db.prepare( `DELETE FROM federation_logins WHERE state = ? RETURNING issuer, code_verifier, nonce, invite_token, created_at;`, diff --git a/api/services/identity-provider-service.ts b/api/services/identity-provider-service.ts index f6d8c0f6..9ce7ece4 100644 --- a/api/services/identity-provider-service.ts +++ b/api/services/identity-provider-service.ts @@ -28,6 +28,7 @@ import { APIException } from "../lib/api-exception.ts"; import { db, transaction } from "../db/connection.ts"; import { AVATARS_DIR, + FEDERATION, FRONTEND_URL, OG_SITE_NAME, PUBLIC_URL, @@ -102,6 +103,7 @@ export function getInstanceMetadata(): InstanceMetadata { gerbeur_site_name: OG_SITE_NAME, gerbeur_redirect_uri: REDIRECT_URI, gerbeur_events_endpoint: `${PUBLIC_URL}/api/federation/events`, + gerbeur_outgoing: FEDERATION.outgoing, }; } @@ -143,16 +145,24 @@ export function identityClaims(user: User): IdentityClaims { /** * Returns the client `clientId` names, checked: an instance other than this * one, not blocked here, answering as a gerbeur. `user` must have a local - * account, which alone may sign in elsewhere. + * account, which alone may sign in elsewhere, and this instance must let its + * users sign in elsewhere (`FEDERATION.outgoing`). * * @throws {APIException} 400 when `clientId` isn't an instance's address, is - * this instance, or `user` comes from another instance; 403 when the client - * is blocked here; 502 when it doesn't answer as a gerbeur. + * this instance, or `user` comes from another instance; 403 when this + * instance's users don't sign in elsewhere, or the client is blocked here; + * 502 when it doesn't answer as a gerbeur. */ async function checkClient( user: User, clientId: string, ): Promise<{ clientId: string; metadata: InstanceMetadata }> { + if (!FEDERATION.outgoing) { + throw new APIException( + 403, + "Accounts on this instance don't sign in to other instances", + ); + } if (isRemoteUser(user)) { throw new APIException( 400, @@ -362,6 +372,24 @@ export async function revokeGrant(userId: string, host: string): Promise { ).run(userId, grant.clientId); } +/** + * Withdraws every grant, as each user would theirs, and drops the codes not + * yet traded, so that no account here stays signed in elsewhere: `main.ts` + * calls it at startup when `FEDERATION.outgoing` is off. Returns how many + * grants it withdrew. + */ +export async function withdrawAllGrants(): Promise { + db.prepare(`DELETE FROM oauth_codes;`).run(); + const grants = db.prepare( + `SELECT user_id, client_id FROM federation_grants;`, + ).all() as { user_id: string; client_id: string }[]; + for (const { user_id, client_id } of grants) { + await notifyClients(user_id, { type: "revoke" }, [client_id]); + } + db.prepare(`DELETE FROM federation_grants;`).run(); + return grants.length; +} + /** * Tells each instance user `userId` signed in to of `event`, or only those of * `clientIds`: `update` when their name, email or avatar changed, `revoke` diff --git a/api/services/user-service.ts b/api/services/user-service.ts index 9adf36f1..288c01b5 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -31,7 +31,7 @@ import { updateClientAvatar, } from "./ws-service.ts"; import { replaceUploadedFile, validateImageUpload } from "../lib/upload.ts"; -import { AVATARS_DIR } from "../config.ts"; +import { AVATARS_DIR, FEDERATION } from "../config.ts"; import { hashPassword } from "../lib/password.ts"; import { linkAttachments } from "./attachment-service.ts"; @@ -187,14 +187,15 @@ export function getUserById(userId: string): User { /** * Returns the user's live role and their last password change, before which * their tokens don't count, or undefined when the user is gone, or comes from - * an instance an admin blocked. `lib/auth.ts` checks every session and media - * token against it. + * another instance while an admin blocks it or the deployment turns sign-ins + * from other instances off (`FEDERATION.incoming`). `lib/auth.ts` checks + * every session and media token against it. */ export function getSessionState( userId: string, ): { role: Role; tokensValidAfter: string | null } | undefined { const row = db.prepare( - `SELECT u.role, u.tokens_valid_after, p.status AS peer_status + `SELECT u.role, u.tokens_valid_after, ui.issuer, p.status AS peer_status FROM users u LEFT JOIN user_identities ui ON ui.user_id = u.id LEFT JOIN federation_peers p ON p.issuer = ui.issuer @@ -203,10 +204,12 @@ export function getSessionState( | { role: Role; tokens_valid_after: string | null; + issuer: string | null; peer_status: string | null; } | undefined; if (!row || row.peer_status === "blocked") return undefined; + if (row.issuer && !FEDERATION.incoming) return undefined; return { role: row.role, tokensValidAfter: row.tokens_valid_after, diff --git a/api/test/helpers.ts b/api/test/helpers.ts index b6309390..7a9bfaa8 100644 --- a/api/test/helpers.ts +++ b/api/test/helpers.ts @@ -19,6 +19,7 @@ import { canonicalizeUrl } from "../lib/canonical-url.ts"; import { clearRichContentCache } from "../services/rich-content-service.ts"; import { clearImageCache } from "../remote/image-proxy.ts"; import { clearInstanceCaches } from "../remote/gerbeur-instance.ts"; +import { FEDERATION } from "../config.ts"; import { type OutgoingEmail, setEmailTransport, @@ -464,8 +465,14 @@ export interface FakeInstance { handle(url: URL, init?: RequestInit): Promise | null; } -/** Makes a {@link FakeInstance} at `origin` with a fresh key. */ -export async function fakeInstance(origin: string): Promise { +/** + * Makes a {@link FakeInstance} at `origin` with a fresh key, its metadata + * with `metadata` over the defaults. + */ +export async function fakeInstance( + origin: string, + metadata: Record = {}, +): Promise { const { kid, privateJwk, publicJwk } = await generateInstanceKey(); const key = await importInstanceKey(kid, privateJwk); const codes = new Map< @@ -496,6 +503,7 @@ export async function fakeInstance(origin: string): Promise { gerbeur_site_name: `Fake ${new URL(origin).host}`, gerbeur_redirect_uri: `${origin}/federation/callback`, gerbeur_events_endpoint: `${origin}/api/federation/events`, + ...metadata, }); case "/.well-known/jwks.json": return json({ keys: [publicJwk] }); @@ -564,6 +572,20 @@ export async function fakeInstance(origin: string): Promise { }; } +/** + * Turns one way of federated sign-in off for the life of the returned handle + * (`using _off = federationOff("incoming")`), as `GERBEUR_FEDERATION_*=0` + * would for the server's life. + */ +export function federationOff(way: keyof typeof FEDERATION): Disposable { + FEDERATION[way] = false; + return { + [Symbol.dispose]() { + FEDERATION[way] = true; + }, + }; +} + // ── Email ──────────────────────────────────────────────────────────────────── /** diff --git a/api/test/lib/index-html.test.ts b/api/test/lib/index-html.test.ts index b5917446..84636c46 100644 --- a/api/test/lib/index-html.test.ts +++ b/api/test/lib/index-html.test.ts @@ -8,11 +8,11 @@ Deno.test("loadIndexHtml: fills the placeholders, and rereads a rebuilt file", a const path = `${root}/index.html`; await Deno.writeTextFile( path, - "__SITE_NAME____BANDCAMP_PLAYER__ __DEFAULT_STYLE__ __EMAIL_ENABLED__", + "__SITE_NAME____BANDCAMP_PLAYER__ __DEFAULT_STYLE__ __EMAIL_ENABLED__ __FEDERATION_INCOMING__ __FEDERATION_OUTGOING__", ); assertEquals( await loadIndexHtml(root), - "gerbeurembed smooth off", + "gerbeurembed smooth off on on", ); await Deno.writeTextFile(path, "rebuilt __SITE_NAME__"); diff --git a/api/test/services/federation-service.test.ts b/api/test/services/federation-service.test.ts index 67d3691f..dec2f57f 100644 --- a/api/test/services/federation-service.test.ts +++ b/api/test/services/federation-service.test.ts @@ -1,9 +1,11 @@ import { + count, createUser, db, del, type FakeInstance, fakeInstance, + federationOff, formWithFile, get, patch, @@ -193,6 +195,48 @@ test("federation: blocking an instance refuses its users and ends their sessions assertEquals((await get("/api/me", { auth })).status, 200); }); +test("federation: with sign-ins from other instances off, none starts or finishes, and those in are out until it is on", async () => { + const home = await fakeInstance(HOME); + using _net = network(home); + await trust(HOME); + const { token } = (await signIn(home, ALICE)).data as AuthResponse; + const auth = { Authorization: `Bearer ${token}` }; + const started = await post( + "/api/federation/logins", + { json: { instance: "home.test" } }, + ); + const state = new URL(started.data.authorizeUrl).searchParams.get("state"); + const code = home.issue(started.data.authorizeUrl, ALICE); + + { + using _off = federationOff("incoming"); + assertEquals((await get("/api/me", { auth })).status, 401); + assertEquals( + (await post("/api/federation/logins", { + json: { instance: "home.test" }, + })).status, + 403, + ); + assertEquals( + (await post("/api/federation/sessions", { json: { code, state } })) + .status, + 403, + ); + } + assertEquals((await get("/api/me", { auth })).status, 200); +}); + +test("federation: a home whose users don't sign in elsewhere is refused before the user leaves", async () => { + const home = await fakeInstance(HOME, { gerbeur_outgoing: false }); + using _net = network(home); + await trust(HOME); + const start = await post("/api/federation/logins", { + json: { instance: "home.test" }, + }); + assertEquals(start.status, 403); + assertEquals(count(`SELECT COUNT(*) AS n FROM federation_logins;`), 0); +}); + test("federation: only admins edit the peers, which must answer as gerbeurs", async () => { const home = await fakeInstance(HOME); using _net = network(home); diff --git a/api/test/services/identity-provider-service.test.ts b/api/test/services/identity-provider-service.test.ts index 317f6f75..6ad8416b 100644 --- a/api/test/services/identity-provider-service.test.ts +++ b/api/test/services/identity-provider-service.test.ts @@ -6,6 +6,7 @@ import { del, type FakeInstance, fakeInstance, + federationOff, formWithFile, get, patch, @@ -25,6 +26,7 @@ import { import { deliverOutbox, identityClaims, + withdrawAllGrants, } from "../../services/identity-provider-service.ts"; import { getUserById } from "../../services/user-service.ts"; import type { @@ -105,6 +107,7 @@ test("identity provider: metadata and keys are served at the well-known paths", assertEquals(body.authorization_endpoint, `${BASE_URL}/authorize`); assertEquals(body.gerbeur_redirect_uri, `${BASE_URL}/federation/callback`); assertEquals(body.gerbeur_federation, 1); + assertEquals(body.gerbeur_outgoing, true); const jwks = await get("/.well-known/jwks.json"); const keys = JSON.parse(new TextDecoder().decode(jwks.bytes)).keys; assertEquals(keys[0].crv, "Ed25519"); @@ -260,6 +263,40 @@ test("identity provider: the instances a user signed in to hear of their changes ); }); +test("identity provider: with sign-ins elsewhere off, no instance signs the users in, and their grants are withdrawn", async () => { + const fake = await fakeInstance(CLIENT); + using _net = network(fake); + const user = await createUser(); + const { verifier, challenge } = pkce(); + const pending = new URL( + (await authorize(user.auth, challenge)).data.redirectTo, + ).searchParams.get("code")!; + + using _off = federationOff("outgoing"); + const meta = await get("/.well-known/openid-configuration"); + assertEquals( + (JSON.parse(new TextDecoder().decode(meta.bytes)) as InstanceMetadata) + .gerbeur_outgoing, + false, + ); + assertEquals( + (await get(`/api/oauth/clients?client_id=${encodeURIComponent(CLIENT)}`, { + auth: user.auth, + })).status, + 403, + ); + assertEquals((await authorize(user.auth, challenge)).status, 403); + + assertEquals(await withdrawAllGrants(), 1); + await until(() => fake.received.length === 1); + assertEquals(decodeJwt(fake.received[0]).type, "revoke"); + assertEquals((await trade(pending, verifier)).status, 400); + assertEquals( + (await get("/api/me/grants", { auth: user.auth })).data, + [], + ); +}); + test("identity provider: an event an instance fails to take waits in the outbox", async () => { const fake = await fakeInstance(CLIENT); using _net = network(fake, () => new Response("", { status: 503 })); diff --git a/dev/instances.ts b/dev/instances.ts index 6f8f81aa..0047cc03 100644 --- a/dev/instances.ts +++ b/dev/instances.ts @@ -34,17 +34,25 @@ export interface DevInstance { webPort: number; /** * `GERBEUR_*` settings of its own, over the base `.env`: a site name, an - * emoji or a style, to tell the instances apart. + * emoji or a style, to tell the instances apart, or which ways federated + * sign-in goes. */ env?: Record; } /** * The instances `deno task dev` starts, in this order. The first keeps the - * ports the single dev server always had. + * ports the single dev server always had, and takes no users of the others, + * while its own sign in to them, so that both switches of federated sign-in + * can be tried. */ export const INSTANCES: readonly DevInstance[] = [ - { name: "gerbeur", apiPort: 8000, webPort: 3000 }, + { + name: "gerbeur", + apiPort: 8000, + webPort: 3000, + env: { GERBEUR_FEDERATION_INCOMING: "0" }, + }, { name: "musique", apiPort: 8001, diff --git a/index.html b/index.html index 11a2dc3b..ee470f12 100644 --- a/index.html +++ b/index.html @@ -16,6 +16,8 @@ + + __SITE_NAME__ diff --git a/locales/en.po b/locales/en.po index 9395ef32..1bc40909 100644 --- a/locales/en.po +++ b/locales/en.po @@ -51,7 +51,7 @@ msgstr "{0} more online: {restNames}" #. placeholder {0}: VALIDATION.USERNAME_MIN #. placeholder {1}: VALIDATION.USERNAME_MAX -#: src/pages/UserRegister.tsx:147 +#: src/pages/UserRegister.tsx:149 msgid "{0}–{1} characters: letters, numbers, or underscores" msgstr "{0}–{1} characters: letters, numbers, or underscores" @@ -350,7 +350,7 @@ msgstr "All" msgid "Already dumped by {0} {1}" msgstr "Already dumped by {0} {1}" -#: src/pages/UserRegister.tsx:179 +#: src/pages/UserRegister.tsx:181 msgid "Already have an account? <0>Log in" msgstr "Already have an account? <0>Log in" @@ -374,7 +374,7 @@ msgstr "Appearance" #. placeholder {0}: VALIDATION.PASSWORD_MIN #: src/components/profile/ChangePasswordModal.tsx:101 #: src/pages/ResetPassword.tsx:124 -#: src/pages/UserRegister.tsx:166 +#: src/pages/UserRegister.tsx:168 msgid "At least {0} characters" msgstr "At least {0} characters" @@ -391,7 +391,7 @@ msgstr "Auto" msgid "Back to login" msgstr "Back to login" -#: src/pages/FederationCallback.tsx:71 +#: src/pages/FederationCallback.tsx:72 msgid "Back to the login page" msgstr "Back to the login page" @@ -453,7 +453,7 @@ msgid "Cancel search" msgstr "Cancel search" #: src/components/category/CategorySelect.tsx:33 -#: src/pages/Manage.tsx:36 +#: src/pages/Manage.tsx:38 msgid "Categories" msgstr "Categories" @@ -498,7 +498,7 @@ msgstr "Chat" msgid "Chat ({unreadChatCount} unread)" msgstr "Chat ({unreadChatCount} unread)" -#: src/pages/UserRegister.tsx:108 +#: src/pages/UserRegister.tsx:110 msgid "Checking invite…" msgstr "Checking invite…" @@ -838,7 +838,7 @@ msgstr "Editing {0}" msgid "Email" msgstr "Email" -#: src/pages/UserRegister.tsx:154 +#: src/pages/UserRegister.tsx:156 msgid "Email address" msgstr "Email address" @@ -958,7 +958,7 @@ msgstr "Following" msgid "email.digest.forYou" msgstr "For you" -#: src/pages/UserLogin.tsx:101 +#: src/pages/UserLogin.tsx:103 msgid "Forgot password?" msgstr "Forgot password?" @@ -997,7 +997,7 @@ msgstr "Hi {username}," msgid "Hot" msgstr "Hot" -#: src/pages/UserLogin.tsx:142 +#: src/pages/UserLogin.tsx:144 msgid "If that address is registered you'll receive a reset link shortly." msgstr "If that address is registered you'll receive a reset link shortly." @@ -1029,11 +1029,11 @@ msgstr "In the chat" msgid "Insert emoji" msgstr "Insert emoji" -#: src/pages/Manage.tsx:43 +#: src/pages/Manage.tsx:45 msgid "Instances" msgstr "Instances" -#: src/pages/UserRegister.tsx:119 +#: src/pages/UserRegister.tsx:121 msgid "Invalid invite" msgstr "Invalid invite" @@ -1156,9 +1156,9 @@ msgstr "Loading…" #: src/components/layout/AppHeader.tsx:208 #: src/pages/FederationCallback.tsx:32 -#: src/pages/UserLogin.tsx:44 -#: src/pages/UserLogin.tsx:71 -#: src/pages/UserLogin.tsx:90 +#: src/pages/UserLogin.tsx:46 +#: src/pages/UserLogin.tsx:73 +#: src/pages/UserLogin.tsx:92 msgid "Log in" msgstr "Log in" @@ -1180,12 +1180,12 @@ msgstr "Log in with {0}" msgid "Log out" msgstr "Log out" -#: src/pages/UserLogin.tsx:89 +#: src/pages/UserLogin.tsx:91 msgid "Logging in…" msgstr "Logging in…" -#: src/pages/FederationCallback.tsx:68 -#: src/pages/UserLogin.tsx:76 +#: src/pages/FederationCallback.tsx:69 +#: src/pages/UserLogin.tsx:78 msgid "Login failed" msgstr "Login failed" @@ -1194,8 +1194,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:27 -#: src/pages/Manage.tsx:34 +#: src/pages/Manage.tsx:29 +#: src/pages/Manage.tsx:36 #: src/pages/UserPublicProfile.tsx:164 msgid "Manage" msgstr "Manage" @@ -1412,12 +1412,12 @@ msgstr "Page not found" #: src/pages/Settings.tsx:96 #: src/pages/Settings.tsx:106 -#: src/pages/UserLogin.tsx:86 +#: src/pages/UserLogin.tsx:88 msgid "Password" msgstr "Password" #. placeholder {0}: VALIDATION.PASSWORD_MIN -#: src/pages/UserRegister.tsx:160 +#: src/pages/UserRegister.tsx:162 msgid "Password (min. {0} characters)" msgstr "Password (min. {0} characters)" @@ -1519,17 +1519,17 @@ msgstr "Refreshing stream…" msgid "Refreshing…" msgstr "Refreshing…" -#: src/pages/UserRegister.tsx:47 -#: src/pages/UserRegister.tsx:131 -#: src/pages/UserRegister.tsx:171 +#: src/pages/UserRegister.tsx:49 +#: src/pages/UserRegister.tsx:133 +#: src/pages/UserRegister.tsx:173 msgid "Register" msgstr "Register" -#: src/pages/UserRegister.tsx:170 +#: src/pages/UserRegister.tsx:172 msgid "Registering…" msgstr "Registering…" -#: src/pages/UserRegister.tsx:136 +#: src/pages/UserRegister.tsx:138 msgid "Registration failed" msgstr "Registration failed" @@ -1581,7 +1581,7 @@ msgstr "Reply" msgid "Replying to" msgstr "Replying to" -#: src/pages/UserLogin.tsx:154 +#: src/pages/UserLogin.tsx:156 msgid "Request failed" msgstr "Request failed" @@ -1688,12 +1688,12 @@ msgstr "Seek" msgid "Send" msgstr "Send" -#: src/pages/UserLogin.tsx:163 +#: src/pages/UserLogin.tsx:165 msgid "Send reset link" msgstr "Send reset link" #: src/components/chat/PendingItem.tsx:104 -#: src/pages/UserLogin.tsx:162 +#: src/pages/UserLogin.tsx:164 msgid "Sending…" msgstr "Sending…" @@ -1730,7 +1730,7 @@ msgid "Sign in to {name}?" msgstr "Sign in to {name}?" #: src/pages/Authorize.tsx:154 -#: src/pages/FederationCallback.tsx:78 +#: src/pages/FederationCallback.tsx:79 msgid "Signing you in…" msgstr "Signing you in…" @@ -1818,11 +1818,11 @@ msgstr "The new order couldn't be saved. Try again." msgid "This comment couldn't be deleted. Try again." msgstr "This comment couldn't be deleted. Try again." -#: src/pages/UserRegister.tsx:120 +#: src/pages/UserRegister.tsx:122 msgid "This invite link is missing, expired, or already used." msgstr "This invite link is missing, expired, or already used." -#: src/pages/UserLogin.tsx:110 +#: src/pages/UserLogin.tsx:112 msgid "This is a mirage." msgstr "This is a mirage." @@ -1838,11 +1838,11 @@ msgstr "This page does not exist." msgid "This reset link is missing or malformed." msgstr "This reset link is missing or malformed." -#: src/pages/FederationCallback.tsx:39 +#: src/pages/FederationCallback.tsx:40 msgid "This sign-in link is incomplete." msgstr "This sign-in link is incomplete." -#: src/pages/FederationCallback.tsx:47 +#: src/pages/FederationCallback.tsx:42 msgid "This sign-in was started elsewhere. Start again from the login page." msgstr "This sign-in was started elsewhere. Start again from the login page." @@ -2022,8 +2022,8 @@ msgstr "User menu" #: src/components/profile/ProfileManageTab.tsx:61 #: src/components/profile/UsernameField.tsx:51 #: src/pages/Settings.tsx:91 -#: src/pages/UserLogin.tsx:79 -#: src/pages/UserRegister.tsx:139 +#: src/pages/UserLogin.tsx:81 +#: src/pages/UserRegister.tsx:141 msgid "Username" msgstr "Username" @@ -2151,7 +2151,7 @@ msgstr "Your account lives on {home}: sign in to other instances from there." msgid "Your account lives on {home}: your password, email address, avatar and username change there." msgstr "Your account lives on {home}: your password, email address, avatar and username change there." -#: src/pages/UserLogin.tsx:158 +#: src/pages/UserLogin.tsx:160 msgid "Your email address" msgstr "Your email address" diff --git a/locales/fr.po b/locales/fr.po index 6ca5c910..c6a80044 100644 --- a/locales/fr.po +++ b/locales/fr.po @@ -51,7 +51,7 @@ msgstr "{0} de plus en ligne : {restNames}" #. placeholder {0}: VALIDATION.USERNAME_MIN #. placeholder {1}: VALIDATION.USERNAME_MAX -#: src/pages/UserRegister.tsx:147 +#: src/pages/UserRegister.tsx:149 msgid "{0}–{1} characters: letters, numbers, or underscores" msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas" @@ -350,7 +350,7 @@ msgstr "Tout" msgid "Already dumped by {0} {1}" msgstr "Déjà recommandé par {0} {1}" -#: src/pages/UserRegister.tsx:179 +#: src/pages/UserRegister.tsx:181 msgid "Already have an account? <0>Log in" msgstr "Vous avez déjà un compte ? <0>Se connecter" @@ -374,7 +374,7 @@ msgstr "Apparence" #. placeholder {0}: VALIDATION.PASSWORD_MIN #: src/components/profile/ChangePasswordModal.tsx:101 #: src/pages/ResetPassword.tsx:124 -#: src/pages/UserRegister.tsx:166 +#: src/pages/UserRegister.tsx:168 msgid "At least {0} characters" msgstr "Au moins {0} caractères" @@ -391,7 +391,7 @@ msgstr "Auto" msgid "Back to login" msgstr "Retour à la connexion" -#: src/pages/FederationCallback.tsx:71 +#: src/pages/FederationCallback.tsx:72 msgid "Back to the login page" msgstr "Retour à la page de connexion" @@ -453,7 +453,7 @@ msgid "Cancel search" msgstr "Annuler la recherche" #: src/components/category/CategorySelect.tsx:33 -#: src/pages/Manage.tsx:36 +#: src/pages/Manage.tsx:38 msgid "Categories" msgstr "Catégories" @@ -498,7 +498,7 @@ msgstr "Tribune" msgid "Chat ({unreadChatCount} unread)" msgstr "{unreadChatCount} messages non lus" -#: src/pages/UserRegister.tsx:108 +#: src/pages/UserRegister.tsx:110 msgid "Checking invite…" msgstr "Vérification de l'invitation…" @@ -838,7 +838,7 @@ msgstr "Édition de {0}" msgid "Email" msgstr "E-mail" -#: src/pages/UserRegister.tsx:154 +#: src/pages/UserRegister.tsx:156 msgid "Email address" msgstr "Adresse e-mail" @@ -958,7 +958,7 @@ msgstr "Abonné" msgid "email.digest.forYou" msgstr "Pour vous" -#: src/pages/UserLogin.tsx:101 +#: src/pages/UserLogin.tsx:103 msgid "Forgot password?" msgstr "Mot de passe oublié ?" @@ -997,7 +997,7 @@ msgstr "Bonjour {username}," msgid "Hot" msgstr "Tendances" -#: src/pages/UserLogin.tsx:142 +#: src/pages/UserLogin.tsx:144 msgid "If that address is registered you'll receive a reset link shortly." msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu." @@ -1029,11 +1029,11 @@ msgstr "Dans la tribune" msgid "Insert emoji" msgstr "Insérer un emoji" -#: src/pages/Manage.tsx:43 +#: src/pages/Manage.tsx:45 msgid "Instances" msgstr "Instances" -#: src/pages/UserRegister.tsx:119 +#: src/pages/UserRegister.tsx:121 msgid "Invalid invite" msgstr "Invitation invalide" @@ -1156,9 +1156,9 @@ msgstr "Chargement…" #: src/components/layout/AppHeader.tsx:208 #: src/pages/FederationCallback.tsx:32 -#: src/pages/UserLogin.tsx:44 -#: src/pages/UserLogin.tsx:71 -#: src/pages/UserLogin.tsx:90 +#: src/pages/UserLogin.tsx:46 +#: src/pages/UserLogin.tsx:73 +#: src/pages/UserLogin.tsx:92 msgid "Log in" msgstr "Se connecter" @@ -1180,12 +1180,12 @@ msgstr "Se connecter avec {0}" msgid "Log out" msgstr "Se déconnecter" -#: src/pages/UserLogin.tsx:89 +#: src/pages/UserLogin.tsx:91 msgid "Logging in…" msgstr "Connexion…" -#: src/pages/FederationCallback.tsx:68 -#: src/pages/UserLogin.tsx:76 +#: src/pages/FederationCallback.tsx:69 +#: src/pages/UserLogin.tsx:78 msgid "Login failed" msgstr "Connexion échouée" @@ -1194,8 +1194,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:27 -#: src/pages/Manage.tsx:34 +#: src/pages/Manage.tsx:29 +#: src/pages/Manage.tsx:36 #: src/pages/UserPublicProfile.tsx:164 msgid "Manage" msgstr "Administration" @@ -1412,12 +1412,12 @@ msgstr "Page introuvable" #: src/pages/Settings.tsx:96 #: src/pages/Settings.tsx:106 -#: src/pages/UserLogin.tsx:86 +#: src/pages/UserLogin.tsx:88 msgid "Password" msgstr "Mot de passe" #. placeholder {0}: VALIDATION.PASSWORD_MIN -#: src/pages/UserRegister.tsx:160 +#: src/pages/UserRegister.tsx:162 msgid "Password (min. {0} characters)" msgstr "Mot de passe (min. {0} caractères)" @@ -1519,17 +1519,17 @@ msgstr "Actualisation du flux…" msgid "Refreshing…" msgstr "Actualisation…" -#: src/pages/UserRegister.tsx:47 -#: src/pages/UserRegister.tsx:131 -#: src/pages/UserRegister.tsx:171 +#: src/pages/UserRegister.tsx:49 +#: src/pages/UserRegister.tsx:133 +#: src/pages/UserRegister.tsx:173 msgid "Register" msgstr "S'inscrire" -#: src/pages/UserRegister.tsx:170 +#: src/pages/UserRegister.tsx:172 msgid "Registering…" msgstr "Inscription…" -#: src/pages/UserRegister.tsx:136 +#: src/pages/UserRegister.tsx:138 msgid "Registration failed" msgstr "Inscription échouée" @@ -1581,7 +1581,7 @@ msgstr "Répondre" msgid "Replying to" msgstr "En réponse à" -#: src/pages/UserLogin.tsx:154 +#: src/pages/UserLogin.tsx:156 msgid "Request failed" msgstr "Échec de la demande" @@ -1688,12 +1688,12 @@ msgstr "Position" msgid "Send" msgstr "Envoyer" -#: src/pages/UserLogin.tsx:163 +#: src/pages/UserLogin.tsx:165 msgid "Send reset link" msgstr "Envoyer le lien de réinitialisation" #: src/components/chat/PendingItem.tsx:104 -#: src/pages/UserLogin.tsx:162 +#: src/pages/UserLogin.tsx:164 msgid "Sending…" msgstr "Envoi…" @@ -1730,7 +1730,7 @@ msgid "Sign in to {name}?" msgstr "Se connecter à {name} ?" #: src/pages/Authorize.tsx:154 -#: src/pages/FederationCallback.tsx:78 +#: src/pages/FederationCallback.tsx:79 msgid "Signing you in…" msgstr "Connexion…" @@ -1818,11 +1818,11 @@ msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez." msgid "This comment couldn't be deleted. Try again." msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez." -#: src/pages/UserRegister.tsx:120 +#: src/pages/UserRegister.tsx:122 msgid "This invite link is missing, expired, or already used." msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé." -#: src/pages/UserLogin.tsx:110 +#: src/pages/UserLogin.tsx:112 msgid "This is a mirage." msgstr "C'est un mirage." @@ -1838,11 +1838,11 @@ msgstr "Rien à voir, circulez." msgid "This reset link is missing or malformed." msgstr "Ce lien de réinitialisation est absent ou malformé." -#: src/pages/FederationCallback.tsx:39 +#: src/pages/FederationCallback.tsx:40 msgid "This sign-in link is incomplete." msgstr "Ce lien de connexion est incomplet." -#: src/pages/FederationCallback.tsx:47 +#: src/pages/FederationCallback.tsx:42 msgid "This sign-in was started elsewhere. Start again from the login page." msgstr "Cette connexion a été commencée ailleurs. Recommencez depuis la page de connexion." @@ -2022,8 +2022,8 @@ msgstr "Menu utilisateur" #: src/components/profile/ProfileManageTab.tsx:61 #: src/components/profile/UsernameField.tsx:51 #: src/pages/Settings.tsx:91 -#: src/pages/UserLogin.tsx:79 -#: src/pages/UserRegister.tsx:139 +#: src/pages/UserLogin.tsx:81 +#: src/pages/UserRegister.tsx:141 msgid "Username" msgstr "Nom d'utilisateur" @@ -2151,7 +2151,7 @@ msgstr "Votre compte est sur {home} : connectez-vous aux autres instances depuis msgid "Your account lives on {home}: your password, email address, avatar and username change there." msgstr "Votre compte est sur {home} : votre mot de passe, votre adresse e-mail, votre avatar et votre nom d'utilisateur se changent là-bas." -#: src/pages/UserLogin.tsx:158 +#: src/pages/UserLogin.tsx:160 msgid "Your email address" msgstr "Votre adresse e-mail" diff --git a/shared/federation.ts b/shared/federation.ts index 00449894..4e093530 100644 --- a/shared/federation.ts +++ b/shared/federation.ts @@ -58,6 +58,12 @@ export interface InstanceMetadata { gerbeur_redirect_uri: string; /** `POST /api/federation/events`, where it receives a home's events. */ gerbeur_events_endpoint: string; + /** + * Whether its users may sign in to other instances, so that a client + * refuses at once to send them to its `/authorize` page, which would. An + * instance that doesn't say lets them. + */ + gerbeur_outgoing?: boolean; } /** @@ -74,7 +80,9 @@ export function isInstanceMetadata(value: unknown): value is InstanceMetadata { v.gerbeur_federation === FEDERATION_VERSION && typeof v.gerbeur_site_name === "string" && typeof v.gerbeur_redirect_uri === "string" && - typeof v.gerbeur_events_endpoint === "string"; + typeof v.gerbeur_events_endpoint === "string" && + (v.gerbeur_outgoing === undefined || + typeof v.gerbeur_outgoing === "boolean"); } /** diff --git a/src/config/instance.ts b/src/config/instance.ts index 6edd9956..160658e2 100644 --- a/src/config/instance.ts +++ b/src/config/instance.ts @@ -1,11 +1,13 @@ /** * Lets a deployment set what the app can't know from the browser: the style - * a visitor sees before choosing one, and whether the server sends email, - * without which the digest settings do nothing. + * a visitor sees before choosing one, whether the server sends email, without + * which the digest settings do nothing, and which ways federated sign-in goes. * * The server writes `GERBEUR_DEFAULT_STYLE` into index.html's `default-style` - * `` tag, and whether `GERBEUR_SMTPS_URL` is set into `email-enabled` - * (`api/lib/index-html.ts`, and the Vite config in development). index.html's + * `` tag, whether `GERBEUR_SMTPS_URL` is set into `email-enabled`, and + * `FEDERATION` (`api/config.ts`) into `federation-incoming` and + * `federation-outgoing` (`api/lib/index-html.ts`, and the Vite config in + * development). index.html's * inline theme script reads the first one too. * * @module @@ -33,3 +35,18 @@ export const DEFAULT_STYLE: StyleName = (() => { * digest switches disabled, with a note why. */ export const EMAIL_ENABLED: boolean = readMeta("email-enabled") === "on"; + +/** + * Whether users of other instances sign in here. Without it the login and + * register pages offer no other instance. + */ +export const FEDERATION_INCOMING: boolean = + readMeta("federation-incoming") !== "off"; + +/** + * Whether this instance's users sign in to other instances. Without it the + * settings page doesn't list the instances they signed in to, since none + * remain. + */ +export const FEDERATION_OUTGOING: boolean = + readMeta("federation-outgoing") !== "off"; diff --git a/src/pages/Manage.tsx b/src/pages/Manage.tsx index 84315cd6..0463df64 100644 --- a/src/pages/Manage.tsx +++ b/src/pages/Manage.tsx @@ -16,18 +16,20 @@ 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 { 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; without + * 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 * 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 = can(user, "federation:manage"); + const instances = FEDERATION_INCOMING && can(user, "federation:manage"); if (!categories && !instances) return ; return ( diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 83d1be8e..10a769d2 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -20,7 +20,7 @@ import { useSoundsEnabled } from "../hooks/shared/useSoundsEnabled.ts"; import { useSettings } from "../hooks/auth/useSettings.ts"; import { useRequiredAuth } from "../hooks/auth/useAuth.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; -import { EMAIL_ENABLED } from "../config/instance.ts"; +import { EMAIL_ENABLED, FEDERATION_OUTGOING } from "../config/instance.ts"; import { previewChime } from "../utils/shared/sound.ts"; import { PageShell } from "../components/layout/PageShell.tsx"; import { PageHeader } from "../components/layout/PageHeader.tsx"; @@ -66,7 +66,7 @@ const ON_OFF_OPTIONS: SegmentedOption[] = [ * Renders `/settings`, signed in only: the username, which only a moderator * changes (`ProfileManageTab`), the password change, which opens * {@link ChangePasswordModal}, the other instances the account signs in to - * ({@link GrantList}), and the settings, each saved as soon as it is + * ({@link GrantList}) unless it may sign in to none, and the settings, each saved as soon as it is * clicked. An account from another instance changes its password there. The style, color scheme and digests are saved to the account; the * default feed tab and sounds, for now, to this browser. */ @@ -124,7 +124,7 @@ export function Settings(): React.JSX.Element {

)} - {!home && ( + {!home && FEDERATION_OUTGOING && ( Other instances}>
diff --git a/src/pages/UserLogin.tsx b/src/pages/UserLogin.tsx index ca7687c1..8ff13eaa 100644 --- a/src/pages/UserLogin.tsx +++ b/src/pages/UserLogin.tsx @@ -24,6 +24,7 @@ import { SubmitButton } from "../components/form/SubmitButton.tsx"; import { TextField } from "../components/form/TextField.tsx"; import { useApiForm } from "../hooks/form/useApiForm.ts"; import { FederatedSignIn } from "../components/federation/FederatedSignIn.tsx"; +import { FEDERATION_INCOMING } from "../config/instance.ts"; import { nextPath } from "../utils/auth/next.ts"; interface LoginValues { @@ -38,7 +39,8 @@ interface ResetValues { /** * Renders `/login`, for the signed out. A sign-in lands on the page `?next=` * names, or `/`, and "Forgot password?" unfolds the reset request form below. - * Users of another instance sign in through {@link FederatedSignIn}. + * Users of another instance sign in through {@link FederatedSignIn}, unless + * the instance takes none. */ export function UserLogin(): React.JSX.Element { useDocumentTitle(t`Log in`); @@ -104,7 +106,7 @@ export function UserLogin(): React.JSX.Element { {showReset && } - + {FEDERATION_INCOMING && }

This is a mirage. diff --git a/src/pages/UserRegister.tsx b/src/pages/UserRegister.tsx index c6b9521a..3ac6f350 100644 --- a/src/pages/UserRegister.tsx +++ b/src/pages/UserRegister.tsx @@ -26,6 +26,7 @@ import { SubmitButton } from "../components/form/SubmitButton.tsx"; import { TextField } from "../components/form/TextField.tsx"; import { useApiForm } from "../hooks/form/useApiForm.ts"; import { FederatedSignIn } from "../components/federation/FederatedSignIn.tsx"; +import { FEDERATION_INCOMING } from "../config/instance.ts"; type TokenState = | { status: "checking" } @@ -41,7 +42,8 @@ interface Values { /** * Renders `/register?token=`, for the signed out. It checks the invite before * showing the form, and signs the new user in on success. A user of another - * instance joins with that account instead, through {@link FederatedSignIn}. + * instance joins with that account instead, through {@link FederatedSignIn}, + * unless the instance takes none. */ export function UserRegister(): React.JSX.Element { useDocumentTitle(t`Register`); @@ -173,7 +175,7 @@ export function UserRegister(): React.JSX.Element { - + {FEDERATION_INCOMING && }

diff --git a/src/test/pages/Settings.test.tsx b/src/test/pages/Settings.test.tsx index 6d901145..126a884a 100644 --- a/src/test/pages/Settings.test.tsx +++ b/src/test/pages/Settings.test.tsx @@ -8,12 +8,18 @@ import { makeSession, makeToken } from "../fixtures.ts"; import { renderApp } from "../render.tsx"; import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts"; -const instance = vi.hoisted(() => ({ emailEnabled: false })); +const instance = vi.hoisted(() => ({ + emailEnabled: false, + federationOutgoing: true, +})); vi.mock("../../config/instance.ts", () => ({ DEFAULT_STYLE: "smooth", get EMAIL_ENABLED() { return instance.emailEnabled; }, + get FEDERATION_OUTGOING() { + return instance.federationOutgoing; + }, })); const annSession = makeSession({ @@ -35,6 +41,7 @@ const row = (label: string) => describe("Settings", () => { afterEach(() => { instance.emailEnabled = false; + instance.federationOutgoing = true; document.documentElement.removeAttribute("data-style"); document.documentElement.removeAttribute("data-color-scheme"); }); @@ -197,6 +204,14 @@ describe("Settings", () => { ); }); + test("no instances are listed where accounts don't sign in elsewhere", async () => { + instance.federationOutgoing = false; + open(); + expect(await screen.findByText("ann")).toBeInTheDocument(); + expect(screen.queryByText("Other instances")).not.toBeInTheDocument(); + expect(calls("GET /api/me/grants")).toHaveLength(0); + }); + test("an account from another instance changes its password there", async () => { open(makeSession({ id: "al", username: "alice@home.example" })); const link = await screen.findByRole("link", { diff --git a/src/test/pages/UserLogin.test.tsx b/src/test/pages/UserLogin.test.tsx index 9fe76090..74200380 100644 --- a/src/test/pages/UserLogin.test.tsx +++ b/src/test/pages/UserLogin.test.tsx @@ -8,6 +8,14 @@ import { calls, deferred, fail, route } from "../fetch.ts"; import { makeSession, wire } from "../fixtures.ts"; import { currentPath, renderWithContexts } from "../render.tsx"; +const instance = vi.hoisted(() => ({ federationIncoming: true })); +vi.mock("../../config/instance.ts", async (importOriginal) => ({ + ...await importOriginal(), + get FEDERATION_INCOMING() { + return instance.federationIncoming; + }, +})); + const home = home page

} />; describe("UserLogin", () => { @@ -153,6 +161,7 @@ describe("UserLogin", () => { afterEach(() => { vi.unstubAllGlobals(); assign.mockReset(); + instance.federationIncoming = true; }); test("a trusted instance's button, or any address, starts the sign-in there", async () => { @@ -198,6 +207,14 @@ describe("UserLogin", () => { ); }); + test("isn't offered where accounts of other instances don't sign in", () => { + instance.federationIncoming = false; + setup(); + expect(screen.getByPlaceholderText("Username")).toBeInTheDocument(); + expect(screen.queryByText(/another gerbeur/)).not.toBeInTheDocument(); + expect(calls("GET /api/federation/peers")).toHaveLength(0); + }); + test("a refusal is shown under the field", async () => { route("GET /api/federation/peers", () => []); route( diff --git a/vite.config.ts b/vite.config.ts index 4d1b399b..44e81782 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,6 +16,10 @@ const DEFAULT_STYLE = isStyleName(process.env.GERBEUR_DEFAULT_STYLE?.trim()) ? process.env.GERBEUR_DEFAULT_STYLE!.trim() : BASE_STYLE; const EMAIL_ENABLED = process.env.GERBEUR_SMTPS_URL?.trim() ? "on" : "off"; +const FEDERATION_INCOMING = + process.env.GERBEUR_FEDERATION_INCOMING?.trim() === "0" ? "off" : "on"; +const FEDERATION_OUTGOING = + process.env.GERBEUR_FEDERATION_OUTGOING?.trim() === "0" ? "off" : "on"; // Cache-busting token for the favicon URLs (see index.html). Changes when the // emoji changes, so browsers don't keep serving a stale cached icon. Mirrors @@ -148,6 +152,8 @@ export default defineConfig({ .replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER) .replaceAll("__DEFAULT_STYLE__", DEFAULT_STYLE) .replaceAll("__EMAIL_ENABLED__", EMAIL_ENABLED) + .replaceAll("__FEDERATION_INCOMING__", FEDERATION_INCOMING) + .replaceAll("__FEDERATION_OUTGOING__", FEDERATION_OUTGOING) : html, }, },