Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 45 additions & 5 deletions apps/dashboard/app/members/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import StatusBadge from "@/components/StatusBadge";
import WalletAddressText from "@/components/WalletAddressText";
import UnsupportedBanner from "@/components/UnsupportedBanner";
import { ApiClientError, readApiResult } from "@/lib/api-client";
import { tryNormaliseAddress } from "@/lib/address";
import { getClientApiMode } from "@/lib/client-env";
import { useSession } from "@/lib/hooks/useSession";
import { useOptimisticMutation } from "@/lib/hooks/useOptimisticMutation";
Expand Down Expand Up @@ -101,6 +102,7 @@ function MembersPageContent() {
const [isInviteOpen, setIsInviteOpen] = useState(false);
const [inviteLoading, setInviteLoading] = useState(false);
const [form, setForm] = useState({ name: "", wallet: "" });
const [walletError, setWalletError] = useState<string | null>(null);

const updateFilterQuery = useCallback(
(updates: { search?: string; status?: MemberStatusFilter; role?: MemberRoleFilter; guild?: string; page?: number | null }) => {
Expand Down Expand Up @@ -477,25 +479,54 @@ function MembersPageContent() {
<h2 className="mb-4 text-lg font-semibold text-slate-900">Invite Member</h2>
<div className="space-y-3">
<input placeholder="Name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="w-full rounded-lg border border-slate-200 p-2" />
<input placeholder="Wallet" value={form.wallet} onChange={(e) => setForm({ ...form, wallet: e.target.value })} className="w-full rounded-lg border border-slate-200 p-2" />
<div>
<input
placeholder="Wallet"
value={form.wallet}
onChange={(e) => {
setForm({ ...form, wallet: e.target.value });
if (walletError) setWalletError(null);
}}
aria-invalid={walletError ? true : undefined}
aria-describedby={walletError ? "invite-wallet-error" : undefined}
className={`w-full rounded-lg border p-2 ${walletError ? "border-red-400" : "border-slate-200"}`}
/>
{walletError && (
<p id="invite-wallet-error" className="mt-1 text-xs text-red-600">
{walletError}
</p>
)}
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button onClick={() => setIsInviteOpen(false)} className="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-700">
<button
onClick={() => {
setIsInviteOpen(false);
setWalletError(null);
}}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-700"
>
Cancel
</button>
<button
disabled={inviteLoading}
className="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
onClick={async () => {
if (!form.name.trim()) return alert("Name is required");
if (!form.wallet.trim()) return alert("Wallet is required");

const normalizedWallet = tryNormaliseAddress(form.wallet);
if (!normalizedWallet) {
setWalletError("Enter a valid Ethereum wallet address (0x followed by 40 hex characters).");
return;
}
setWalletError(null);

try {
setInviteLoading(true);
const res = await guildFetch("/api/members", guildId, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: form.name.trim(), wallet: form.wallet.trim() }),
body: JSON.stringify({ name: form.name.trim(), wallet: normalizedWallet }),
});
const newMember = await readApiResult<MockMember>(res);
const safeMember = {
Expand All @@ -509,7 +540,16 @@ function MembersPageContent() {
setIsInviteOpen(false);
setForm({ name: "", wallet: "" });
} catch (error: unknown) {
alert(error instanceof Error ? error.message : "Failed to invite member.");
const walletFieldError =
error instanceof ApiClientError
? error.fields?.find((field) => field.field === "wallet")?.message
: undefined;

if (walletFieldError) {
setWalletError(walletFieldError);
} else {
alert(error instanceof Error ? error.message : "Failed to invite member.");
}
} finally {
setInviteLoading(false);
}
Expand Down
9 changes: 5 additions & 4 deletions apps/dashboard/components/WalletAddressText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState } from "react";
import { formatWalletAddress } from "@/lib/formatters/wallet";
import { copyToClipboard } from "@/lib/clipboard";

type WalletAddressTextProps = {
address?: string | null;
Expand All @@ -23,12 +24,12 @@ export default function WalletAddressText({
const truncated = formatWalletAddress(address, prefixLength, suffixLength);

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(address);
const success = await copyToClipboard(address);
if (success) {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch (error) {
console.warn("Failed to copy wallet address:", error);
} else {
console.warn("Failed to copy wallet address");
}
};

Expand Down
19 changes: 19 additions & 0 deletions apps/dashboard/lib/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,26 @@ export function normaliseAddress(addr: string): string {
return getAddress(addr);
}

/**
* Best-effort version of `normaliseAddress` for form inputs: trims whitespace
* and returns the checksummed address, or `null` if the value isn't a valid
* Ethereum address (never throws).
*/
export function tryNormaliseAddress(addr: string | null | undefined): string | null {
if (!addr || typeof addr !== "string") return null;

const trimmed = addr.trim();
if (!trimmed) return null;

try {
return getAddress(trimmed);
} catch {
return null;
}
}

export default {
isValidChecksumAddress,
normaliseAddress,
tryNormaliseAddress,
};
9 changes: 9 additions & 0 deletions apps/dashboard/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** Writes text to the clipboard, returning whether it succeeded (never throws). */
export async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
102 changes: 102 additions & 0 deletions apps/dashboard/test/address.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";

import { isValidChecksumAddress, normaliseAddress, tryNormaliseAddress } from "../lib/address";

const CHECKSUMMED = "0x742d35cC6634c0532925a3B8879539d43374E290";
const LOWERCASE = CHECKSUMMED.toLowerCase();
const UPPERCASE = "0x" + CHECKSUMMED.slice(2).toUpperCase();
const BAD_CHECKSUM = "0x742d35cc6634C0532925a3B8879539d43374E290"; // mixed case, wrong checksum
const MALFORMED = "0x123456"; // too short
const EMPTY = "";
const WHITESPACE = " ";

describe("isValidChecksumAddress", () => {
test("accepts an already-checksummed address", () => {
assert.equal(isValidChecksumAddress(CHECKSUMMED), true);
});

test("rejects an all-lowercase address (not checksummed)", () => {
assert.equal(isValidChecksumAddress(LOWERCASE), false);
});

test("rejects an all-uppercase address (not checksummed)", () => {
assert.equal(isValidChecksumAddress(UPPERCASE), false);
});

test("rejects a mixed-case address with an incorrect checksum", () => {
assert.equal(isValidChecksumAddress(BAD_CHECKSUM), false);
});

test("rejects a malformed address", () => {
assert.equal(isValidChecksumAddress(MALFORMED), false);
});

test("rejects an empty string", () => {
assert.equal(isValidChecksumAddress(EMPTY), false);
});
});

describe("normaliseAddress", () => {
test("returns the checksummed form of a lowercase address", () => {
assert.equal(normaliseAddress(LOWERCASE), CHECKSUMMED);
});

test("returns the checksummed form of an uppercase address", () => {
assert.equal(normaliseAddress(UPPERCASE), CHECKSUMMED);
});

test("returns an already-checksummed address unchanged", () => {
assert.equal(normaliseAddress(CHECKSUMMED), CHECKSUMMED);
});

test("normalises a mixed-case address with an incorrect checksum to the canonical form", () => {
// getAddress recomputes the checksum from the underlying hex digits — it
// corrects bad casing rather than treating it as invalid. Only
// `isValidChecksumAddress` rejects non-canonical casing.
assert.equal(normaliseAddress(BAD_CHECKSUM), CHECKSUMMED);
});

test("throws for a malformed address", () => {
assert.throws(() => normaliseAddress(MALFORMED));
});
});

describe("tryNormaliseAddress", () => {
test("normalises a valid lowercase address", () => {
assert.equal(tryNormaliseAddress(LOWERCASE), CHECKSUMMED);
});

test("normalises a valid uppercase address", () => {
assert.equal(tryNormaliseAddress(UPPERCASE), CHECKSUMMED);
});

test("normalises an already-checksummed address", () => {
assert.equal(tryNormaliseAddress(CHECKSUMMED), CHECKSUMMED);
});

test("normalises a mixed-case address with an incorrect checksum to the canonical form", () => {
assert.equal(tryNormaliseAddress(BAD_CHECKSUM), CHECKSUMMED);
});

test("returns null for a malformed address", () => {
assert.equal(tryNormaliseAddress(MALFORMED), null);
});

test("returns null for an empty string", () => {
assert.equal(tryNormaliseAddress(EMPTY), null);
});

test("returns null for whitespace-only input", () => {
assert.equal(tryNormaliseAddress(WHITESPACE), null);
});

test("trims surrounding whitespace before normalising", () => {
assert.equal(tryNormaliseAddress(` ${LOWERCASE} `), CHECKSUMMED);
});

test("returns null for null or undefined input", () => {
assert.equal(tryNormaliseAddress(null), null);
assert.equal(tryNormaliseAddress(undefined), null);
});
});
69 changes: 69 additions & 0 deletions apps/dashboard/test/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";

import { copyToClipboard } from "../lib/clipboard";

const WALLET = "0x742d35cC6634c0532925a3B8879539d43374E290";

/**
* `navigator` is a getter-only global in Node, so it must be overridden via
* `Object.defineProperty` (a plain assignment throws in strict-mode ESM).
*/
async function withStubbedNavigator<T>(stub: unknown, run: () => Promise<T>): Promise<T> {
const original = Object.getOwnPropertyDescriptor(globalThis, "navigator");

Object.defineProperty(globalThis, "navigator", {
value: stub,
configurable: true,
writable: true,
});

try {
return await run();
} finally {
if (original) {
Object.defineProperty(globalThis, "navigator", original);
}
}
}

describe("copyToClipboard", () => {
test("returns true and writes the given text when the clipboard API succeeds", async () => {
const written: string[] = [];

const result = await withStubbedNavigator(
{
clipboard: {
writeText: async (text: string) => {
written.push(text);
},
},
},
() => copyToClipboard(WALLET)
);

assert.equal(result, true);
assert.deepEqual(written, [WALLET]);
});

test("returns false when the clipboard API rejects", async () => {
const result = await withStubbedNavigator(
{
clipboard: {
writeText: async () => {
throw new Error("Clipboard permission denied");
},
},
},
() => copyToClipboard(WALLET)
);

assert.equal(result, false);
});

test("returns false when clipboard access is unavailable", async () => {
const result = await withStubbedNavigator({}, () => copyToClipboard(WALLET));

assert.equal(result, false);
});
});