feat(tauri-app): Einladungscode im Client, Produktions-URLs als Default

Zwei Dinge, die einen ausgelieferten Client heute unbrauchbar gemacht
hätten.

Erstens: Seit die öffentliche Instanz Einladungscodes verlangt, bekam
jeder Registrierungsversuch aus dem Client 400 InvalidInviteCode, ohne
dass es ein Feld für einen Code gegeben hätte. auth_register und
create_account nehmen ihn jetzt entgegen, der LoginScreen zeigt das Feld
nur im Registrieren-Modus und leert es beim Moduswechsel — sonst würde
ein getippter Code im ausgeblendeten Feld überleben und beim nächsten
Versuch stillschweigend mitgehen.

Ein leeres Feld wird weggelassen, nicht abgelehnt: ob ein Code nötig
ist, entscheidet der Server (PDS_INVITE_REQUIRED), und eine lokale
Dev-PDS verlangt keinen. "Kein Code angegeben" und "mein Code ist der
Leerstring" sind zwei verschiedene Aussagen; nur die erste ist je wahr.
Deshalb fliegt der Schlüssel per skip_serializing_if ganz aus dem Body.

Der Server unterscheidet bei der Ablehnung bewusst nicht zwischen
fehlend, falsch, gesperrt und verbraucht — sonst wären Codes
enumerierbar. Die Meldung im Client nennt deshalb beide plausiblen
Auswege, statt einen zu raten.

Zweitens: Die Basis-URLs fielen ohne Umgebungsvariable auf
http://127.0.0.1:2583 bzw. :2584 zurück. Ein gebautes Paket hätte also
gegen nichts gesprochen. Beide zeigen jetzt auf
https://tweet.maarcade.com — dieselbe Origin für PDS und AppView, der
Proxy trennt nach Pfadpräfix. Die Variablen überschreiben weiterhin,
damit Entwicklung gegen localhost möglich bleibt; eine leer gesetzte
Variable gilt dabei als ungesetzt, weil eine leere Basis-URL sonst als
kryptischer Relative-URL-Fehler weit weg von der Ursache auftaucht.

Der Settings-View spiegelte dieselben alten Defaults und hätte falsche
Backends angezeigt — mitgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
tomdebone
2026-09-10 22:58:25 +02:00
co-authored by Claude Opus 5
parent f04d63dd7b
commit 73959f9dde
7 changed files with 691 additions and 13 deletions
+152 -5
View File
@@ -34,15 +34,32 @@ async fn pds_describe(state: tauri::State<'_, AppState>) -> Result<serde_json::V
state.pds.describe_server().await.map_err(|e| e.to_string())
}
/// `auth_register(handle, password, inviteCode?)` — create an account
/// on the configured PDS and store the resulting session.
///
/// `invite_code` arrives from the frontend as `inviteCode` (Tauri maps
/// camelCase JS argument keys onto snake_case Rust parameters, the same
/// way `mark_notifications_seen` receives `seenAt`). It is `Option`
/// because the invite gate is a *server* setting
/// (`PDS_INVITE_REQUIRED`): the public instance at
/// `https://tweet.maarcade.com` demands a code, a locally run dev PDS
/// usually does not, and the client has no business deciding which.
/// When no code is given the field is dropped from the request body
/// rather than sent empty — see [`pds_client::CreateAccountReq`].
///
/// A refused code surfaces as the PDS's `400
/// {"error":"InvalidInviteCode", …}` inside the stringified error, and
/// `errorMessage()` in `client.ts` turns that into German copy.
#[tauri::command]
async fn auth_register(
state: tauri::State<'_, AppState>,
handle: String,
password: String,
invite_code: Option<String>,
) -> Result<AccountSession, String> {
let sess = state
.pds
.create_account(&handle, &password)
.create_account(&handle, &password, invite_code.as_deref())
.await
.map_err(|e| e.to_string())?;
let s = AccountSession {
@@ -768,16 +785,74 @@ async fn show_notification(
Ok(())
}
/// Default PDS base URL — the public instance.
///
/// This is what a *shipped* build talks to. It used to be
/// `http://127.0.0.1:2583`, which meant a packaged `.app` handed to
/// anyone but the developer pointed at a server that does not exist on
/// their machine: every call failed with a connection error and the
/// login screen could not even render `describeServer`. A default is
/// the configuration of the people who never set one, so it has to be
/// the production deployment.
///
/// No trailing slash: [`PdsHttpClient`] builds its endpoints as
/// `{base}/xrpc/com.atproto.…`, so the base must end at the host.
const DEFAULT_PDS_URL: &str = "https://tweet.maarcade.com";
/// Default AppView base URL. Same host as the PDS — the reverse proxy
/// in front of `tweet.maarcade.com` routes by path prefix: `/xrpc/…`
/// to the PDS, `/api/…` to the AppView. [`AppViewClient`] appends
/// `/api/…` to this base (see the `format!("{}/api/…", self.base_url)`
/// calls in `appview_client.rs`), so the two clients can and must
/// share the one origin.
const DEFAULT_APPVIEW_URL: &str = "https://tweet.maarcade.com";
/// Resolve one base URL from its environment variable, falling back to
/// the compiled-in default.
///
/// **The environment always wins.** Development runs against a local
/// stack — `MAARCADETWEET_PDS_URL=http://127.0.0.1:2583` and
/// `MAARCADETWEET_APPVIEW_URL=http://127.0.0.1:2584`, which is what
/// `scripts/` and the dev docker-compose set up — and pointing the
/// desktop client at it must stay a matter of exporting two variables,
/// never of rebuilding. Only an *unset* variable takes the production
/// default.
///
/// A variable set to whitespace (or the empty string) counts as unset:
/// an empty base URL would silently produce request URLs like
/// `/xrpc/…` with no host, and `reqwest` would reject them as a
/// relative-URL error far away from the actual mistake. Trailing
/// slashes are trimmed because both clients append an absolute path to
/// this string, and `https://host//api/x` is not the same route to
/// every proxy.
///
/// Takes the already-performed lookup rather than the variable name so
/// it stays a pure function — testable without mutating the process
/// environment, and without a Tauri runtime.
fn base_url_or_default(from_env: Result<String, std::env::VarError>, default: &str) -> String {
let configured = from_env.ok();
let trimmed = configured
.as_deref()
.map(|v| v.trim().trim_end_matches('/'))
.filter(|v| !v.is_empty());
trimmed.unwrap_or(default).to_string()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.init();
let pds_url = std::env::var("MAARCADETWEET_PDS_URL")
.unwrap_or_else(|_| "http://127.0.0.1:2583".to_string());
let appview_url = std::env::var("MAARCADETWEET_APPVIEW_URL")
.unwrap_or_else(|_| "http://127.0.0.1:2584".to_string());
let pds_url = base_url_or_default(
std::env::var("MAARCADETWEET_PDS_URL"),
DEFAULT_PDS_URL,
);
let appview_url = base_url_or_default(
std::env::var("MAARCADETWEET_APPVIEW_URL"),
DEFAULT_APPVIEW_URL,
);
tracing::info!(%pds_url, %appview_url, "resolved backend base URLs");
let state = AppState {
pds: PdsHttpClient::new(pds_url.clone()),
@@ -1056,3 +1131,75 @@ async fn profile_set(
.map_err(|e| e.to_string())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::env::VarError;
/// The whole point of the change: a build with nothing configured
/// must talk to the public instance, not to a loopback port that
/// only exists on a developer's laptop. Pinned as a literal so a
/// well-meant "let's default back to localhost for dev" has to
/// argue with a red test first.
#[test]
fn unset_env_falls_back_to_the_public_instance() {
assert_eq!(
base_url_or_default(Err(VarError::NotPresent), DEFAULT_PDS_URL),
"https://tweet.maarcade.com"
);
assert_eq!(
base_url_or_default(Err(VarError::NotPresent), DEFAULT_APPVIEW_URL),
"https://tweet.maarcade.com"
);
// Both services live behind the same origin — the proxy splits
// them by path prefix (`/xrpc/` vs `/api/`), which the clients
// append themselves.
assert_eq!(DEFAULT_PDS_URL, DEFAULT_APPVIEW_URL);
assert!(!DEFAULT_PDS_URL.ends_with('/'));
}
/// Development against a local stack has to keep working by
/// exporting a variable, so a set value always beats the default.
#[test]
fn env_var_overrides_the_default() {
assert_eq!(
base_url_or_default(Ok("http://127.0.0.1:2583".into()), DEFAULT_PDS_URL),
"http://127.0.0.1:2583"
);
assert_eq!(
base_url_or_default(Ok("http://127.0.0.1:2584".into()), DEFAULT_APPVIEW_URL),
"http://127.0.0.1:2584"
);
}
/// An empty or whitespace-only variable is a misconfiguration, not
/// a request for an empty base URL: `reqwest` would answer the
/// resulting host-less URL with a relative-URL error nowhere near
/// the cause. Trailing slashes go because both clients append an
/// absolute path (`{base}/xrpc/…`, `{base}/api/…`).
#[test]
fn blank_env_is_ignored_and_trailing_slashes_are_trimmed() {
assert_eq!(
base_url_or_default(Ok("".into()), DEFAULT_PDS_URL),
DEFAULT_PDS_URL
);
assert_eq!(
base_url_or_default(Ok(" ".into()), DEFAULT_PDS_URL),
DEFAULT_PDS_URL
);
assert_eq!(
base_url_or_default(Ok("http://127.0.0.1:2584/".into()), DEFAULT_APPVIEW_URL),
"http://127.0.0.1:2584"
);
assert_eq!(
base_url_or_default(Ok(" https://tweet.maarcade.com// ".into()), DEFAULT_PDS_URL),
"https://tweet.maarcade.com"
);
// A non-UTF-8 variable is as unusable as an unset one.
assert_eq!(
base_url_or_default(Err(VarError::NotUnicode("\u{fffd}".into())), DEFAULT_PDS_URL),
DEFAULT_PDS_URL
);
}
}
@@ -26,6 +26,27 @@ pub struct CreateAccountReq {
pub handle: String,
pub email: Option<String>,
pub password: String,
/// Invite code, required by the PDS whenever it runs with
/// `PDS_INVITE_REQUIRED=true` (the public instance at
/// `https://tweet.maarcade.com` does). Rejected codes come back as
/// `400 {"error":"InvalidInviteCode", …}`.
///
/// **Wire name.** The server's `CreateAccountReq`
/// (`crates/pds-server/src/routes/types.rs`) is snake_case with an
/// `#[serde(alias = "inviteCode")]` for off-the-shelf atproto
/// clients. We are not one of those: every body this client sends
/// is snake_case (`refresh_jwt` in `refresh_session`, `handle` /
/// `password` right here), so `invite_code` is the field name that
/// matches the rest of the file. The alias exists for other people.
///
/// **Skipped when `None`.** Same reasoning as `validate` below —
/// a PDS with the invite gate *off* must keep accepting our
/// registrations, and sending `"invite_code": null` (let alone
/// `""`) would be claiming the user supplied something. Omitting
/// the key leaves the server's `Option` at `None`, which is exactly
/// "the user gave no code".
#[serde(skip_serializing_if = "Option::is_none")]
pub invite_code: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -130,15 +151,31 @@ impl PdsHttpClient {
Ok(r)
}
/// `com.atproto.server.createAccount`.
///
/// `invite_code` is `None` when the user left the field empty; the
/// key is then left out of the body entirely (see
/// [`CreateAccountReq::invite_code`]) so a PDS running without the
/// invite gate still registers the account. A code that only
/// contains whitespace is treated as absent for the same reason —
/// the server's `invite::normalize` trims before looking it up, so
/// `" "` could never match a real code anyway, and passing it on
/// would only turn "you forgot the field" into "your code is
/// wrong".
pub async fn create_account(
&self,
handle: &str,
password: &str,
invite_code: Option<&str>,
) -> Result<AccountSession> {
let body = CreateAccountReq {
handle: handle.to_string(),
email: None,
password: password.to_string(),
invite_code: invite_code
.map(str::trim)
.filter(|c| !c.is_empty())
.map(str::to_string),
};
let r = self
.client
+8 -2
View File
@@ -545,17 +545,23 @@
// Used in the Settings view to show which backends the client is
// talking to. Kept as plain helpers so they can be swapped for a
// `pds_describe`/`appview_describe` Tauri command later.
// The fallbacks mirror `DEFAULT_PDS_URL` / `DEFAULT_APPVIEW_URL` in
// `src-tauri/src/lib.rs`: both services sit behind the one public
// origin, split by path prefix (`/xrpc/` → PDS, `/api/` → AppView).
// If those constants ever move, move these with them — a Settings
// pane that names the wrong backend is worse than one that names
// none.
function pdsBase(): string {
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_PDS_URL) {
return (import.meta as any).env.VITE_PDS_URL as string;
}
return "http://127.0.0.1:2583";
return "https://tweet.maarcade.com";
}
function appviewBase(): string {
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_APPVIEW_URL) {
return (import.meta as any).env.VITE_APPVIEW_URL as string;
}
return "http://127.0.0.1:2584";
return "https://tweet.maarcade.com";
}
</script>
+56 -2
View File
@@ -142,6 +142,21 @@ export function isAuthFailure(e: unknown): boolean {
);
}
/// True when the PDS refused a registration because of the invite
/// code. The public instance runs with `PDS_INVITE_REQUIRED=true` and
/// answers `400 {"error":"InvalidInviteCode","message":…}` — the same
/// code for a missing, misspelled, disabled and already-spent code, on
/// purpose: the server does not tell an unauthenticated caller which
/// of those it was, since that would make invite codes enumerable.
///
/// Matched on the string for the same reason as [`isTokenInvalid`]:
/// `pds_client.rs` bails with `createAccount failed: {status} {body}`
/// and `lib.rs` stringifies that into the command's `Err(String)`, so
/// the code travels verbatim across the IPC boundary.
function isInvalidInviteCode(e: unknown): boolean {
return errorText(e).includes("InvalidInviteCode");
}
/// User-facing copy for a failed call, in the app's German UI voice.
///
/// An auth failure gets a sentence naming the actual remedy. The raw
@@ -155,6 +170,18 @@ export function errorMessage(e: unknown): string {
if (isAuthFailure(e)) {
return "Sitzung abgelaufen oder abgelehnt — bitte neu anmelden.";
}
if (isInvalidInviteCode(e)) {
// Deliberately covers "no code given" too: the raw body a user
// would otherwise read is `createAccount failed: 400 Bad Request
// {"error":"InvalidInviteCode","message":"a valid invite code is
// required to create an account on this server"}`. Since the
// server refuses to say *which* way the code was wrong, the copy
// names both plausible fixes rather than guessing one.
return (
"Einladungscode ungültig oder bereits verbraucht — " +
"bitte prüfen oder einen neuen Code anfordern."
);
}
return String(e);
}
@@ -208,11 +235,38 @@ function createSessionStore() {
set(s);
return s;
},
async register(handle: string, password: string) {
/// Create an account on the configured PDS.
///
/// `inviteCode` is optional because the invite gate lives on the
/// *server* (`PDS_INVITE_REQUIRED`): the public instance at
/// `https://tweet.maarcade.com` requires a code, a local dev PDS
/// normally does not. The client therefore never refuses a
/// registration for a missing code on its own — it would break
/// development against localhost — it just forwards what the user
/// typed and lets the PDS decide.
///
/// An empty (or whitespace-only) field is *omitted*, not sent as
/// `""`. Those are two different statements: "I gave no code" vs.
/// "my code is the empty string". The first is legitimate against
/// an open server; the second is never true and would only turn
/// into a confusing `InvalidInviteCode` on a server that has the
/// gate switched off. Dropping the key leaves the Rust
/// `Option<String>` at `None`, and `create_account` then leaves
/// the field out of the JSON body entirely.
async register(handle: string, password: string, inviteCode?: string) {
if (!isTauri()) {
throw new Error("register requires the Tauri desktop runtime");
}
const s = await safeInvoke<Session>("auth_register", { handle, password });
const code = inviteCode?.trim();
const s = await safeInvoke<Session>("auth_register", {
handle,
password,
// Explicit `null` rather than a dropped key, matching how
// `createPost` passes its optional `embed` / `reply`: it
// deserialises into the Rust `Option<String>` as `None`
// without depending on how `invoke` treats `undefined`.
inviteCode: code ? code : null,
});
set(s);
return s;
},
+138
View File
@@ -0,0 +1,138 @@
// Unit tests for the invite-code half of the registration path.
//
// Same setup as `notifications.test.ts`: `@tauri-apps/api/core` is
// mocked so no Tauri shell is needed, and every assertion is about the
// exact command name + argument bag we hand the Rust IPC layer. That
// argument bag is the contract — `invoke` maps camelCase JS keys onto
// the snake_case Rust command parameters (`inviteCode` → `invite_code`
// on `auth_register`), so a typo here surfaces at runtime as a null
// argument, not at compile time.
//
// Covered:
// * `session.register` — the code is forwarded as `inviteCode`,
// alongside the unchanged `handle` / `password`;
// * the empty / whitespace-only field — must reach the shell as
// `null` ("no code given"), never as `""` ("my code is the empty
// string"), because a PDS without the invite gate has to keep
// accepting registrations;
// * `errorMessage` — the PDS's `InvalidInviteCode` body becomes
// German copy instead of the raw wire string.
//
// Run with:
// npx vitest run src/lib/api/invite.test.ts
import { beforeEach, describe, expect, it, vi } from "vitest";
const invokeMock = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => invokeMock(...args),
isTauri: () => true,
}));
/// What the Rust `auth_register` command answers with on success.
const SESSION = {
did: "did:plc:alice",
handle: "alice.tweet.maarcade.com",
access_jwt: "acc",
refresh_jwt: "ref",
};
beforeEach(() => {
invokeMock.mockReset();
});
describe("session.register", () => {
it("forwards the invite code as `inviteCode`", async () => {
const { session } = await import("./client");
invokeMock.mockResolvedValueOnce(SESSION);
const s = await session.register(
"alice.tweet.maarcade.com",
"hunter2hunter2",
"mt-7k3qw-z9d2m",
);
expect(invokeMock).toHaveBeenCalledTimes(1);
expect(invokeMock).toHaveBeenCalledWith("auth_register", {
handle: "alice.tweet.maarcade.com",
password: "hunter2hunter2",
inviteCode: "mt-7k3qw-z9d2m",
});
expect(s.did).toBe("did:plc:alice");
});
it("trims the surrounding whitespace off a pasted code", async () => {
const { session } = await import("./client");
invokeMock.mockResolvedValueOnce(SESSION);
// Copying a code out of a chat message routinely drags a space or
// a newline along. The server trims too (`invite::normalize`), but
// sending the untrimmed string would mean the *client* and the
// server disagree about whether the field is empty.
await session.register("alice.test", "pw", " mt-7k3qw-z9d2m\n");
expect(invokeMock.mock.calls[0][1]).toMatchObject({
inviteCode: "mt-7k3qw-z9d2m",
});
});
it("sends null — never an empty string — when the field is blank", async () => {
const { session } = await import("./client");
// Three ways the UI can hand us "nothing": the argument omitted
// entirely (login-shaped call), an untouched input, and an input
// holding only whitespace. All three mean "the user gave no code"
// and must arrive at the Rust `Option<String>` as `None`, so that
// a PDS running without `PDS_INVITE_REQUIRED` still registers the
// account instead of rejecting a blank code.
for (const blank of [undefined, "", " "]) {
invokeMock.mockReset();
invokeMock.mockResolvedValueOnce(SESSION);
await session.register("alice.test", "pw", blank);
const args = invokeMock.mock.calls[0][1] as Record<string, unknown>;
expect(args.inviteCode).toBeNull();
expect(args.inviteCode).not.toBe("");
// The rest of the bag is unaffected.
expect(args.handle).toBe("alice.test");
expect(args.password).toBe("pw");
}
});
});
describe("errorMessage for InvalidInviteCode", () => {
/// Exactly what crosses the IPC boundary when the PDS refuses the
/// code: `pds_client.rs` bails with `createAccount failed: {status}
/// {body}` and `lib.rs` stringifies that into `Err(String)`, which
/// `invoke` rejects with as a bare JS string.
const RAW =
'createAccount failed: 400 Bad Request {"error":"InvalidInviteCode",' +
'"message":"a valid invite code is required to create an account on this server"}';
it("replaces the raw 400 body with copy the user can act on", async () => {
const { errorMessage } = await import("./client");
const msg = errorMessage(RAW);
expect(msg).toContain("Einladungscode");
// None of the wire noise survives into the UI.
expect(msg).not.toContain("InvalidInviteCode");
expect(msg).not.toContain("400");
// Both shapes a rejected `invoke` can produce — a bare string and
// an Error — go through the same `errorText` normalisation.
expect(errorMessage(new Error(RAW))).toBe(msg);
});
it("leaves unrelated registration failures verbatim", async () => {
const { errorMessage } = await import("./client");
// A taken handle is a different 400 and has its own message worth
// showing; the invite branch must not swallow it.
expect(
errorMessage(
'createAccount failed: 400 Bad Request {"error":"HandleNotAvailable"}',
),
).toContain("HandleNotAvailable");
expect(errorMessage("createAccount failed: 500 db down")).toContain("500");
});
});
@@ -1,6 +1,11 @@
<script lang="ts">
import { onMount } from "svelte";
import { session, describeServer, type Session } from "../api/client";
import {
session,
describeServer,
errorMessage,
type Session,
} from "../api/client";
let { onLogin }: { onLogin: (s: Session) => void } = $props();
@@ -10,6 +15,9 @@
let mode: "login" | "register" = $state("login");
let handle: string = $state("");
let password: string = $state("");
// Only meaningful in "register" mode — the field below is rendered
// solely there, and `submit()` only forwards it on that branch.
let inviteCode: string = $state("");
let busy = $state(false);
let error: string | null = $state(null);
let serverInfo: any = $state(null);
@@ -27,16 +35,40 @@
busy = true;
error = null;
try {
// The invite code is deliberately *not* part of the guard above.
// Whether one is required is a server setting
// (`PDS_INVITE_REQUIRED`): the public instance demands a code, a
// dev PDS on localhost usually does not. Refusing to submit
// without one would make the client unusable against the second
// kind of server for a rule it cannot see. So we forward what
// the user typed — `session.register` drops an empty string
// instead of sending a blank code — and let the PDS answer.
const s = mode === "register"
? await session.register(handle, password)
? await session.register(handle, password, inviteCode)
: await session.login(handle, password);
onLogin(s);
} catch (e) {
error = String(e);
// `errorMessage` translates the failures worth naming — an
// expired session, and a rejected `InvalidInviteCode` — into
// German copy, and passes everything else through verbatim.
error = errorMessage(e);
} finally {
busy = false;
}
}
/// Clear the form's mode-specific state when switching sides.
///
/// Without this, a code typed while registering would linger in the
/// hidden field: switch to login, switch back, and the stale value
/// is silently submitted again. The error goes too — the message
/// from a failed registration says nothing about the login the user
/// is now attempting.
function toggleMode() {
mode = mode === "register" ? "login" : "register";
inviteCode = "";
error = null;
}
</script>
<div class="login">
@@ -75,6 +107,30 @@
autocomplete={mode === "register" ? "new-password" : "current-password"}
/>
</label>
{#if mode === "register"}
<!--
Registration only. Logging in never carries a code, and a
field that is present but meaningless invites people to
fill it in. `{#if}` removes it from the DOM rather than
hiding it, so it also drops out of the tab order.
-->
<label class="field">
<span class="key">einladungscode</span>
<input
type="text"
bind:value={inviteCode}
placeholder="mt-xxxxx-xxxxx"
disabled={busy}
onkeydown={(e) => e.key === "Enter" && submit()}
autocomplete="off"
autocapitalize="none"
spellcheck="false"
/>
<span class="hint">
// von dieser Instanz verlangt — ohne Code keine Registrierung
</span>
</label>
{/if}
</form>
{#if error}
<div class="err">err: {error}</div>
@@ -83,7 +139,7 @@
<button class="btn btn--primary" onclick={submit} disabled={busy || !handle || !password}>
{busy ? "..." : mode === "register" ? "create account" : "log in"}
</button>
<button class="btn btn--ghost" onclick={() => (mode = mode === "register" ? "login" : "register")} disabled={busy}>
<button class="btn btn--ghost" onclick={toggleMode} disabled={busy}>
{mode === "register" ? "have an account? log in" : "no account? register"}
</button>
</div>
@@ -172,6 +228,15 @@
font-size: var(--fs-50);
letter-spacing: var(--tracking-label);
}
/* Sub-label under the invite field. Same dim mono voice as the
`// pds: …` server meta line above the form, so it reads as a
comment on the field rather than as a second input label. */
.hint {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
opacity: 0.8;
}
.form input {
background: var(--bg);
border: 1px solid var(--line-2);
@@ -0,0 +1,231 @@
// Regression guard for the invite-code field on the login screen.
//
// The public instance runs the PDS with `PDS_INVITE_REQUIRED=true`, so
// `createAccount` without a code is refused with `400
// {"error":"InvalidInviteCode", …}`. Three things have to hold for the
// screen to be usable against it:
//
// 1. the field exists in "register" mode and *not* in "login" mode —
// a code is meaningless when signing in, and an input that is
// present but ignored invites people to fill it in;
// 2. what the user typed reaches `session.register` as its third
// argument, and an untouched field does not become a blank code;
// 3. a rejected code renders as German copy, not as the raw wire
// body, which is where the user would otherwise read
// `createAccount failed: 400 Bad Request {"error":…}`.
//
// Setup follows `NotificationsView.test.ts`: the component is mounted
// against jsdom with `../api/client` partially mocked — the real
// `errorMessage` is kept, since the error copy is part of what we are
// asserting on, and only the calls that would need a Tauri runtime are
// stubbed.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mount, unmount, tick } from "svelte";
const registerMock = vi.fn();
const loginMock = vi.fn();
const describeServerMock = vi.fn();
vi.mock("../api/client", async () => {
const actual =
await vi.importActual<typeof import("../api/client")>("../api/client");
return {
...actual,
// Keep the real `errorMessage` from `actual` — the German copy for
// `InvalidInviteCode` is exactly what test 3 checks.
describeServer: (...args: unknown[]) => describeServerMock(...args),
session: {
...actual.session,
register: (...args: unknown[]) => registerMock(...args),
login: (...args: unknown[]) => loginMock(...args),
},
};
});
import LoginScreen from "./LoginScreen.svelte";
let target: HTMLDivElement;
let app: ReturnType<typeof mount> | null = null;
const SESSION = {
did: "did:plc:alice",
handle: "alice.tweet.maarcade.com",
access_jwt: "acc",
refresh_jwt: "ref",
};
beforeEach(() => {
target = document.createElement("div");
document.body.appendChild(target);
registerMock.mockReset();
loginMock.mockReset();
describeServerMock.mockReset();
// `onMount` calls this; a resolved stub keeps the meta line quiet.
describeServerMock.mockResolvedValue({ did: "did:web:tweet.maarcade.com" });
});
afterEach(() => {
if (app) unmount(app);
app = null;
target.remove();
});
/// Let `onMount`, the mocked promises and Svelte's flush settle.
async function flush(turns = 6) {
for (let i = 0; i < turns; i++) {
await Promise.resolve();
await tick();
}
}
/// The screen has no test ids; the inputs are addressed the way a user
/// would, by the label text beside them.
function fieldByLabel(label: string): HTMLInputElement | null {
for (const el of target.querySelectorAll("label.field")) {
if (el.querySelector(".key")?.textContent?.trim() === label) {
return el.querySelector("input");
}
}
return null;
}
function typeInto(input: HTMLInputElement, value: string) {
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
}
/// Flip login ⇄ register via the ghost button under the form.
async function toggleMode() {
const buttons = [...target.querySelectorAll("button.btn--ghost")];
buttons[buttons.length - 1].dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
await tick();
}
async function submitForm() {
target
.querySelector("button.btn--primary")!
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
}
async function mountScreen() {
app = mount(LoginScreen, { target, props: { onLogin: vi.fn() } });
await flush();
}
describe("LoginScreen invite field", () => {
it("shows the code field only while registering", async () => {
await mountScreen();
// "login" is the default mode — no invite field, and nothing in
// the tab order either, since `{#if}` removes it from the DOM.
expect(fieldByLabel("einladungscode")).toBeNull();
await toggleMode();
expect(fieldByLabel("einladungscode")).not.toBeNull();
await toggleMode();
expect(fieldByLabel("einladungscode")).toBeNull();
});
it("passes the typed code to session.register as the third argument", async () => {
registerMock.mockResolvedValue(SESSION);
await mountScreen();
await toggleMode();
typeInto(fieldByLabel("handle")!, "alice.tweet.maarcade.com");
typeInto(fieldByLabel("password")!, "hunter2hunter2");
typeInto(fieldByLabel("einladungscode")!, "mt-7k3qw-z9d2m");
await submitForm();
expect(loginMock).not.toHaveBeenCalled();
expect(registerMock).toHaveBeenCalledTimes(1);
expect(registerMock).toHaveBeenCalledWith(
"alice.tweet.maarcade.com",
"hunter2hunter2",
"mt-7k3qw-z9d2m",
);
});
it("does not turn an untouched field into a blank code", async () => {
registerMock.mockResolvedValue(SESSION);
await mountScreen();
await toggleMode();
typeInto(fieldByLabel("handle")!, "alice.test");
typeInto(fieldByLabel("password")!, "hunter2hunter2");
// Invite field deliberately left alone. The submit must still go
// through: whether a code is required is the *server's* call
// (`PDS_INVITE_REQUIRED`), and a dev PDS on localhost runs without
// the gate. What must not happen is a blank code travelling on as
// if the user had entered one.
await submitForm();
expect(registerMock).toHaveBeenCalledTimes(1);
const code = registerMock.mock.calls[0][2];
expect(code === "" || code === undefined).toBe(true);
expect(code?.trim?.() ?? "").toBe("");
});
it("clears a typed code when switching back to login", async () => {
loginMock.mockResolvedValue(SESSION);
await mountScreen();
await toggleMode();
typeInto(fieldByLabel("handle")!, "alice.test");
typeInto(fieldByLabel("password")!, "hunter2hunter2");
typeInto(fieldByLabel("einladungscode")!, "mt-stale-code0");
// Back to login, then to register again: a stale code lingering in
// the hidden field would be submitted silently on the next try.
await toggleMode();
await toggleMode();
expect(fieldByLabel("einladungscode")!.value).toBe("");
});
it("renders German copy when the PDS rejects the code", async () => {
// Verbatim what reaches the component: `pds_client.rs` bails with
// `createAccount failed: {status} {body}`, `lib.rs` stringifies it
// into the command's `Err(String)`, and `invoke` rejects with that
// bare string.
registerMock.mockRejectedValue(
'createAccount failed: 400 Bad Request {"error":"InvalidInviteCode",' +
'"message":"a valid invite code is required to create an account on this server"}',
);
await mountScreen();
await toggleMode();
typeInto(fieldByLabel("handle")!, "alice.test");
typeInto(fieldByLabel("password")!, "hunter2hunter2");
typeInto(fieldByLabel("einladungscode")!, "mt-wrong-code0");
await submitForm();
const err = target.querySelector(".err");
expect(err).not.toBeNull();
expect(err!.textContent).toContain("Einladungscode");
// The wire noise the user would otherwise be shown is gone.
expect(err!.textContent).not.toContain("InvalidInviteCode");
expect(err!.textContent).not.toContain("400 Bad Request");
});
it("still shows an unrelated failure verbatim", async () => {
// The invite branch must not swallow every registration error —
// a taken handle has its own message worth reading.
registerMock.mockRejectedValue(
'createAccount failed: 400 Bad Request {"error":"HandleNotAvailable"}',
);
await mountScreen();
await toggleMode();
typeInto(fieldByLabel("handle")!, "alice.test");
typeInto(fieldByLabel("password")!, "hunter2hunter2");
await submitForm();
expect(target.querySelector(".err")!.textContent).toContain(
"HandleNotAvailable",
);
});
});