Skip to content

Commit e251265

Browse files
thephezclaude
andauthored
feat(dashnote): preview identity + key-type fitness on WIF paste (#75)
* feat(dashnote): accept WIF private key login with auto-detected secret shape Login form now accepts either a BIP39 mnemonic or a WIF private key in a single field, dispatching by whitespace presence. WIF flow looks up the identity via byPublicKeyHash, validates the matched key is an AUTHENTICATION key at HIGH or CRITICAL level (rejecting MASTER which can't sign documents), and rejects disabled keys. Failed logins no longer clobber prior session state — they restore the prior status, or fall back to browsing if a remembered identity exists. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(dashnote): preview identity + key-type fitness on WIF paste Eagerly resolve the identity (and DPNS name) for a pasted WIF once it passes a cheap structural gate (length 51/52, base58 charset) plus a 400ms debounce. Surface "wrong key type" / "key disabled" warnings pre-submit so users see the issue before clicking Login. Splits a signer-free `resolveIdentityFromWif` out of `loginWithPrivateKey` for shared use; wrong-purpose errors now also carry the security-level name so MASTER auth keys read distinctly from purpose mismatches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(dashnote): clarify login secret label and surface key-type requirement Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(dashnote): defer loginWithPrivateKey to keep SDK out of app shell The eager-preview hook and SessionContext were statically importing loginWithPrivateKey, which transitively pulls @dashevo/evo-sdk (~8MB WASM) into the entry chunk and defeats the lazy-load strategy. Switch both to dynamic imports gated behind the post-debounce timer (hook) and the WIF login branch (context); rename SessionValue.login's parameter mnemonic→secret to match the implementation since the prior WIF-login PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(dashnote): tighten secret retention and session-recovery semantics - Drop the module-scoped previewCache in useWifPreview: cached raw WIFs survived modal close/reopen for the page lifetime, extending secret retention beyond the form. Resolution state is now strictly component-local. - Move the loadLoginModule() await inside the try in useWifPreview so a chunk-fetch failure collapses to idle instead of stranding the preview on "checking". - Restore the active session in SessionContext's login catch path when priorStatus is "authenticated": typing a bad secret while signed in no longer demotes the user to browsing, even when a remembered identity is on disk. Switch flows already logout() before login() and are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8f0fb0a commit e251265

12 files changed

Lines changed: 1921 additions & 55 deletions

example-apps/dashnote/src/components/LoginModal.tsx

Lines changed: 88 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { useEffect, useState, type FormEvent } from "react";
1+
import { useEffect, useMemo, useState, type FormEvent } from "react";
22

33
import { registerContract } from "../dash/contract";
4+
import { useWifPreview } from "../hooks/useWifPreview";
5+
import { detectSecretShape } from "../lib/detectSecretShape";
46
import { errorMessage } from "../lib/logger";
57
import { useSession } from "../session/useSession";
68
import { Modal } from "./Modal";
@@ -13,7 +15,7 @@ export interface LoginModalProps {
1315

1416
export function LoginModal({ open, onClose }: LoginModalProps) {
1517
const session = useSession();
16-
const [mnemonic, setMnemonic] = useState("");
18+
const [secret, setSecret] = useState("");
1719
const [identityIndex, setIdentityIndex] = useState("0");
1820
const [contractInput, setContractInput] = useState(session.contractId ?? "");
1921
const [error, setError] = useState<string | null>(null);
@@ -26,6 +28,17 @@ export function LoginModal({ open, onClose }: LoginModalProps) {
2628
const showRememberedPanel = Boolean(
2729
session.rememberedIdentityId && !useDifferentIdentity,
2830
);
31+
// Detect what the user pasted so we can hide the identity-index field for
32+
// single-key WIF input (where DIP-13 derivation doesn't apply).
33+
const secretShape = useMemo(
34+
() => (secret.trim() ? detectSecretShape(secret) : null),
35+
[secret],
36+
);
37+
const isWifInput = secretShape === "wif";
38+
const wifPreview = useWifPreview(session.sdk, secret, isWifInput);
39+
const previewBlocksLogin =
40+
wifPreview.status === "wrong-purpose" ||
41+
wifPreview.status === "key-disabled";
2942

3043
useEffect(() => {
3144
setContractInput(session.contractId ?? "");
@@ -36,7 +49,7 @@ export function LoginModal({ open, onClose }: LoginModalProps) {
3649
setRememberMe(true);
3750
setUseDifferentIdentity(false);
3851
setError(null);
39-
setMnemonic("");
52+
setSecret("");
4053
}
4154
}, [open]);
4255

@@ -69,11 +82,11 @@ export function LoginModal({ open, onClose }: LoginModalProps) {
6982
setSubmitting(true);
7083
try {
7184
const index = Number.parseInt(identityIndex, 10);
72-
await session.login(mnemonic, {
85+
await session.login(secret, {
7386
identityIndex: Number.isNaN(index) ? 0 : index,
7487
rememberMe,
7588
});
76-
setMnemonic("");
89+
setSecret("");
7790
onClose();
7891
} catch (err) {
7992
setError(errorMessage(err));
@@ -240,7 +253,9 @@ export function LoginModal({ open, onClose }: LoginModalProps) {
240253

241254
<label className="flex flex-col gap-1">
242255
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-ink-4">
243-
{showRememberedPanel ? "Mnemonic" : "Identity Mnemonic"}
256+
{showRememberedPanel
257+
? "Mnemonic or Private Key"
258+
: "Identity Mnemonic or Private Key"}
244259
</span>
245260
{!showRememberedPanel && (
246261
<p className="text-[11px] text-ink-3">
@@ -260,15 +275,58 @@ export function LoginModal({ open, onClose }: LoginModalProps) {
260275
type="password"
261276
autoComplete="off"
262277
required
263-
value={mnemonic}
264-
onChange={(event) => setMnemonic(event.target.value)}
265-
placeholder={
266-
showRememberedPanel
267-
? "Enter the mnemonic for this identity"
268-
: "mnemonic phrase"
269-
}
278+
value={secret}
279+
onChange={(event) => setSecret(event.target.value)}
280+
placeholder="Mnemonic phrase or WIF private key (high/critical)"
270281
className="rounded-md border border-line bg-bg px-3 py-2 text-[13px] text-ink outline-none transition focus:border-accent-dim"
271282
/>
283+
{isWifInput && wifPreview.status !== "idle" && (
284+
<div
285+
data-testid="wif-preview"
286+
aria-live="polite"
287+
className="mt-1 min-h-[1.5em] text-[11px]"
288+
>
289+
{wifPreview.status === "checking" && (
290+
<span className="text-ink-4">Checking…</span>
291+
)}
292+
{wifPreview.status === "resolved" && (
293+
<span className="text-ink-3">
294+
✓ Identity{" "}
295+
<span className="font-mono text-accent">
296+
{wifPreview.dpnsName
297+
? `${wifPreview.dpnsName}.dash`
298+
: `${wifPreview.identityId.slice(0, 8)}${wifPreview.identityId.slice(-4)}`}
299+
</span>
300+
</span>
301+
)}
302+
{wifPreview.status === "wrong-purpose" && (
303+
<span className="text-[color:var(--color-danger)]">
304+
Found identity{" "}
305+
<span className="font-mono">
306+
{wifPreview.dpnsName
307+
? `${wifPreview.dpnsName}.dash`
308+
: `${wifPreview.identityId.slice(0, 8)}${wifPreview.identityId.slice(-4)}`}
309+
</span>
310+
, but this is a{" "}
311+
{wifPreview.purposeName === "AUTHENTICATION"
312+
? `${wifPreview.securityLevelName} authentication`
313+
: wifPreview.purposeName}{" "}
314+
key — paste a HIGH or CRITICAL authentication key instead.
315+
</span>
316+
)}
317+
{wifPreview.status === "key-disabled" && (
318+
<span className="text-[color:var(--color-danger)]">
319+
The matching key on identity{" "}
320+
<span className="font-mono">
321+
{wifPreview.dpnsName
322+
? `${wifPreview.dpnsName}.dash`
323+
: `${wifPreview.identityId.slice(0, 8)}${wifPreview.identityId.slice(-4)}`}
324+
</span>{" "}
325+
has been disabled.
326+
</span>
327+
)}
328+
</div>
329+
)}
272330
</label>
273331

274332
{session.rememberedIdentityId && (
@@ -320,18 +378,20 @@ export function LoginModal({ open, onClose }: LoginModalProps) {
320378

321379
{showAdvanced && (
322380
<div className="flex flex-col gap-4 rounded-md border border-line bg-bg/40 p-3">
323-
<label className="flex flex-col gap-1">
324-
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-ink-4">
325-
Identity index
326-
</span>
327-
<input
328-
type="number"
329-
min={0}
330-
value={identityIndex}
331-
onChange={(event) => setIdentityIndex(event.target.value)}
332-
className="w-24 rounded-md border border-line bg-bg px-3 py-2 text-[13px] text-ink outline-none transition focus:border-accent-dim"
333-
/>
334-
</label>
381+
{!isWifInput && (
382+
<label className="flex flex-col gap-1">
383+
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-ink-4">
384+
Identity index
385+
</span>
386+
<input
387+
type="number"
388+
min={0}
389+
value={identityIndex}
390+
onChange={(event) => setIdentityIndex(event.target.value)}
391+
className="w-24 rounded-md border border-line bg-bg px-3 py-2 text-[13px] text-ink outline-none transition focus:border-accent-dim"
392+
/>
393+
</label>
394+
)}
335395

336396
<div className="flex flex-col gap-2">
337397
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-ink-4">
@@ -363,15 +423,14 @@ export function LoginModal({ open, onClose }: LoginModalProps) {
363423
)}
364424

365425
<p className="text-[11px] text-ink-4">
366-
Your mnemonic stays in browser memory and is never sent or saved.
367-
Only the public identity ID is persisted, and only when “Remember
368-
me” is checked.
426+
Your secret never leaves this browser. Only the public identity ID
427+
is stored when this identity is remembered on this device.
369428
</p>
370429

371430
<div className="flex gap-2 pt-1">
372431
<button
373432
type="submit"
374-
disabled={submitting || !mnemonic.trim()}
433+
disabled={submitting || !secret.trim() || previewBlocksLogin}
375434
className="flex-1 rounded-md bg-accent px-4 py-2 text-[13px] font-semibold text-bg transition hover:bg-accent-dim disabled:cursor-not-allowed disabled:bg-surface-2 disabled:text-ink-4"
376435
>
377436
{submitting ? "Connecting…" : "Login"}

0 commit comments

Comments
 (0)