v4: a deployment chooses which ways federated sign-in goes — every instance took users of the others and let its own sign in to them, with no way to turn either off. GERBEUR_FEDERATION_INCOMING=0 keeps users of other instances out: the login and register pages offer no other instance, /manage no list of instances, their sign-ins are refused, and those already in are signed out until it is on again, their homes' events still applied meanwhile. GERBEUR_FEDERATION_OUTGOING=0 keeps the instance's users from signing in elsewhere: its /authorize page lets no instance sign them in, its metadata says so (gerbeur_outgoing), which other instances check before sending anyone there, the settings page lists no instances, and at startup every grant is withdrawn, which signs them out there. Both are on by default and reach the client through index.html. The dev instance gerbeur (:3000) takes no users of the others, while its own still sign in to them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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`
|
||||
* `<meta>` 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
|
||||
* `<meta>` 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";
|
||||
|
||||
@@ -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 <NotFound />;
|
||||
return (
|
||||
<PageShell narrow>
|
||||
|
||||
@@ -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<boolean>[] = [
|
||||
* 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 {
|
||||
</p>
|
||||
)}
|
||||
</PageGroup>
|
||||
{!home && (
|
||||
{!home && FEDERATION_OUTGOING && (
|
||||
<PageGroup label={<Trans>Other instances</Trans>}>
|
||||
<div className="page-card">
|
||||
<GrantList />
|
||||
|
||||
@@ -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 && <ResetRequestPanel />}
|
||||
|
||||
<FederatedSignIn />
|
||||
{FEDERATION_INCOMING && <FederatedSignIn />}
|
||||
|
||||
<p className="auth-card-footer">
|
||||
<Trans>This is a mirage.</Trans>
|
||||
|
||||
@@ -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 {
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
||||
<FederatedSignIn inviteToken={token} />
|
||||
{FEDERATION_INCOMING && <FederatedSignIn inviteToken={token} />}
|
||||
|
||||
<p className="auth-card-footer">
|
||||
<Trans>
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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<typeof import("../../config/instance.ts")>(),
|
||||
get FEDERATION_INCOMING() {
|
||||
return instance.federationIncoming;
|
||||
},
|
||||
}));
|
||||
|
||||
const home = <Route path="/" element={<p>home page</p>} />;
|
||||
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user