v4: users sign in to one gerbeur instance with their account on another — topic instances, each with its own admins, domain and data, had no way to share accounts. Sign-in is federated over a subset of OpenID Connect, the authorization code flow with PKCE: each instance is an identity provider for its own users and a client for the others', and signs what it tells them with an Ed25519 key kept in federation_keys and published at /.well-known/jwks.json, beside its metadata at /.well-known/openid-configuration. Only sign-in is federated: an account from another instance is a local users row linked to (issuer, subject) in user_identities, with no password (password_hash ''), so sessions, roles, permissions, the socket and every foreign key work as before.

A remote user is name@host here, in users.username, in paths (/@alice@music.example) and in mentions, through one handle rule in shared/validation.ts that paths, MENTION_RE and Markdown all build on; Markdown undoes GFM's email autolink of a handle. Their name, email and avatar come from their home at each sign-in and by its signed events (federation_outbox, delivered by the federation job with backoff): a rename at home leaves the old handle an alias here, and a password change or a withdrawn grant ends their sessions here. Their password, email, avatar and name can't change here (assertLocalUser), nor can they sign in elsewhere from here.

An admin keeps the trusted and blocked instances on /manage (federation:manage): a trusted instance's users sign in from a button on the login page, a blocked one's can't and are signed out at once, and any other's join with an invite from the register page. The /authorize page asks the user once per instance, and /settings lists the instances their account signs in to, each disconnectable; /login?next= brings a signed-out visitor back to /authorize. Emails stay unique among local accounts only, and migration 0024 adds the tables, makes that index partial and reserves authorize and federation as category slugs. remote/gerbeur-instance.ts is the only way to other instances: https and public addresses only, no redirects, no certificate fallback, unless GERBEUR_FEDERATION_ALLOW_PRIVATE lets two instances on one machine talk.

298 backend tests and 702 frontend tests pass, and deno check, deno fmt, deno lint, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked end to end with two built instances on one machine: sign-in through the other's login and consent pages, consent skipped once granted and asked again after disconnecting, a rename and a password change at home reaching the other, and blocking; screenshots of the login, consent, profile, settings and manage pages at 1280 and 390px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-09 14:23:00 +00:00
parent 2d26d0aada
commit c0d03e9aaa
67 changed files with 5261 additions and 339 deletions

View File

@@ -2297,6 +2297,19 @@ body.has-player .chat-fab {
font-weight: 600;
}
/* The `@host` of a user whose account lives on another instance. */
.profile-username-host {
font-weight: 400;
color: var(--color-text-muted);
overflow-wrap: anywhere;
}
.profile-home {
font-size: var(--text-xs);
color: var(--color-text-muted);
margin: 0 0 0.4rem;
}
/* Role badge — elevated roles only (see RoleChip) */
.profile-role-chip {
padding: 0.1rem 0.55rem;
@@ -3317,6 +3330,53 @@ body.has-player .chat-fab {
opacity: 0.85;
}
/* Signing in with an account on another instance, under the login and
register forms, and the consent page that instance shows. */
.auth-federated {
border-top: 1px solid var(--color-border-subtle);
padding-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.auth-federated-title {
margin: 0;
font-size: var(--text-sm);
text-align: center;
color: var(--color-text-muted);
}
.auth-federated-peer {
display: flex;
align-items: baseline;
justify-content: center;
flex-wrap: wrap;
gap: 0.25rem 0.5rem;
}
.auth-federated-host {
font-size: var(--text-xs);
opacity: 0.7;
}
.auth-consent {
margin: 0;
line-height: 1.5;
overflow-wrap: anywhere;
}
.auth-consent-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.auth-consent-actions > * {
flex: 1 1 auto;
text-align: center;
}
/* ── Form pages (DumpCreate / DumpEdit) ── */
@keyframes page-enter {
from {
@@ -7271,6 +7331,46 @@ body.has-player .chat-fab {
margin: 0.75rem 0 0;
}
/* ── Instances (/manage, /settings) ─────────────────────────────────────── */
.peer-manager {
display: flex;
flex-direction: column;
gap: 1rem;
}
.peer-name {
display: flex;
flex-direction: column;
min-width: 0;
overflow-wrap: anywhere;
}
.peer-host {
font-weight: 400;
font-size: var(--text-xs);
}
.peer-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.peer-add {
margin: 0;
}
.peer-add-actions {
display: flex;
gap: 0.5rem;
}
.peer-add-actions > * {
flex: 1;
}
/* ── Categories ─────────────────────────────────────────────────────────── */
.visually-hidden {

View File

@@ -9,12 +9,16 @@
* the create form on a shared link.
* - `/search` and `/search/:searchTab` are the search results (`Search`).
* - `/register` and `/login` sign up and sign in (`UserRegister`,
* `UserLogin`), signed out only.
* `UserLogin`), signed out only, `/login?next=` coming back to a page.
* - `/authorize` lets another gerbeur instance sign the user in with their
* account here (`Authorize`), and `/federation/callback` finishes a sign-in
* here with an account on another (`FederationCallback`).
* - `/reset-password` sets a new password from an emailed link
* (`ResetPassword`), and `/unsubscribe` turns off a digest from its emailed
* link (`Unsubscribe`).
* - `/notifications` (`Notifications`) and `/settings` (`Settings`), signed in
* only, and `/manage`, the site's tools, for `category:manage` (`Manage`).
* only, and `/manage`, the site's tools, for `category:manage` or
* `federation:manage` (`Manage`).
* - `/community` and `/community/monthly`, the site's week and month
* (`Community`), signed in only.
* - `/@:username/*` is a user's (`UserRoutes`): their profile and its tabs,
@@ -108,6 +112,14 @@ const ResetPassword = lazy(() =>
const Unsubscribe = lazy(() =>
import("./pages/Unsubscribe.tsx").then((m) => ({ default: m.Unsubscribe }))
);
const Authorize = lazy(() =>
import("./pages/Authorize.tsx").then((m) => ({ default: m.Authorize }))
);
const FederationCallback = lazy(() =>
import("./pages/FederationCallback.tsx").then((m) => ({
default: m.FederationCallback,
}))
);
const NotFound = lazy(() =>
import("./pages/NotFound.tsx").then((m) => ({ default: m.NotFound }))
);
@@ -202,6 +214,11 @@ function AppRoutes() {
</RestrictedGuest>
}
/>
<Route path="/authorize" element={<Authorize />} />
<Route
path="/federation/callback"
element={<FederationCallback />}
/>
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/unsubscribe" element={<Unsubscribe />} />
<Route

View File

@@ -12,6 +12,7 @@ import remarkGfm from "remark-gfm";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { userPath } from "../../../shared/paths.ts";
import { EVERYONE } from "../../../shared/mention.ts";
import { HANDLE_SOURCE } from "../../../shared/validation.ts";
import { isAttachmentUrl } from "../../../shared/attachment.ts";
/** Passed to {@link Markdown} by any component that shows a user's text. */
@@ -36,7 +37,7 @@ export interface MarkdownProps {
* parsed text: link syntax is already parsed away here, so only a preceding
* word character (an email address) rules a mention out.
*/
const MENTION_RE = /(?<!\w)@(\w+)/g;
const MENTION_RE = new RegExp(`(?<!\\w)@(${HANDLE_SOURCE})`, "g");
/** The part of a Markdown syntax tree node that the plugins below read. */
interface MdNode {
@@ -49,9 +50,37 @@ interface MdNode {
}
/**
* Turns a bare `@username` into a link to that user's profile. It works on the
* parsed tree rather than the source, so code keeps its `@`s and a URL that
* contains one (`https://mastodon.social/@bob`) isn't split in two.
* Returns `children` with each `@name@host` that GFM took for an email address
* (text ending in `@`, then a `mailto:` link of `name@host`) back in one text
* node, which {@link remarkMentions} then reads as a remote user's handle.
*/
function unlinkHandles(children: MdNode[]): MdNode[] {
const out: MdNode[] = [];
for (const child of children) {
const prev = out.at(-1);
const only = child.children?.length === 1 ? child.children[0] : undefined;
const address = child.type === "link" && only?.type === "text"
? only.value
: undefined;
if (
prev?.type === "text" &&
((address && child.url === `mailto:${address}` &&
/(?<!\w)@$/.test(prev.value ?? "")) ||
child.type === "text")
) {
prev.value = `${prev.value}${address ?? child.value}`;
continue;
}
out.push(child);
}
return out;
}
/**
* Turns a bare `@username`, or a remote user's `@name@host`, into a link to
* that user's profile. It works on the parsed tree rather than the source, so
* code keeps its `@`s and a URL that contains one
* (`https://mastodon.social/@bob`) isn't split in two.
* `@everyone` names no profile: it is left as text, or with `everyone` set,
* becomes a `span.md-everyone`.
*/
@@ -60,7 +89,7 @@ function remarkMentions({ everyone = false }: { everyone?: boolean } = {}) {
if (!node.children) return;
// Link text is already a link, whatever it says.
if (node.type === "link" || node.type === "linkReference") return;
node.children = node.children.flatMap((child) => {
node.children = unlinkHandles(node.children).flatMap((child) => {
if (child.type !== "text" || !child.value?.includes("@")) {
walk(child);
return [child];

View File

@@ -0,0 +1,119 @@
/**
* Renders the way in for users of another gerbeur instance, under the login
* form and, with the invite, under the register form.
*
* @module
*/
import { useEffect, useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { FormProvider } from "react-hook-form";
import { API_URL } from "../../config/api.ts";
import type { Wire } from "../../../shared/api.ts";
import type {
FederationLoginRequest,
FederationLoginResponse,
FederationPeer,
} from "../../../shared/federation.ts";
import { dataOr, expectOk } from "../../utils/shared/api.ts";
import { deserializeFederationPeer } from "../../utils/shared/deserialize.ts";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { FormError } from "../form/FormError.tsx";
import { SubmitButton } from "../form/SubmitButton.tsx";
import { TextField } from "../form/TextField.tsx";
/** Passed to {@link FederatedSignIn} by `UserLogin` and `UserRegister`. */
export interface FederatedSignInProps {
/**
* The invite the register page was opened with, which lets a user of any
* instance join; the login page has none, and offers the trusted instances.
*/
inviteToken?: string;
}
interface Values {
instance: string;
}
/**
* Renders a button per trusted instance (`GET /api/federation/peers`) and a
* field for any other's address. Either starts the sign-in
* (`POST /api/federation/logins`) and leaves for that instance's `/authorize`
* page, which sends the user back to `/federation/callback`.
*/
export function FederatedSignIn(
{ inviteToken }: FederatedSignInProps,
): React.JSX.Element {
const [peers, setPeers] = useState<FederationPeer[]>([]);
const form = useApiForm<Values>({ defaultValues: { instance: "" } });
useEffect(() => {
if (inviteToken) return;
const controller = new AbortController();
fetch(`${API_URL}/api/federation/peers`, { signal: controller.signal })
.then((res) => dataOr<Wire<FederationPeer>[]>(res, []))
.then((list) => setPeers(list.map(deserializeFederationPeer)))
.catch(() => {});
return () => controller.abort();
}, [inviteToken]);
const onSubmit = form.submit(async ({ instance }) => {
const { authorizeUrl } = await expectOk<FederationLoginResponse>(
await fetch(`${API_URL}/api/federation/logins`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{
instance: instance.trim(),
inviteToken,
} satisfies FederationLoginRequest,
),
}),
);
globalThis.location.assign(authorizeUrl);
});
return (
<div className="auth-federated">
<p className="auth-federated-title">
{inviteToken
? <Trans>Or join with your account on another gerbeur</Trans>
: <Trans>Or log in with your account on another gerbeur</Trans>}
</p>
<FormProvider {...form}>
{peers.map((peer) => (
<button
key={peer.issuer}
type="button"
className="btn-secondary auth-federated-peer"
disabled={form.formState.isSubmitting}
onClick={() => {
form.setValue("instance", peer.issuer);
onSubmit();
}}
>
<Trans>Log in with {peer.name ?? peer.host}</Trans>
<span className="auth-federated-host">{peer.host}</span>
</button>
))}
<form onSubmit={onSubmit} className="form">
<FormError title={t`Couldn't reach that instance`} />
<TextField<Values>
name="instance"
placeholder={t`Its address, like music.example`}
autoComplete="url"
rules={{ required: true }}
/>
<SubmitButton
className="btn-secondary"
pendingLabel={<Trans>Connecting…</Trans>}
>
<Trans>Continue</Trans>
</SubmitButton>
</form>
</FormProvider>
</div>
);
}

View File

@@ -0,0 +1,101 @@
/**
* Renders the other gerbeur instances the signed-in user let their account
* sign in to, on `/settings`.
*
* @module
*/
import { useCallback, useEffect, useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
import type { Wire } from "../../../shared/api.ts";
import type { FederationGrant } from "../../../shared/federation.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
import { deserializeFederationGrant } from "../../utils/shared/deserialize.ts";
import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { ErrorCard } from "../shared/ErrorCard.tsx";
import { SettingRow } from "../profile/SettingRow.tsx";
/**
* Renders each instance (`GET /api/me/grants`), when it last signed the user
* in, and a button that withdraws it (`DELETE /api/me/grants/:host`), which
* signs them out there; signing in there again asks them again.
*/
export function GrantList(): React.JSX.Element {
const { authFetch } = useAuth();
const [grants, setGrants] = useState<FederationGrant[] | null>(null);
const [error, setError] = useState<string | null>(null);
const fetchGrants = useCallback(
async () =>
(await expectOk<Wire<FederationGrant>[]>(
await authFetch(`${API_URL}/api/me/grants`),
)).map(deserializeFederationGrant),
[authFetch],
);
useEffect(() => {
fetchGrants()
.then(setGrants)
.catch((err) => setError(friendlyFetchError(err)));
}, [fetchGrants]);
const revoke = async (grant: FederationGrant) => {
setError(null);
try {
await expectOk(
await authFetch(
`${API_URL}/api/me/grants/${encodeURIComponent(grant.host)}`,
{ method: "DELETE" },
),
);
setGrants(await fetchGrants());
} catch (err) {
setError(friendlyFetchError(err));
}
};
return (
<>
{grants && grants.length > 0 && (
<div className="profile-appearance-grid">
{grants.map((grant) => (
<SettingRow
key={grant.clientId}
label={
<span className="peer-name">
{grant.name ?? grant.host}
<span className="peer-host">{grant.host}</span>
</span>
}
>
<span className="peer-controls">
<span className="form-hint">
<Trans>last used {relativeTime(grant.lastUsedAt)}</Trans>
</span>
<button
type="button"
className="btn btn--ghost"
onClick={() => revoke(grant)}
>
<Trans>Disconnect</Trans>
</button>
</span>
</SettingRow>
))}
</div>
)}
{grants?.length === 0 && (
<p className="form-hint">
<Trans>
You haven't signed in to another gerbeur with this account yet.
</Trans>
</p>
)}
{error && <ErrorCard title={t`Failed to save`} message={error} />}
</>
);
}

View File

@@ -0,0 +1,177 @@
/**
* Renders the list of the other gerbeur instances an admin trusts or blocks,
* on `/manage`, for those who hold `federation:manage`.
*
* @module
*/
import { useCallback, useEffect, useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { FormProvider } from "react-hook-form";
import { API_URL } from "../../config/api.ts";
import type { Wire } from "../../../shared/api.ts";
import type {
FederationPeer,
PeerStatus,
SetPeerRequest,
} from "../../../shared/federation.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
import { deserializeFederationPeer } from "../../utils/shared/deserialize.ts";
import { FormError } from "../form/FormError.tsx";
import { SubmitButton } from "../form/SubmitButton.tsx";
import { TextField } from "../form/TextField.tsx";
import { ErrorCard } from "../shared/ErrorCard.tsx";
import { Segmented, type SegmentedOption } from "../shared/Segmented.tsx";
import { SettingRow } from "../profile/SettingRow.tsx";
const STATUS_OPTIONS: SegmentedOption<PeerStatus>[] = [
{ value: "trusted", label: <Trans>Trusted</Trans> },
{ value: "blocked", label: <Trans>Blocked</Trans> },
];
interface Values {
instance: string;
}
/**
* Renders each listed instance with its status, which a click changes
* (`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.
*/
export function PeerManager(): React.JSX.Element {
const { authFetch } = useAuth();
const [peers, setPeers] = useState<FederationPeer[] | null>(null);
const [error, setError] = useState<string | null>(null);
const form = useApiForm<Values>({ defaultValues: { instance: "" } });
const fetchPeers = useCallback(
async () =>
(await expectOk<Wire<FederationPeer>[]>(
await authFetch(`${API_URL}/api/federation/peers`),
)).map(deserializeFederationPeer),
[authFetch],
);
const load = async () => setPeers(await fetchPeers());
useEffect(() => {
fetchPeers()
.then(setPeers)
.catch((err) => setError(friendlyFetchError(err)));
}, [fetchPeers]);
const save = async (instance: string, status: PeerStatus) => {
await expectOk<Wire<FederationPeer>>(
await authFetch(`${API_URL}/api/federation/peers`, {
method: "PUT",
body: JSON.stringify({ instance, status } satisfies SetPeerRequest),
}),
);
await load();
};
/** Runs a change from a row, whose failure shows above the form. */
const change = async (run: () => Promise<void>) => {
setError(null);
try {
await run();
} catch (err) {
setError(friendlyFetchError(err));
}
};
const remove = (peer: FederationPeer) =>
change(async () => {
await expectOk(
await authFetch(
`${API_URL}/api/federation/peers/${encodeURIComponent(peer.host)}`,
{ method: "DELETE" },
),
);
await load();
});
const add = (status: PeerStatus) =>
form.submit(async ({ instance }) => {
await save(instance.trim(), status);
form.reset();
});
return (
<div className="peer-manager">
{peers === null && !error && (
<p className="index-status">
<Trans>Loading…</Trans>
</p>
)}
{peers && peers.length > 0 && (
<div className="profile-appearance-grid">
{peers.map((peer) => (
<SettingRow
key={peer.issuer}
label={
<span className="peer-name">
{peer.name ?? peer.host}
<span className="peer-host">{peer.host}</span>
</span>
}
>
<span className="peer-controls">
<Segmented
options={STATUS_OPTIONS}
value={peer.status}
onChange={(status) => change(() => save(peer.issuer, status))}
/>
<button
type="button"
className="btn--ghost peer-remove"
onClick={() => remove(peer)}
title={t`Remove ${peer.host}`}
aria-label={t`Remove ${peer.host}`}
>
✕
</button>
</span>
</SettingRow>
))}
</div>
)}
{error && <ErrorCard title={t`Failed to save`} message={error} />}
<FormProvider {...form}>
<form className="form peer-add" onSubmit={add("trusted")}>
<FormError title={t`Couldn't add that instance`} />
<TextField<Values>
name="instance"
placeholder={t`An instance's address, like music.example`}
autoComplete="url"
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>
</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>
</p>
</div>
);
}

View File

@@ -26,8 +26,8 @@ export interface UserMenuProps {
/**
* Renders the signed-in user's avatar, which opens a menu with links to their
* profile, their playlists, `/community` and `/settings`, to `/manage` for
* those who hold `category:manage`, and a Log out button. A click outside or
* Escape closes it.
* those who hold `category:manage` or `federation:manage`, and a Log out
* button. A click outside or Escape closes it.
*/
export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
const { i18n } = useLingui();
@@ -103,7 +103,8 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
>
<Trans>Settings</Trans>
</Link>
{can(user, "category:manage") && (
{(can(user, "category:manage") ||
can(user, "federation:manage")) && (
<Link
to="/manage"
className="user-menu-item"

View File

@@ -23,6 +23,8 @@ import { EmailEditor } from "./EmailEditor.tsx";
import { ProfileOwnActions } from "./ProfileOwnActions.tsx";
import { RoleChip } from "./RoleSwitcher.tsx";
import { userPath } from "../../../shared/paths.ts";
import { splitHandle } from "../../../shared/validation.ts";
import { homeUrl } from "../../utils/auth/home.ts";
/** Passed to {@link ProfileHeader} by `UserPublicProfile`, once loaded. */
export interface ProfileHeaderProps {
@@ -33,10 +35,12 @@ export interface ProfileHeaderProps {
}
/**
* Renders the avatar, username, role and inviter. Visitors get a follow
* button. On their own profile, users change their avatar
* (`PUT /api/me/avatar`) and email, and invite someone; their settings, the
* site's tools and logging out are in the header's avatar menu.
* Renders the avatar, username, role and inviter, and for a user from another
* instance, a link to their profile there. Visitors get a follow button. On
* their own profile, users change their avatar (`PUT /api/me/avatar`) and
* email, unless their account comes from another instance, and invite
* someone; their settings, the site's tools and logging out are in the
* header's avatar menu.
*/
export function ProfileHeader(
{ user, isOwnProfile, onUserChange }: ProfileHeaderProps,
@@ -47,6 +51,9 @@ export function ProfileHeader(
const fileInputRef = useRef<HTMLInputElement>(null);
const [emailEditing, setEmailEditing] = useState(false);
const infoRef = useHeightTransition<HTMLDivElement>(emailEditing);
const { name, host } = splitHandle(user.username);
// A user from another instance changes their avatar and email there.
const editable = isOwnProfile && !host;
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -91,7 +98,7 @@ export function ProfileHeader(
size={128}
version={user.updatedAt?.getTime()}
/>
{isOwnProfile && (
{editable && (
<label className="avatar-change-overlay" title={t`Change avatar`}>
{uploading ? "…" : "✎"}
<input
@@ -107,7 +114,10 @@ export function ProfileHeader(
</div>
<div className="profile-info" ref={infoRef}>
<div className="profile-username-row">
<h1 className="profile-username">{user.username}</h1>
<h1 className="profile-username">
{name}
{host && <span className="profile-username-host">@{host}</span>}
</h1>
<RoleChip role={user.role} />
</div>
{user.invitedByUsername
@@ -122,12 +132,22 @@ export function ProfileHeader(
</Link>
</p>
)
: (
// Without an inviter, a local user was here first; one from a
// trusted instance just came in.
: !host && (
<p className="profile-invited-by profile-invited-by--founding">
O.G.
</p>
)}
{isOwnProfile && (
{host && (
<p className="profile-home">
<Trans>
Account on{" "}
<a href={homeUrl(user.username) ?? undefined}>{host}</a>
</Trans>
</p>
)}
{editable && (
emailEditing
? (
<EmailEditor

164
src/pages/Authorize.tsx Normal file
View File

@@ -0,0 +1,164 @@
/**
* Renders the page where another gerbeur instance asks to sign the user in
* with their account here, `/authorize`. That instance's login page sends
* them, with its request in the query, and this page sends them back to it.
*
* @module
*/
import { useCallback, useEffect, useState } from "react";
import { Link, Navigate, useLocation, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type {
AuthorizeRequest,
AuthorizeResponse,
OAuthClient,
} from "../../shared/federation.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { PageShell } from "../components/layout/PageShell.tsx";
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
import { homeHost } from "../utils/auth/home.ts";
import { loginPath } from "../utils/auth/next.ts";
/**
* Renders `/authorize?client_id=&redirect_uri=&state=&code_challenge=&nonce=`.
* A signed-out visitor signs in first and comes back. The page names the
* instance asking (`GET /api/oauth/clients`) and what it will receive; once
* the user continues, or at once if they already did before, it gets a code
* (`POST /api/oauth/codes`) and goes back to the instance with it. An account
* that itself comes from another instance can't be used from here.
*/
export function Authorize(): React.JSX.Element {
useDocumentTitle(t`Sign in elsewhere`);
const { user, authFetch } = useAuth();
const location = useLocation();
const [params] = useSearchParams();
const [client, setClient] = useState<OAuthClient | null>(null);
const [error, setError] = useState<string | null>(null);
const [leaving, setLeaving] = useState(false);
const clientId = params.get("client_id") ?? "";
const home = user ? homeHost(user.username) : undefined;
const approve = useCallback(async () => {
setError(null);
setLeaving(true);
try {
const request: AuthorizeRequest = {
clientId,
redirectUri: params.get("redirect_uri") ?? "",
state: params.get("state") ?? "",
codeChallenge: params.get("code_challenge") ?? "",
codeChallengeMethod: params.get("code_challenge_method") ?? "",
nonce: params.get("nonce") ?? "",
};
const { redirectTo } = await expectOk<AuthorizeResponse>(
await authFetch(`${API_URL}/api/oauth/codes`, {
method: "POST",
body: JSON.stringify(request),
}),
);
globalThis.location.assign(redirectTo);
} catch (err) {
setError(friendlyFetchError(err));
setLeaving(false);
}
}, [authFetch, clientId, params]);
useEffect(() => {
if (!user || home) return;
let cancelled = false;
authFetch(
`${API_URL}/api/oauth/clients?client_id=${encodeURIComponent(clientId)}`,
)
.then((res) => expectOk<OAuthClient>(res))
.then((found) => {
if (cancelled) return;
setClient(found);
if (found.granted) approve();
})
.catch((err) => {
if (!cancelled) setError(friendlyFetchError(err));
});
return () => {
cancelled = true;
};
// Asked once per instance; approving changes nothing to ask again.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id, home, clientId]);
if (!user) {
return <Navigate to={loginPath(location.pathname + location.search)} />;
}
if (home) {
return (
<PageShell centered>
<div className="page-error-wrap">
<ErrorCard
title={t`Not from here`}
message={t`Your account lives on ${home}: sign in to other instances from there.`}
/>
</div>
</PageShell>
);
}
if (!client) {
return (
<PageShell centered>
{error
? (
<div className="page-error-wrap">
<ErrorCard title={t`Can't sign in there`} message={error} />
</div>
)
: (
<p className="page-loading">
<Trans>Loading…</Trans>
</p>
)}
</PageShell>
);
}
const { name, host } = client;
const username = user.username;
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">
<Trans>Sign in to {name}?</Trans>
</h1>
<p className="auth-consent">
<Trans>
<strong>{name}</strong>{" "}
({host}) asks to sign you in with your account here,{" "}
<strong>@{username}</strong>. It will receive your username, your
email address and your avatar.
</Trans>
</p>
{error && <ErrorCard title={t`Can't sign in there`} message={error} />}
<div className="auth-consent-actions">
<button
type="button"
className="btn-primary"
onClick={approve}
disabled={leaving}
>
{leaving
? <Trans>Signing you in…</Trans>
: <Trans>Continue as @{username}</Trans>}
</button>
<Link to="/" className="btn-secondary">
<Trans>Cancel</Trans>
</Link>
</div>
</div>
</PageShell>
);
}

View File

@@ -0,0 +1,78 @@
/**
* Renders the page another gerbeur instance sends a user back to once they
* let it sign them in here, `/federation/callback`.
*
* @module
*/
import { useEffect, useRef, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type { Wire } from "../../shared/api.ts";
import type { FederationSessionRequest } from "../../shared/federation.ts";
import type { AuthResponse } from "../../shared/user.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { PageShell } from "../components/layout/PageShell.tsx";
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
import { deserializeAuthResponse } from "../utils/shared/deserialize.ts";
/**
* Renders `/federation/callback?code=&state=`. It finishes the sign-in
* (`POST /api/federation/sessions`), once: a code works only once, and a
* second request would fail the sign-in. On success the user lands on `/`,
* signed in.
*/
export function FederationCallback(): React.JSX.Element {
useDocumentTitle(t`Log in`);
const [params] = useSearchParams();
const navigate = useNavigate();
const { login } = useAuth();
const code = params.get("code");
const state = params.get("state");
const [error, setError] = useState<string | null>(
code && state ? null : t`This sign-in link is incomplete.`,
);
const sent = useRef(false);
useEffect(() => {
if (sent.current || !code || !state) return;
sent.current = true;
fetch(`${API_URL}/api/federation/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, state } satisfies FederationSessionRequest),
})
.then((res) => expectOk<Wire<AuthResponse>>(res))
.then((data) => {
login(deserializeAuthResponse(data));
navigate("/", { replace: true });
})
.catch((err) => setError(friendlyFetchError(err)));
}, [code, state, login, navigate]);
return (
<PageShell centered>
{error
? (
<div className="page-error-wrap">
<ErrorCard title={t`Login failed`} message={error} />
<p className="auth-card-footer">
<Link to="/login">
<Trans>Back to the login page</Trans>
</Link>
</p>
</div>
)
: (
<p className="page-loading">
<Trans>Signing you in…</Trans>
</p>
)}
</PageShell>
);
}

View File

@@ -1,6 +1,7 @@
/**
* Renders the site's tools, `/manage`, for those who hold `category:manage`.
* Another user's role and name are managed on their profile's "manage" tab.
* Renders the site's tools, `/manage`, for those who hold `category:manage` or
* `federation:manage`. Another user's role and name are managed on their
* profile's "manage" tab.
*
* @module
*/
@@ -14,24 +15,37 @@ import { PageShell } from "../components/layout/PageShell.tsx";
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 { NotFound } from "./NotFound.tsx";
/**
* Renders the category manager, signed in only; without `category:manage`,
* which the API checks again, the page isn't found.
* 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
* either, the page isn't found.
*/
export function Manage(): React.JSX.Element {
useDocumentTitle(t`Manage`);
const { user } = useRequiredAuth();
if (!can(user, "category:manage")) return <NotFound />;
const categories = can(user, "category:manage");
const instances = can(user, "federation:manage");
if (!categories && !instances) return <NotFound />;
return (
<PageShell narrow>
<PageHeader icon="🛠️" title={<Trans>Manage</Trans>} />
<PageGroup label={<Trans>Categories</Trans>}>
<div className="page-card">
<CategoryManager />
</div>
</PageGroup>
{categories && (
<PageGroup label={<Trans>Categories</Trans>}>
<div className="page-card">
<CategoryManager />
</div>
</PageGroup>
)}
{instances && (
<PageGroup label={<Trans>Instances</Trans>}>
<div className="page-card">
<PeerManager />
</div>
</PageGroup>
)}
</PageShell>
);
}

View File

@@ -5,9 +5,10 @@
* @module
*/
import { Navigate } from "react-router";
import { Navigate, useSearchParams } from "react-router";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { nextPath } from "../utils/auth/next.ts";
/** Passed to {@link RestrictedGuest} by `App`, around a route's page. */
export interface RestrictedGuestProps {
@@ -15,12 +16,16 @@ export interface RestrictedGuestProps {
}
/**
* Renders `children` when no one is signed in, and redirects to `/` otherwise.
* Renders `children` when no one is signed in, and redirects otherwise: to the
* page a `?next=` names (`nextPath`), or to `/`.
*/
export function RestrictedGuest(
{ children }: RestrictedGuestProps,
): React.JSX.Element {
const { user } = useAuth();
const [params] = useSearchParams();
return user ? <Navigate to="/" /> : <>{children}</>;
return user
? <Navigate to={nextPath(params) ?? "/"} replace />
: <>{children}</>;
}

View File

@@ -31,6 +31,8 @@ import {
} from "../components/shared/Segmented.tsx";
import { ChangePasswordModal } from "../components/profile/ChangePasswordModal.tsx";
import { SettingRow } from "../components/profile/SettingRow.tsx";
import { GrantList } from "../components/federation/GrantList.tsx";
import { homeHost, homeUrl } from "../utils/auth/home.ts";
// The styles' names are their own, in every language.
const STYLE_OPTIONS: SegmentedOption<StyleName>[] = [
@@ -63,8 +65,9 @@ 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}, and the settings, each saved as soon as it is
* clicked. The style, color scheme and digests are saved to the account; the
* {@link ChangePasswordModal}, the other instances the account signs in to
* ({@link GrantList}), 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.
*/
export function Settings(): React.JSX.Element {
@@ -78,6 +81,7 @@ export function Settings(): React.JSX.Element {
// Until the session has the settings, a click would save over unknown ones.
const digestsDisabled = !EMAIL_ENABLED || !settings;
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
const home = homeHost(user.username);
return (
<PageShell narrow>
@@ -87,17 +91,46 @@ export function Settings(): React.JSX.Element {
<SettingRow label={<Trans>Username</Trans>}>
<span className="profile-appearance-value">{user.username}</span>
</SettingRow>
<SettingRow label={<Trans>Password</Trans>}>
<button
type="button"
className="btn btn--ghost"
onClick={() => setChangePasswordOpen(true)}
>
<Trans>Change password…</Trans>
</button>
</SettingRow>
{home
? (
<SettingRow label={<Trans>Password</Trans>}>
<a
className="btn btn--ghost"
href={homeUrl(user.username, "/settings") ?? undefined}
>
<Trans>Change it on {home}</Trans>
</a>
</SettingRow>
)
: (
<SettingRow label={<Trans>Password</Trans>}>
<button
type="button"
className="btn btn--ghost"
onClick={() => setChangePasswordOpen(true)}
>
<Trans>Change password…</Trans>
</button>
</SettingRow>
)}
</div>
{home && (
<p className="form-hint profile-appearance-note">
<Trans>
Your account lives on{" "}
{home}: your password, email address, avatar and username change
there.
</Trans>
</p>
)}
</PageGroup>
{!home && (
<PageGroup label={<Trans>Other instances</Trans>}>
<div className="page-card">
<GrantList />
</div>
</PageGroup>
)}
<PageGroup label={<Trans>Appearance</Trans>}>
<div className="page-card profile-appearance-grid">
<SettingRow label={<Trans>Style</Trans>}>

View File

@@ -5,7 +5,7 @@
*/
import { useState } from "react";
import { useNavigate } from "react-router";
import { useNavigate, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -23,6 +23,8 @@ import { FormProvider } from "react-hook-form";
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 { nextPath } from "../utils/auth/next.ts";
interface LoginValues {
username: string;
@@ -34,12 +36,14 @@ interface ResetValues {
}
/**
* Renders `/login`, for the signed out. A sign-in lands on `/`, and "Forgot
* password?" unfolds the reset request form below.
* 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}.
*/
export function UserLogin(): React.JSX.Element {
useDocumentTitle(t`Log in`);
const navigate = useNavigate();
const [params] = useSearchParams();
const { login } = useAuth();
const [showReset, setShowReset] = useState(false);
const cardRef = useHeightTransition<HTMLDivElement>(showReset);
@@ -57,7 +61,7 @@ export function UserLogin(): React.JSX.Element {
}),
);
login(deserializeAuthResponse(data));
navigate("/");
navigate(nextPath(params) ?? "/", { replace: true });
});
return (
@@ -100,6 +104,8 @@ export function UserLogin(): React.JSX.Element {
{showReset && <ResetRequestPanel />}
<FederatedSignIn />
<p className="auth-card-footer">
<Trans>This is a mirage.</Trans>
</p>

View File

@@ -25,6 +25,7 @@ import { FormProvider } from "react-hook-form";
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";
type TokenState =
| { status: "checking" }
@@ -39,7 +40,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.
* showing the form, and signs the new user in on success. A user of another
* instance joins with that account instead, through {@link FederatedSignIn}.
*/
export function UserRegister(): React.JSX.Element {
useDocumentTitle(t`Register`);
@@ -171,6 +173,8 @@ export function UserRegister(): React.JSX.Element {
</form>
</FormProvider>
<FederatedSignIn inviteToken={token} />
<p className="auth-card-footer">
<Trans>
Already have an account? <Link to="/login">Log in</Link>

View File

@@ -33,6 +33,17 @@ describe("Markdown", () => {
expect(currentPath()).toBe("/@ann");
});
test("a remote user's @name@host links to their profile here", () => {
md("hi @alice@music.example. and @bob@localhost:8001!");
expect(
screen.getByRole("link", { name: "@alice@music.example" }),
).toHaveAttribute("href", "/@alice@music.example");
expect(
screen.getByRole("link", { name: "@bob@localhost:8001" }),
).toHaveAttribute("href", "/@bob@localhost:8001");
expect(document.querySelector('a[href^="mailto:"]')).toBeNull();
});
test("@everyone links nowhere, and shows as a mention only when flagged", () => {
md("hi @everyone");
expect(screen.queryByRole("link")).toBeNull();

View File

@@ -0,0 +1,109 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Route } from "react-router";
import { Authorize } from "../../pages/Authorize.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makeSession } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
const QUERY =
"?response_type=code&client_id=https%3A%2F%2Fmusic.example&redirect_uri=https%3A%2F%2Fmusic.example%2Ffederation%2Fcallback&state=s1&code_challenge=c1&code_challenge_method=S256&nonce=n1";
const ann = makeSession({ id: "ann", username: "ann" });
describe("Authorize", () => {
const assign = vi.fn();
beforeEach(() => {
vi.stubGlobal("location", { ...globalThis.location, assign });
});
afterEach(() => {
vi.unstubAllGlobals();
assign.mockReset();
});
function open(session = ann) {
const user = userEvent.setup();
renderApp(<Authorize />, {
route: `/authorize${QUERY}`,
path: "/authorize",
session,
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
});
return user;
}
test("asks before signing in, then goes back with a code", async () => {
route("GET /api/oauth/clients", () => ({
clientId: "https://music.example",
host: "music.example",
name: "Music",
granted: false,
}));
route("POST /api/oauth/codes", () => ({
redirectTo: "https://music.example/federation/callback?code=x&state=s1",
}));
const user = open();
expect(await screen.findByText("Sign in to Music?")).toBeInTheDocument();
expect(calls("GET /api/oauth/clients")[0].url.searchParams.get("client_id"))
.toBe("https://music.example");
expect(calls("POST /api/oauth/codes")).toHaveLength(0);
await user.click(screen.getByRole("button", { name: "Continue as @ann" }));
await waitFor(() =>
expect(assign).toHaveBeenCalledWith(
"https://music.example/federation/callback?code=x&state=s1",
)
);
expect(calls("POST /api/oauth/codes")[0].body).toEqual({
clientId: "https://music.example",
redirectUri: "https://music.example/federation/callback",
state: "s1",
codeChallenge: "c1",
codeChallengeMethod: "S256",
nonce: "n1",
});
});
test("goes straight back once the user already agreed", async () => {
route("GET /api/oauth/clients", () => ({
clientId: "https://music.example",
host: "music.example",
name: "Music",
granted: true,
}));
route("POST /api/oauth/codes", () => ({ redirectTo: "https://x/cb" }));
open();
await waitFor(() => expect(assign).toHaveBeenCalledWith("https://x/cb"));
});
test("a signed-out visitor logs in first and comes back", async () => {
renderApp(<Authorize />, {
route: `/authorize${QUERY}`,
path: "/authorize",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
});
await screen.findByText("login page");
expect(currentPath()).toBe(
`/login?next=${encodeURIComponent(`/authorize${QUERY}`)}`,
);
});
test("an account from another instance is turned away", async () => {
open(makeSession({ id: "bob", username: "bob@home.example" }));
expect(
await screen.findByText(/Your account lives on home\.example/),
).toBeInTheDocument();
expect(calls("GET /api/oauth/clients")).toHaveLength(0);
});
test("an instance the server refuses is reported", async () => {
route(
"GET /api/oauth/clients",
() => fail("This instance is blocked", 403),
);
open();
expect(await screen.findByText("This instance is blocked"))
.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,51 @@
import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react";
import { Route } from "react-router";
import { FederationCallback } from "../../pages/FederationCallback.tsx";
import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeSession, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
function open(query: string) {
renderApp(<FederationCallback />, {
route: `/federation/callback${query}`,
path: "/federation/callback",
extraRoutes: <Route path="/" element={<p>home page</p>} />,
});
}
describe("FederationCallback", () => {
test("finishes the sign-in once and lands home signed in", async () => {
route(
"POST /api/federation/sessions",
() => wire(makeSession({ username: "alice@home.example" })),
);
open("?code=c&state=s&iss=https%3A%2F%2Fhome.example");
await screen.findByText("home page");
expect(calls("POST /api/federation/sessions").map((c) => c.body)).toEqual(
[{ code: "c", state: "s" }],
);
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.username)
.toBe("alice@home.example");
});
test("a refusal is shown, with the way back", async () => {
route(
"POST /api/federation/sessions",
() => fail("This sign-in has expired, start again"),
);
open("?code=c&state=s");
expect(await screen.findByText("This sign-in has expired, start again"))
.toBeInTheDocument();
expect(screen.getByRole("link", { name: "Back to the login page" }))
.toHaveAttribute("href", "/login");
});
test("a link without its code says so", async () => {
open("?state=s");
expect(await screen.findByText("This sign-in link is incomplete."))
.toBeInTheDocument();
expect(calls("POST /api/federation/sessions")).toHaveLength(0);
});
});

View File

@@ -9,6 +9,7 @@ import { renderApp } from "../render.tsx";
const admin = makeSession({ id: "ann", username: "ann", role: "admin" });
function open(session = admin) {
route("GET /api/federation/peers", () => [], { fallback: true });
const user = userEvent.setup();
renderApp(<Manage />, { route: "/manage", path: "/manage", session });
return user;
@@ -78,6 +79,55 @@ describe("Manage", () => {
.toBeInTheDocument();
});
test("an admin trusts, blocks and removes other instances", async () => {
route("GET /api/categories", () => []);
const music = {
issuer: "https://music.example",
host: "music.example",
name: "Music",
status: "trusted",
createdAt: new Date().toISOString(),
};
let peers: (typeof music)[] = [];
route("GET /api/federation/peers", () => peers);
route("PUT /api/federation/peers", (req) => {
const { status } = req.body as { status: string };
peers = [{ ...music, status }];
return peers[0];
});
route("DELETE /api/federation/peers/:host", () => {
peers = [];
return null;
});
const user = open();
await user.type(
await screen.findByPlaceholderText(
"An instance's address, like music.example",
),
"music.example",
);
await user.click(screen.getByRole("button", { name: "Trust" }));
expect(await screen.findByText("Music")).toBeInTheDocument();
expect(calls("PUT /api/federation/peers")[0].body).toEqual({
instance: "music.example",
status: "trusted",
});
await user.click(screen.getByRole("button", { name: "Blocked" }));
await waitFor(() =>
expect(calls("PUT /api/federation/peers")[1]?.body).toEqual({
instance: "https://music.example",
status: "blocked",
})
);
await user.click(
screen.getByRole("button", { name: "Remove music.example" }),
);
await waitFor(() =>
expect(screen.queryByText("Music")).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."))

View File

@@ -23,6 +23,7 @@ const annSession = makeSession({
});
function open(session = annSession) {
route("GET /api/me/grants", () => [], { fallback: true });
const user = userEvent.setup();
renderApp(<Settings />, { route: "/settings", path: "/settings", session });
return user;
@@ -170,4 +171,42 @@ describe("Settings", () => {
"Current password is incorrect",
);
});
test("lists the instances the account signs in to, and disconnects one", async () => {
const now = new Date().toISOString();
let grants = [{
clientId: "https://music.example",
host: "music.example",
name: "Music",
grantedAt: now,
lastUsedAt: now,
}];
route("GET /api/me/grants", () => grants);
route("DELETE /api/me/grants/:host", () => {
grants = [];
return null;
});
const user = open();
expect(await screen.findByText("Music")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Disconnect" }));
expect(
await screen.findByText(/You haven't signed in to another gerbeur/),
).toBeInTheDocument();
expect(calls("DELETE /api/me/grants/:host")[0].path).toBe(
"/api/me/grants/music.example",
);
});
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", {
name: "Change it on home.example",
});
expect(link).toHaveAttribute(
"href",
`${location.protocol}//home.example/settings`,
);
expect(screen.queryByText("Other instances")).not.toBeInTheDocument();
expect(calls("GET /api/me/grants")).toHaveLength(0);
});
});

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Route } from "react-router";
import { UserLogin } from "../../pages/UserLogin.tsx";
@@ -120,4 +120,96 @@ describe("UserLogin", () => {
expect(screen.queryByText(/you'll receive a reset link/)).not
.toBeInTheDocument();
});
test("a sign-in follows ?next= on this site, and only there", async () => {
route("POST /api/sessions", () => wire(makeSession()));
const user = userEvent.setup();
renderWithContexts(<UserLogin />, {
route: `/login?next=${encodeURIComponent("/authorize?client_id=x")}`,
path: "/login",
extraRoutes: <Route path="/authorize" element={<p>authorize</p>} />,
});
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
await screen.findByText("authorize");
expect(currentPath()).toBe("/authorize?client_id=x");
});
test("ignores a ?next= that leaves the site", async () => {
route("POST /api/sessions", () => wire(makeSession()));
const user = userEvent.setup();
renderWithContexts(<UserLogin />, {
route: `/login?next=${encodeURIComponent("//evil.example")}`,
path: "/login",
extraRoutes: home,
});
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
await screen.findByText("home page");
});
describe("with an account on another instance", () => {
const assign = vi.fn();
afterEach(() => {
vi.unstubAllGlobals();
assign.mockReset();
});
test("a trusted instance's button, or any address, starts the sign-in there", async () => {
vi.stubGlobal("location", { ...globalThis.location, assign });
route("GET /api/federation/peers", () => [{
issuer: "https://music.example",
host: "music.example",
name: "Music",
status: "trusted",
createdAt: new Date().toISOString(),
}]);
route("POST /api/federation/logins", (req) => ({
authorizeUrl: `https://${
(req.body as { instance: string }).instance.replace(/^https:\/\//, "")
}/authorize?x`,
}));
const user = setup();
await user.click(
await screen.findByRole("button", { name: /Log in with Music/ }),
);
await waitFor(() =>
expect(assign).toHaveBeenCalledWith(
"https://music.example/authorize?x",
)
);
expect(calls("POST /api/federation/logins")[0].body).toEqual({
instance: "https://music.example",
});
await user.clear(
screen.getByPlaceholderText("Its address, like music.example"),
);
await user.type(
screen.getByPlaceholderText("Its address, like music.example"),
" films.example {Enter}",
);
await waitFor(() =>
expect(assign).toHaveBeenLastCalledWith(
"https://films.example/authorize?x",
)
);
});
test("a refusal is shown under the field", async () => {
route("GET /api/federation/peers", () => []);
route(
"POST /api/federation/logins",
() => fail("Users of this instance need an invite to join", 403),
);
const user = setup();
await user.type(
screen.getByPlaceholderText("Its address, like music.example"),
"films.example{Enter}",
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"need an invite",
);
});
});
});

View File

@@ -56,6 +56,27 @@ function open(
}
describe("UserPublicProfile", () => {
test("a user from another instance shows their home, and edits nothing here", async () => {
const alice = makeUser({ id: "al", username: "alice@home.example" });
open({
user: alice,
url: "/@alice@home.example",
session: makeSession({ id: "al", username: "alice@home.example" }),
});
expect(await screen.findByRole("heading", { name: "alice@home.example" }))
.toBeInTheDocument();
expect(screen.getByRole("link", { name: "home.example" })).toHaveAttribute(
"href",
`${location.protocol}//home.example/@alice`,
);
expect(screen.queryByTitle("Change avatar")).not.toBeInTheDocument();
expect(screen.queryByTitle("Edit email")).not.toBeInTheDocument();
expect(screen.queryByText("O.G.")).not.toBeInTheDocument();
expect(calls("GET /api/users/:u")[0].path).toBe(
"/api/users/@alice%40home.example",
);
});
test("header, description, and the overview", async () => {
open();
expect(await screen.findByRole("heading", { name: "ann" }))

View File

@@ -0,0 +1,15 @@
import { describe, expect, test } from "vitest";
import { loginPath, nextPath } from "../../../utils/auth/next.ts";
describe("next", () => {
test("comes back only to a path on this site", () => {
const next = (value: string) =>
nextPath(new URLSearchParams({ next: value }));
expect(next("/authorize?client_id=x")).toBe("/authorize?client_id=x");
expect(next("//evil.example")).toBeNull();
expect(next("/\\evil.example")).toBeNull();
expect(next("https://evil.example")).toBeNull();
expect(nextPath(new URLSearchParams())).toBeNull();
expect(loginPath("/a?b=c")).toBe("/login?next=%2Fa%3Fb%3Dc");
});
});

30
src/utils/auth/home.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* Points at the instance an account from another gerbeur comes from, its
* home, where its password, email, avatar and name are changed. Such a user's
* handle here is `name@host` (`splitHandle`); the profile, the settings and
* the `/authorize` page read it through these.
*
* @module
*/
import { splitHandle } from "../../../shared/validation.ts";
import { userPath } from "../../../shared/paths.ts";
/**
* Returns the host of `handle`'s home instance, or undefined for a local
* user.
*/
export function homeHost(handle: string): string | undefined {
return splitHandle(handle).host;
}
/**
* Returns the URL of `path` on `handle`'s home instance, their profile there
* by default, or null for a local user. Instances speak the same protocol as
* this page, https outside development.
*/
export function homeUrl(handle: string, path?: string): string | null {
const { name, host } = splitHandle(handle);
if (!host) return null;
return `${globalThis.location.protocol}//${host}${path ?? userPath(name)}`;
}

25
src/utils/auth/next.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* Brings a visitor back to the page that sent them to sign in. The
* `/authorize` page sends a signed-out visitor to `/login?next=`, and the
* login page and `RestrictedGuest` follow `next` once they are signed in.
*
* @module
*/
/**
* Returns the `/login` path that comes back to `path` after signing in, a
* path on this site with its query.
*/
export function loginPath(path: string): string {
return `/login?next=${encodeURIComponent(path)}`;
}
/**
* Returns the `next` of `params` when it is a path on this site, or null.
* Anything else, `//elsewhere.example` included, is ignored, so a link can't
* send someone off the site through the login page.
*/
export function nextPath(params: URLSearchParams): string | null {
const next = params.get("next");
return next && /^\/(?![/\\])/.test(next) ? next : null;
}

View File

@@ -13,6 +13,10 @@ import type { ChatMessage } from "../../../shared/chat.ts";
import type { Comment } from "../../../shared/comment.ts";
import type { CommunityStats } from "../../../shared/community.ts";
import type { Dump, DumpUrlMatch } from "../../../shared/dump.ts";
import type {
FederationGrant,
FederationPeer,
} from "../../../shared/federation.ts";
import type { Notification } from "../../../shared/notification.ts";
import type {
Playlist,
@@ -142,3 +146,21 @@ export function deserializeCommunityStats(
playlists: raw.playlists.map(deserializePlaylist),
};
}
/** Returns `raw` as a {@link FederationPeer}. */
export function deserializeFederationPeer(
raw: Wire<FederationPeer>,
): FederationPeer {
return { ...raw, createdAt: new Date(raw.createdAt) };
}
/** Returns `raw` as a {@link FederationGrant}. */
export function deserializeFederationGrant(
raw: Wire<FederationGrant>,
): FederationGrant {
return {
...raw,
grantedAt: new Date(raw.grantedAt),
lastUsedAt: new Date(raw.lastUsedAt),
};
}