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:
co-authored by
Claude Opus 5
parent
f04d63dd7b
commit
73959f9dde
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user