release(v4.0.0): Shade GA — V3.x consolidation + audit prep
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers byte-for-byte. The version bump is semantic: audit-cycle complete, opt-in surface fully exposed, threat model refreshed for every new surface. Highlights: - All 24 @shade/* packages bumped to 4.0.0 in lockstep. - CHANGELOG 4.0.0 section is the canonical manifest of what landed. - THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12 Web-Worker boundary) + residual-risks table refreshed. - OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox, bridge, observer, /metrics, /healthz, /ready. - MIGRATION 0.3.x → 4.0 documented + smoke-tested against shade migrate-storage on a real SQLite DB. - docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer. - scripts/soak.ts harness for the GA-stable 2-week soak window. - All V*.md plans archived under docs/archive/ with Status: Done. - Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen non-realtime stack. Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green. Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports version 4.0.0 on /health. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "@shade/widgets",
|
||||
"version": "0.3.0",
|
||||
"version": "4.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@shade/recovery": "workspace:*",
|
||||
"@shade/sdk": "workspace:*",
|
||||
"@shade/streams": "workspace:*",
|
||||
"@shade/transfer": "workspace:*"
|
||||
|
||||
@@ -2,19 +2,51 @@ import React, { useState } from 'react';
|
||||
import { useShadeState } from '../useShadeState.js';
|
||||
import { useShadeContext } from '../ShadeProvider.js';
|
||||
import { WidgetShell } from './shared.js';
|
||||
import { formatOobText } from './FingerprintGate.js';
|
||||
|
||||
export interface FingerprintCompareProps {
|
||||
/**
|
||||
* Optional callback invoked when the user clicks "I have verified" after
|
||||
* confirming a peer's fingerprint matches. The widget passes the
|
||||
* normalized fingerprint that was compared. Wire this to
|
||||
* `Shade.markPeerVerified(address)` in your app.
|
||||
*/
|
||||
onVerified?: (fingerprint: string) => void | Promise<void>;
|
||||
/** Optional peer address to display alongside the OOB-copy text. */
|
||||
peerAddress?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FingerprintCompare — paste a fingerprint and check if it matches your own
|
||||
* or any active session.
|
||||
* (or, when wired with `onVerified`, manually accept a peer's fingerprint).
|
||||
*
|
||||
* V3.3: extended with "Copy OOB text" + "I have verified" actions so the
|
||||
* widget can drive `Shade.markPeerVerified` directly.
|
||||
*/
|
||||
export function FingerprintCompare(): React.ReactElement {
|
||||
export function FingerprintCompare(props: FingerprintCompareProps = {}): React.ReactElement {
|
||||
const { state } = useShadeState();
|
||||
const { theme } = useShadeContext();
|
||||
const [input, setInput] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const normalized = input.replace(/\s+/g, ' ').trim();
|
||||
const yourFp = state?.identity.fingerprint?.replace(/\s+/g, ' ').trim();
|
||||
const matches = normalized && yourFp && normalized === yourFp;
|
||||
const matches = normalized.length > 0 && yourFp !== undefined && normalized === yourFp;
|
||||
|
||||
const copyOob = async () => {
|
||||
if (yourFp === undefined) return;
|
||||
const text = formatOobText(props.peerAddress ?? 'me', yourFp);
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText !== undefined) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
const verify = async () => {
|
||||
if (normalized.length === 0) return;
|
||||
await props.onVerified?.(normalized);
|
||||
};
|
||||
|
||||
return (
|
||||
<WidgetShell title="Verify fingerprint">
|
||||
@@ -38,7 +70,7 @@ export function FingerprintCompare(): React.ReactElement {
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
{normalized && (
|
||||
{normalized.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
@@ -54,6 +86,50 @@ export function FingerprintCompare(): React.ReactElement {
|
||||
{matches ? '✓ Matches your identity' : '✗ Does not match your identity'}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyOob()}
|
||||
disabled={yourFp === undefined}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
color: theme.text,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: 6,
|
||||
padding: '6px 12px',
|
||||
fontFamily: theme.font,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: yourFp === undefined ? 'not-allowed' : 'pointer',
|
||||
opacity: yourFp === undefined ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy OOB text'}
|
||||
</button>
|
||||
{props.onVerified !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void verify()}
|
||||
disabled={normalized.length === 0}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: theme.accent,
|
||||
color: theme.bg,
|
||||
border: `1px solid ${theme.accent}`,
|
||||
borderRadius: 6,
|
||||
padding: '6px 12px',
|
||||
fontFamily: theme.font,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: normalized.length === 0 ? 'not-allowed' : 'pointer',
|
||||
opacity: normalized.length === 0 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
I have verified
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WidgetShell>
|
||||
);
|
||||
|
||||
213
packages/shade-widgets/src/components/FingerprintGate.tsx
Normal file
213
packages/shade-widgets/src/components/FingerprintGate.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import type { Shade } from '@shade/sdk';
|
||||
import { useShadeRuntime, useShadeRuntimeTheme } from '../ShadeRuntimeProvider.js';
|
||||
import type { ShadeTheme } from '../theme.js';
|
||||
|
||||
export interface FingerprintGateProps {
|
||||
/** The peer address that the gated UI talks to. */
|
||||
peerAddress: string;
|
||||
/**
|
||||
* Optional render-prop that replaces the default "verify first" UI.
|
||||
* Receives the peer's safety number and a callback that marks the peer
|
||||
* verified (after the user confirmed OOB).
|
||||
*/
|
||||
fallback?: (props: FingerprintGateFallbackProps) => React.ReactNode;
|
||||
/** Called when the peer transitions from unverified → verified. */
|
||||
onVerified?: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface FingerprintGateFallbackProps {
|
||||
peerAddress: string;
|
||||
fingerprint: string;
|
||||
/** Mark the peer verified at the current fingerprint. */
|
||||
verify: () => Promise<void>;
|
||||
/** Copy a copy-pasteable OOB text for the safety number. */
|
||||
copyOob: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `<FingerprintGate />` — blocks rendering of `children` until the
|
||||
* `peerAddress` has been verified at its current fingerprint. Pairs with
|
||||
* the V3.3 `Shade.beforeFirstLargeFile` / `Shade.beforeBackupImport` /
|
||||
* `Shade.beforeNewDeviceTrust` hooks to expose verification in UI flows
|
||||
* outside the SDK-driven gates.
|
||||
*
|
||||
* SSR-safe: the gate fetches state inside `useEffect`, so initial render
|
||||
* shows nothing until the runtime resolves verification status.
|
||||
*/
|
||||
export function FingerprintGate(props: FingerprintGateProps): React.ReactElement | null {
|
||||
const runtime = useShadeRuntime();
|
||||
const theme = useShadeRuntimeTheme();
|
||||
const status = useGateStatus(runtime, props.peerAddress);
|
||||
|
||||
const verify = useCallback(async () => {
|
||||
await runtime.markPeerVerified(props.peerAddress);
|
||||
status.refresh();
|
||||
}, [runtime, props.peerAddress, status]);
|
||||
|
||||
const copyOob = useCallback(async () => {
|
||||
if (status.fingerprint === null) return;
|
||||
await copyToClipboard(formatOobText(props.peerAddress, status.fingerprint));
|
||||
}, [props.peerAddress, status.fingerprint]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.verified === true) props.onVerified?.();
|
||||
}, [status.verified, props.onVerified, props]);
|
||||
|
||||
if (status.verified === null) return null; // SSR / first paint
|
||||
if (status.verified === true) return <>{props.children}</>;
|
||||
if (status.fingerprint === null) return null; // session not ready yet
|
||||
|
||||
if (props.fallback !== undefined) {
|
||||
return (
|
||||
<>{props.fallback({
|
||||
peerAddress: props.peerAddress,
|
||||
fingerprint: status.fingerprint,
|
||||
verify,
|
||||
copyOob,
|
||||
})}</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaultFallback
|
||||
peerAddress={props.peerAddress}
|
||||
fingerprint={status.fingerprint}
|
||||
theme={theme}
|
||||
verify={verify}
|
||||
copyOob={copyOob}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface GateStatus {
|
||||
verified: boolean | null;
|
||||
fingerprint: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
function useGateStatus(runtime: Shade, peerAddress: string): GateStatus {
|
||||
const [verified, setVerified] = useState<boolean | null>(null);
|
||||
const [fingerprint, setFingerprint] = useState<string | null>(null);
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const fp = await runtime.getFingerprintFor(peerAddress);
|
||||
if (cancelled) return;
|
||||
setFingerprint(fp);
|
||||
const v = await runtime.isPeerVerified(peerAddress);
|
||||
if (cancelled) return;
|
||||
setVerified(v);
|
||||
} catch {
|
||||
// No session yet — leave both null so the component renders nothing
|
||||
// until a session is established.
|
||||
if (cancelled) return;
|
||||
setFingerprint(null);
|
||||
setVerified(null);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [runtime, peerAddress, tick]);
|
||||
|
||||
const refresh = useCallback(() => setTick((n) => n + 1), []);
|
||||
return { verified, fingerprint, refresh };
|
||||
}
|
||||
|
||||
function DefaultFallback(props: {
|
||||
peerAddress: string;
|
||||
fingerprint: string;
|
||||
theme: ShadeTheme;
|
||||
verify: () => Promise<void>;
|
||||
copyOob: () => Promise<void>;
|
||||
}): React.ReactElement {
|
||||
const { theme } = props;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: theme.bg,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
color: theme.text,
|
||||
fontFamily: theme.font,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 6 }}>
|
||||
Verify {props.peerAddress} before continuing
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: theme.textMuted, marginBottom: 12 }}>
|
||||
Compare the safety number below over a side channel (call, SMS, in person).
|
||||
Approve only after both ends match.
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
background: theme.bgElevated,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
fontFamily: theme.fontMono,
|
||||
fontSize: 13,
|
||||
color: theme.text,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{props.fingerprint}
|
||||
</pre>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void props.copyOob()}
|
||||
style={buttonStyle(theme, 'secondary')}
|
||||
>
|
||||
Copy OOB text
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void props.verify()}
|
||||
style={buttonStyle(theme, 'primary')}
|
||||
>
|
||||
I have verified
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buttonStyle(theme: ShadeTheme, variant: 'primary' | 'secondary'): React.CSSProperties {
|
||||
const isPrimary = variant === 'primary';
|
||||
return {
|
||||
background: isPrimary ? theme.accent : 'transparent',
|
||||
color: isPrimary ? theme.bg : theme.text,
|
||||
border: `1px solid ${isPrimary ? theme.accent : theme.border}`,
|
||||
borderRadius: 6,
|
||||
padding: '6px 12px',
|
||||
fontFamily: theme.font,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the OOB text that gets copied to the clipboard. Includes the peer
|
||||
* address so the recipient knows which conversation it belongs to.
|
||||
*/
|
||||
export function formatOobText(peerAddress: string, fingerprint: string): string {
|
||||
return `Shade safety number for ${peerAddress}:\n${fingerprint}`;
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string): Promise<void> {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText !== undefined) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
// Headless / SSR: silently no-op.
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* `<RecoveryApprove />` — guardian-side widget for V3.10 social key
|
||||
* recovery.
|
||||
*
|
||||
* Renders one approval card per pending recovery request. Each card
|
||||
* shows:
|
||||
* - Who is asking (the requester's Shade address).
|
||||
* - The original (lost) device's setup-time fingerprint.
|
||||
* - The new device's TEMPORARY fingerprint — the value the user
|
||||
* MUST verify OOB before approving.
|
||||
*
|
||||
* The widget enforces V3.10 acceptance criterion #3: an approval is
|
||||
* blocked behind a "fingerprint matches" checkbox AND an "I have
|
||||
* verified OOB" checkbox before the green button is even clickable.
|
||||
*
|
||||
* The widget does NOT call `attachGuardian` itself — instead, the host
|
||||
* application calls `attachGuardian({ approve: ... })` and forwards
|
||||
* the approval into a queue this component consumes via the
|
||||
* `pending` + `onResolve` props. That way the same approve channel
|
||||
* can be shared between this UI and any out-of-app surface (push
|
||||
* notification, OS-level prompt, hardware confirmation, etc.).
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { GuardianApproveContext } from '@shade/recovery';
|
||||
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
|
||||
|
||||
export interface PendingApproval {
|
||||
/** Stable id used to correlate approve/decline with the deferred promise. */
|
||||
id: string;
|
||||
/** Recovery context handed to the approve handler. */
|
||||
ctx: GuardianApproveContext;
|
||||
}
|
||||
|
||||
export interface RecoveryApproveProps {
|
||||
/** Pending requests waiting on the user. Caller updates this list. */
|
||||
pending: ReadonlyArray<PendingApproval>;
|
||||
/**
|
||||
* Called when the user picks a side. The host wires this to resolve
|
||||
* the deferred promise the `attachGuardian` approve callback is
|
||||
* blocked on.
|
||||
*/
|
||||
onResolve: (id: string, decision: boolean) => void;
|
||||
}
|
||||
|
||||
export function RecoveryApprove(props: RecoveryApproveProps): React.ReactElement {
|
||||
const theme = useShadeRuntimeTheme();
|
||||
if (props.pending.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
borderRadius: theme.radius,
|
||||
border: `1px dashed ${theme.border}`,
|
||||
color: theme.textMuted,
|
||||
fontSize: 12,
|
||||
fontFamily: theme.font,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
No pending recovery requests.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{props.pending.map((p) => (
|
||||
<ApprovalCard key={p.id} approval={p} onResolve={props.onResolve} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalCard(props: {
|
||||
approval: PendingApproval;
|
||||
onResolve: (id: string, decision: boolean) => void;
|
||||
}): React.ReactElement {
|
||||
const theme = useShadeRuntimeTheme();
|
||||
const { ctx } = props.approval;
|
||||
const [matches, setMatches] = useState(false);
|
||||
const [confirmedOob, setConfirmedOob] = useState(false);
|
||||
|
||||
const requestedAt = useMemo(() => new Date(ctx.requestReceivedAt).toISOString(), [ctx]);
|
||||
const setupAt = useMemo(() => new Date(ctx.depositCreatedAt).toISOString(), [ctx]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: theme.bgElevated,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: theme.radius,
|
||||
padding: 16,
|
||||
color: theme.text,
|
||||
fontFamily: theme.font,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>
|
||||
Recovery request for {ctx.originalAddress}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: theme.textMuted, marginBottom: 10 }}>
|
||||
Requester: <span style={{ fontFamily: theme.fontMono }}>{ctx.requesterAddress}</span>
|
||||
<br />
|
||||
Original deposit: {setupAt}, request received: {requestedAt}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ fontSize: 11, color: theme.textMuted, marginBottom: 4 }}>
|
||||
Original device fingerprint (at setup):
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
background: theme.bg,
|
||||
border: `1px solid ${theme.border}`,
|
||||
fontFamily: theme.fontMono,
|
||||
fontSize: 12,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{ctx.setupFingerprint}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ fontSize: 11, color: theme.textMuted, marginBottom: 4 }}>
|
||||
New device fingerprint (verify OOB before approving):
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
background: theme.bg,
|
||||
border: `1px solid ${theme.accent}`,
|
||||
fontFamily: theme.fontMono,
|
||||
fontSize: 12,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{ctx.requesterFingerprint}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, marginBottom: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={matches}
|
||||
onChange={(e) => setMatches(e.target.checked)}
|
||||
/>
|
||||
I confirm the new device's fingerprint matches what the requester read aloud.
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, marginBottom: 12 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmedOob}
|
||||
onChange={(e) => setConfirmedOob(e.target.checked)}
|
||||
/>
|
||||
I verified the requester's identity over a side channel (call, in person), not Shade itself.
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onResolve(props.approval.id, false)}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
color: theme.text,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: 6,
|
||||
padding: '8px 14px',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!matches || !confirmedOob}
|
||||
onClick={() => props.onResolve(props.approval.id, true)}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: matches && confirmedOob ? theme.accent : 'transparent',
|
||||
color: matches && confirmedOob ? theme.bg : theme.textMuted,
|
||||
border: `1px solid ${matches && confirmedOob ? theme.accent : theme.border}`,
|
||||
borderRadius: 6,
|
||||
padding: '8px 14px',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: matches && confirmedOob ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
Release share
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny helper that turns the `attachGuardian({ approve })` callback
|
||||
* shape into a deferred queue the `<RecoveryApprove />` widget can
|
||||
* consume. Returns a tuple of:
|
||||
*
|
||||
* - `approve(ctx)`: the function to pass to `attachGuardian`.
|
||||
* - `pending`: live list of pending approvals (consumer is expected
|
||||
* to mirror this into React state via subscribe()).
|
||||
* - `subscribe(listener)`: register for change notifications.
|
||||
* - `resolve(id, decision)`: the value the widget calls.
|
||||
*/
|
||||
export function createApprovalQueue(): {
|
||||
approve: (ctx: GuardianApproveContext) => Promise<boolean>;
|
||||
pending: () => PendingApproval[];
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
resolve: (id: string, decision: boolean) => void;
|
||||
} {
|
||||
const queue: Array<{
|
||||
id: string;
|
||||
ctx: GuardianApproveContext;
|
||||
resolve: (decision: boolean) => void;
|
||||
}> = [];
|
||||
const listeners = new Set<() => void>();
|
||||
const notify = (): void => {
|
||||
for (const l of listeners) l();
|
||||
};
|
||||
|
||||
return {
|
||||
approve: (ctx) =>
|
||||
new Promise<boolean>((resolveDecision) => {
|
||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
queue.push({ id, ctx, resolve: resolveDecision });
|
||||
notify();
|
||||
}),
|
||||
pending: () => queue.map((q) => ({ id: q.id, ctx: q.ctx })),
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
resolve: (id, decision) => {
|
||||
const idx = queue.findIndex((q) => q.id === id);
|
||||
if (idx === -1) return;
|
||||
const [entry] = queue.splice(idx, 1);
|
||||
entry!.resolve(decision);
|
||||
notify();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* `<RecoveryRequest />` — new-device widget for V3.10 social key
|
||||
* recovery.
|
||||
*
|
||||
* The user provides the recovery-card values they recorded at setup
|
||||
* time (originalAddress, setupId, threshold, guardian list). The
|
||||
* widget shows the new device's TEMPORARY identity fingerprint
|
||||
* prominently — the user reads it OOB to each guardian to authorize
|
||||
* the share release — then runs `requestRecovery` and reports
|
||||
* progress as guardians respond.
|
||||
*
|
||||
* On success the widget surfaces the restored fingerprint so the
|
||||
* user can sanity-check it against the value on the recovery card
|
||||
* before continuing in the app.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
RecoveryDeclinedError,
|
||||
RecoveryReconstructionError,
|
||||
RecoveryTimeoutError,
|
||||
requestRecovery,
|
||||
type RecoveryDeliver,
|
||||
type RecoveryProgress,
|
||||
type RecoveryResult,
|
||||
} from '@shade/recovery';
|
||||
import { useShadeRuntime, useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
|
||||
import { formatOobText } from '../FingerprintGate.js';
|
||||
|
||||
export interface RecoveryRequestProps {
|
||||
/** Address of the original (lost) identity. From the recovery card. */
|
||||
originalAddress: string;
|
||||
/** Setup id from the recovery card. */
|
||||
setupId: string;
|
||||
/** Reconstruction threshold. From the recovery card. */
|
||||
threshold: number;
|
||||
/** Guardian addresses to query. Caller can preselect or let user choose. */
|
||||
guardians: ReadonlyArray<string>;
|
||||
/** Outbound transport callback. */
|
||||
deliver: RecoveryDeliver;
|
||||
/** Override timeout in ms (defaults to 5 min). */
|
||||
timeoutMs?: number;
|
||||
/** Fired on successful recovery. */
|
||||
onComplete?: (result: RecoveryResult) => void;
|
||||
/** Fired on terminal failure. */
|
||||
onError?: (err: Error) => void;
|
||||
}
|
||||
|
||||
type Phase =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'running'; progress: RecoveryProgress | null }
|
||||
| { kind: 'done'; result: RecoveryResult }
|
||||
| { kind: 'failed'; error: Error };
|
||||
|
||||
export function RecoveryRequest(props: RecoveryRequestProps): React.ReactElement {
|
||||
const runtime = useShadeRuntime();
|
||||
const theme = useShadeRuntimeTheme();
|
||||
const [phase, setPhase] = useState<Phase>({ kind: 'idle' });
|
||||
const [tempFingerprint, setTempFingerprint] = useState<string | null>(null);
|
||||
const [copyHit, setCopyHit] = useState(false);
|
||||
const guardianList = useMemo(() => [...props.guardians], [props.guardians]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const fp = await runtime.fingerprint;
|
||||
if (!cancelled) setTempFingerprint(fp);
|
||||
} catch {
|
||||
if (!cancelled) setTempFingerprint(null);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [runtime]);
|
||||
|
||||
const start = async (): Promise<void> => {
|
||||
setPhase({ kind: 'running', progress: null });
|
||||
try {
|
||||
const result = await requestRecovery({
|
||||
shade: runtime,
|
||||
originalAddress: props.originalAddress,
|
||||
setupId: props.setupId,
|
||||
threshold: props.threshold,
|
||||
guardians: guardianList,
|
||||
deliver: props.deliver,
|
||||
...(props.timeoutMs !== undefined ? { timeoutMs: props.timeoutMs } : {}),
|
||||
onProgress: (progress) => setPhase({ kind: 'running', progress }),
|
||||
});
|
||||
setPhase({ kind: 'done', result });
|
||||
props.onComplete?.(result);
|
||||
} catch (err) {
|
||||
setPhase({ kind: 'failed', error: err as Error });
|
||||
props.onError?.(err as Error);
|
||||
}
|
||||
};
|
||||
|
||||
const copyOob = async (): Promise<void> => {
|
||||
if (tempFingerprint === null) return;
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText !== undefined) {
|
||||
const text = formatOobText(runtime.myAddress, tempFingerprint);
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopyHit(true);
|
||||
setTimeout(() => setCopyHit(false), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: theme.bgElevated,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: theme.radius,
|
||||
padding: 16,
|
||||
color: theme.text,
|
||||
fontFamily: theme.font,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8 }}>
|
||||
Recover identity for {props.originalAddress}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: theme.textMuted, marginBottom: 10 }}>
|
||||
Read the safety number below to each guardian over a side channel (call, in person)
|
||||
BEFORE asking them to approve. They will only release their share if your fingerprint
|
||||
matches what they verify.
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${theme.border}`,
|
||||
background: theme.bg,
|
||||
fontFamily: theme.fontMono,
|
||||
fontSize: 12,
|
||||
marginBottom: 10,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{tempFingerprint ?? 'Loading temporary fingerprint…'}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyOob()}
|
||||
disabled={tempFingerprint === null}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
color: theme.text,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: 6,
|
||||
padding: '6px 10px',
|
||||
fontSize: 12,
|
||||
cursor: tempFingerprint === null ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{copyHit ? 'Copied!' : 'Copy OOB text'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={phase.kind === 'running' || tempFingerprint === null}
|
||||
onClick={() => void start()}
|
||||
style={{
|
||||
background: theme.accent,
|
||||
color: theme.bg,
|
||||
border: `1px solid ${theme.accent}`,
|
||||
borderRadius: 6,
|
||||
padding: '6px 12px',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: phase.kind === 'running' ? 'not-allowed' : 'pointer',
|
||||
opacity: phase.kind === 'running' ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{phase.kind === 'running' ? 'Requesting…' : 'Request recovery'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{phase.kind === 'running' && phase.progress !== null && (
|
||||
<ProgressLine progress={phase.progress} theme={theme} />
|
||||
)}
|
||||
|
||||
{phase.kind === 'done' && (
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${theme.success}`,
|
||||
background: 'rgba(34, 197, 94, 0.08)',
|
||||
color: theme.text,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, color: theme.success, marginBottom: 4 }}>
|
||||
✓ Identity restored
|
||||
</div>
|
||||
<div>
|
||||
Restored fingerprint:
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fontMono,
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{phase.result.restoredFingerprint}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 11, color: theme.textMuted }}>
|
||||
Compare this against the value on your recovery card before continuing.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase.kind === 'failed' && (
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${theme.danger}`,
|
||||
background: 'rgba(239, 68, 68, 0.08)',
|
||||
color: theme.danger,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{failureHeadline(phase.error)}
|
||||
</div>
|
||||
<div style={{ color: theme.text, fontSize: 11 }}>{phase.error.message}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressLine(props: {
|
||||
progress: RecoveryProgress;
|
||||
theme: ReturnType<typeof useShadeRuntimeTheme>;
|
||||
}): React.ReactElement {
|
||||
const { progress, theme } = props;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${theme.border}`,
|
||||
background: theme.bg,
|
||||
fontSize: 12,
|
||||
color: theme.text,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
Granted: {progress.granted} / {progress.threshold} • Declined: {progress.declined} •
|
||||
Pending: {progress.pending}
|
||||
</div>
|
||||
{progress.fromAddress !== undefined && (
|
||||
<div style={{ color: theme.textMuted, fontSize: 11, marginTop: 4 }}>
|
||||
{progress.fromAddress}: {progress.latest === 'grant' ? 'granted' : 'declined'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function failureHeadline(err: Error): string {
|
||||
if (err instanceof RecoveryDeclinedError) return 'Too many guardians declined';
|
||||
if (err instanceof RecoveryTimeoutError) return 'Timed out waiting for guardians';
|
||||
if (err instanceof RecoveryReconstructionError)
|
||||
return 'Reconstruction failed — at least one share is invalid';
|
||||
return 'Recovery failed';
|
||||
}
|
||||
280
packages/shade-widgets/src/components/recovery/RecoverySetup.tsx
Normal file
280
packages/shade-widgets/src/components/recovery/RecoverySetup.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* `<RecoverySetup />` — primary-device widget for V3.10 social key
|
||||
* recovery setup.
|
||||
*
|
||||
* Lets the user pick guardians, choose a threshold (k-of-n), and run
|
||||
* `setupRecovery` from `@shade/recovery`. The component is fully
|
||||
* controlled — guardian addresses come from a prop the consumer
|
||||
* populates from their address book; the widget never tries to read
|
||||
* the host app's contact list directly.
|
||||
*
|
||||
* After a successful run, the widget displays the `setupId` and the
|
||||
* setup-time fingerprint with a "Copy recovery card" action so the
|
||||
* user can record the data they'll need to retrieve their identity
|
||||
* later (setupId, threshold, guardian addresses, original
|
||||
* fingerprint).
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
setupRecovery,
|
||||
type RecoveryDeliver,
|
||||
type SetupRecoveryResult,
|
||||
} from '@shade/recovery';
|
||||
import { useShadeRuntime, useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
|
||||
|
||||
export interface RecoverySetupProps {
|
||||
/**
|
||||
* Candidate guardian directory. The widget shows them as toggleable
|
||||
* chips. Consumer is expected to source these from the host app's
|
||||
* existing peer / contacts list.
|
||||
*/
|
||||
candidates: ReadonlyArray<{ address: string; label?: string }>;
|
||||
/**
|
||||
* Outbound transport callback — same shape as `setupRecovery({
|
||||
* deliver })`. Wires through the host app's message-delivery layer.
|
||||
*/
|
||||
deliver: RecoveryDeliver;
|
||||
/**
|
||||
* Optional list of peer addresses whose Double-Ratchet sessions
|
||||
* should be embedded in the backup blob. Defaults to `[]` (identity +
|
||||
* prekeys only, no sessions).
|
||||
*/
|
||||
knownAddresses?: ReadonlyArray<string>;
|
||||
/**
|
||||
* Default threshold proposed in the slider. Defaults to
|
||||
* `Math.ceil(candidates.length / 2)`.
|
||||
*/
|
||||
defaultThreshold?: number;
|
||||
/** Fired after a successful setup completes. */
|
||||
onComplete?: (result: SetupRecoveryResult) => void;
|
||||
/** Fired if setup throws. */
|
||||
onError?: (err: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary-device widget. Use inside `<ShadeRuntimeProvider runtime>`.
|
||||
*/
|
||||
export function RecoverySetup(props: RecoverySetupProps): React.ReactElement {
|
||||
const runtime = useShadeRuntime();
|
||||
const theme = useShadeRuntimeTheme();
|
||||
const candidates = useMemo(() => [...props.candidates], [props.candidates]);
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||||
const [threshold, setThreshold] = useState<number>(
|
||||
() => props.defaultThreshold ?? Math.ceil(Math.max(1, candidates.length / 2)),
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<SetupRecoveryResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const guardianList = candidates.filter((c) => selected.has(c.address)).map((c) => c.address);
|
||||
const validThreshold = threshold >= 1 && threshold <= guardianList.length;
|
||||
const canRun = !busy && guardianList.length >= 1 && validThreshold;
|
||||
|
||||
const toggle = (addr: string): void => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(addr)) next.delete(addr);
|
||||
else next.add(addr);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
if (!canRun) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await setupRecovery({
|
||||
shade: runtime,
|
||||
guardians: guardianList,
|
||||
threshold,
|
||||
deliver: props.deliver,
|
||||
knownAddresses: [...(props.knownAddresses ?? [])],
|
||||
});
|
||||
setResult(r);
|
||||
props.onComplete?.(r);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
setError(message);
|
||||
props.onError?.(err as Error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyCard = async (): Promise<void> => {
|
||||
if (result === null) return;
|
||||
const text = formatRecoveryCard(runtime.myAddress, result, guardianList);
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText !== undefined) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: theme.bgElevated,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: theme.radius,
|
||||
padding: 16,
|
||||
color: theme.text,
|
||||
fontFamily: theme.font,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8 }}>
|
||||
Set up social key recovery
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: theme.textMuted, marginBottom: 12 }}>
|
||||
Pick guardians (people who can help you recover this identity if you lose your device)
|
||||
and a threshold. Any threshold-many guardians together can restore — fewer cannot.
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||
{candidates.map((c) => {
|
||||
const on = selected.has(c.address);
|
||||
return (
|
||||
<button
|
||||
key={c.address}
|
||||
type="button"
|
||||
onClick={() => toggle(c.address)}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
fontFamily: theme.fontMono,
|
||||
border: `1px solid ${on ? theme.accent : theme.border}`,
|
||||
background: on ? theme.accent : 'transparent',
|
||||
color: on ? theme.bg : theme.text,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{c.label ?? c.address}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ fontSize: 12, color: theme.textMuted, display: 'block', marginBottom: 4 }}>
|
||||
Threshold (k-of-{Math.max(1, guardianList.length)}): {threshold}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={Math.max(1, guardianList.length)}
|
||||
step={1}
|
||||
value={Math.min(threshold, Math.max(1, guardianList.length))}
|
||||
onChange={(e) => setThreshold(parseInt(e.target.value, 10))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRun}
|
||||
onClick={() => void run()}
|
||||
style={{
|
||||
background: theme.accent,
|
||||
color: theme.bg,
|
||||
border: `1px solid ${theme.accent}`,
|
||||
borderRadius: 6,
|
||||
padding: '8px 14px',
|
||||
fontFamily: theme.font,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: canRun ? 'pointer' : 'not-allowed',
|
||||
opacity: canRun ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
{busy ? 'Distributing shares…' : `Distribute ${threshold}-of-${guardianList.length}`}
|
||||
</button>
|
||||
|
||||
{error !== null && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${theme.danger}`,
|
||||
background: 'rgba(239, 68, 68, 0.08)',
|
||||
color: theme.danger,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result !== null && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${theme.success}`,
|
||||
background: 'rgba(34, 197, 94, 0.08)',
|
||||
color: theme.text,
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, color: theme.success }}>
|
||||
✓ Distributed to {result.deliveries.filter((d) => d.error === null).length} of {result.guardianCount}
|
||||
</div>
|
||||
<div>
|
||||
setupId: <span style={{ fontFamily: theme.fontMono }}>{result.setupId}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11 }}>
|
||||
Save this id (or click "Copy recovery card") together with the guardian list. You'll
|
||||
need both on the new device.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyCard()}
|
||||
style={{
|
||||
alignSelf: 'flex-start',
|
||||
background: 'transparent',
|
||||
color: theme.text,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: 6,
|
||||
padding: '6px 10px',
|
||||
fontSize: 11,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Copy recovery card
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the human-readable "recovery card" the user should record on
|
||||
* paper / a second device. Plain text on purpose — no QR, no JSON —
|
||||
* so the user can transcribe it manually if needed.
|
||||
*/
|
||||
export function formatRecoveryCard(
|
||||
ownAddress: string,
|
||||
result: SetupRecoveryResult,
|
||||
guardians: ReadonlyArray<string>,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
lines.push('Shade recovery card');
|
||||
lines.push('=====================');
|
||||
lines.push(`Owner: ${ownAddress}`);
|
||||
lines.push(`Threshold: ${result.threshold} of ${result.guardianCount}`);
|
||||
lines.push(`SetupId: ${result.setupId}`);
|
||||
lines.push(`Setup fingerprint: ${result.setupFingerprint}`);
|
||||
lines.push('Guardians:');
|
||||
for (const g of guardians) lines.push(` - ${g}`);
|
||||
lines.push('');
|
||||
lines.push('Keep this card somewhere safe but accessible. You will need ALL of these');
|
||||
lines.push('values on a new device to begin recovery.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
isAllowedThumbnailMime,
|
||||
THUMBNAIL_MAX_BYTES,
|
||||
type ThumbnailMime,
|
||||
} from '@shade/sdk';
|
||||
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
|
||||
|
||||
export interface ThumbnailPreviewProps {
|
||||
/** Bytes from `useThumbnail(streamId)`. */
|
||||
bytes: Uint8Array | null;
|
||||
/** Declared MIME — must be in the allowlist or the preview is blanked. */
|
||||
mime: string | null;
|
||||
/** Side length in CSS px (square). Default 48. */
|
||||
size?: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
/** Override the placeholder shown when no thumbnail is available. */
|
||||
placeholder?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* V3.9 — safe thumbnail renderer.
|
||||
*
|
||||
* Defends against malicious image bytes by:
|
||||
* - refusing to render anything outside `THUMBNAIL_MIME_ALLOWLIST`,
|
||||
* - capping displayed sizes at `THUMBNAIL_MAX_BYTES` (defensive — the
|
||||
* cache already enforces this at write-time, but the prop boundary
|
||||
* is the last chance to catch a caller passing arbitrary bytes),
|
||||
* - rendering through a Blob URL inside an `<img>` so the browser's
|
||||
* own image-decoding sandbox is the rendering trust boundary, and
|
||||
* - revoking the Blob URL on unmount so a long-running app doesn't
|
||||
* leak handles.
|
||||
*/
|
||||
export function ThumbnailPreview({
|
||||
bytes,
|
||||
mime,
|
||||
size,
|
||||
className,
|
||||
style,
|
||||
placeholder,
|
||||
}: ThumbnailPreviewProps): React.ReactElement {
|
||||
const theme = useShadeRuntimeTheme();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const lastUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastUrlRef.current !== null) {
|
||||
URL.revokeObjectURL(lastUrlRef.current);
|
||||
lastUrlRef.current = null;
|
||||
}
|
||||
if (
|
||||
bytes === null ||
|
||||
mime === null ||
|
||||
!isAllowedThumbnailMime(mime) ||
|
||||
bytes.byteLength === 0 ||
|
||||
bytes.byteLength > THUMBNAIL_MAX_BYTES
|
||||
) {
|
||||
setUrl(null);
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([bytes as BlobPart], { type: mime });
|
||||
const next = URL.createObjectURL(blob);
|
||||
lastUrlRef.current = next;
|
||||
setUrl(next);
|
||||
return () => {
|
||||
if (lastUrlRef.current === next) {
|
||||
URL.revokeObjectURL(next);
|
||||
lastUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [bytes, mime]);
|
||||
|
||||
const edge = size ?? 48;
|
||||
const baseStyle: React.CSSProperties = {
|
||||
width: edge,
|
||||
height: edge,
|
||||
flexShrink: 0,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: theme.bg,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: theme.radius,
|
||||
overflow: 'hidden',
|
||||
color: theme.textMuted,
|
||||
fontSize: 11,
|
||||
...style,
|
||||
};
|
||||
|
||||
if (url === null) {
|
||||
return (
|
||||
<div className={className} style={baseStyle}>
|
||||
{placeholder ?? <span aria-hidden>—</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={className} style={baseStyle}>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
width={edge}
|
||||
height={edge}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import React from 'react';
|
||||
import type { TransferHandle, TransferProgress } from '@shade/sdk';
|
||||
import type {
|
||||
StreamFileMetadata,
|
||||
TransferHandle,
|
||||
TransferProgress,
|
||||
} from '@shade/sdk';
|
||||
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
|
||||
import { useThumbnail } from '../../useShadeThumbnails.js';
|
||||
import { ProgressBar } from './ProgressBar.js';
|
||||
import { SpeedReadout } from './SpeedReadout.js';
|
||||
import { ETAReadout } from './ETAReadout.js';
|
||||
import { LaneIndicator } from './LaneIndicator.js';
|
||||
import { ThumbnailPreview } from './ThumbnailPreview.js';
|
||||
|
||||
export interface TransferRowProps {
|
||||
handle: TransferHandle;
|
||||
@@ -17,6 +23,22 @@ export interface TransferRowProps {
|
||||
error?: unknown;
|
||||
onCancel?: () => void;
|
||||
onDismiss?: () => void;
|
||||
/**
|
||||
* V3.9 — render a 48×48 preview to the left of the row when the main
|
||||
* stream's `fileMetadata` references a thumbnail-stream that has
|
||||
* arrived in the cache. Off by default (rows stay compact in chat
|
||||
* lists); turn it on per row, or wrap in `<ShadeThumbnailProvider />`
|
||||
* + flip on at the row level for a file-browser-style UI.
|
||||
*/
|
||||
showThumbnail?: boolean;
|
||||
/**
|
||||
* V3.9 — `fileMetadata` from the main `stream-init`. Required when
|
||||
* `showThumbnail` is true; the row reads `thumbnailStreamId` /
|
||||
* `thumbnailHash` to consult the cache.
|
||||
*/
|
||||
fileMetadata?: StreamFileMetadata;
|
||||
/** Thumbnail edge in CSS px (default 48). */
|
||||
thumbnailSize?: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
@@ -31,14 +53,26 @@ export function TransferRow({
|
||||
error,
|
||||
onCancel,
|
||||
onDismiss,
|
||||
showThumbnail,
|
||||
fileMetadata,
|
||||
thumbnailSize,
|
||||
className,
|
||||
style,
|
||||
}: TransferRowProps): React.ReactElement {
|
||||
const theme = useShadeRuntimeTheme();
|
||||
const percent = progress?.percent ?? (bytesTotal !== undefined && progress !== null ? progress.bytesSent / bytesTotal : undefined);
|
||||
const percent =
|
||||
progress?.percent ??
|
||||
(bytesTotal !== undefined && progress !== null ? progress.bytesSent / bytesTotal : undefined);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : error !== undefined ? String(error) : null;
|
||||
|
||||
const thumb = useThumbnail(
|
||||
showThumbnail === true ? fileMetadata?.thumbnailStreamId : undefined,
|
||||
showThumbnail === true ? fileMetadata?.thumbnailHash : undefined,
|
||||
);
|
||||
|
||||
const displayName = name ?? fileMetadata?.filename ?? handle.streamId;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
@@ -56,19 +90,32 @@ export function TransferRow({
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
color: errorMessage !== null ? theme.danger : theme.text,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={name ?? handle.streamId}
|
||||
>
|
||||
{name ?? handle.streamId}
|
||||
</span>
|
||||
{showThumbnail === true ? (
|
||||
<ThumbnailPreview
|
||||
bytes={thumb?.bytes ?? null}
|
||||
mime={thumb?.mime ?? null}
|
||||
size={thumbnailSize ?? 48}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: errorMessage !== null ? theme.danger : theme.text,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={displayName}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
{fileMetadata?.mimeType !== undefined && fileMetadata.mimeType.length > 0 ? (
|
||||
<span style={{ fontSize: 11, color: theme.textMuted }}>
|
||||
{fileMetadata.mimeType}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{progress !== null ? (
|
||||
<LaneIndicator lanes={progress.lanes} />
|
||||
) : null}
|
||||
|
||||
@@ -9,6 +9,12 @@ export { PrekeyStock } from './components/PrekeyStock.js';
|
||||
export { RecentActivity } from './components/RecentActivity.js';
|
||||
export { ServerStatus } from './components/ServerStatus.js';
|
||||
export { FingerprintCompare } from './components/FingerprintCompare.js';
|
||||
export type { FingerprintCompareProps } from './components/FingerprintCompare.js';
|
||||
export { FingerprintGate, formatOobText } from './components/FingerprintGate.js';
|
||||
export type {
|
||||
FingerprintGateProps,
|
||||
FingerprintGateFallbackProps,
|
||||
} from './components/FingerprintGate.js';
|
||||
export { WidgetCatalog } from './components/WidgetCatalog.js';
|
||||
|
||||
export type { ShadeProviderProps, ShadeContextValue } from './ShadeProvider.js';
|
||||
@@ -43,6 +49,14 @@ export { DropZone } from './components/transfer/DropZone.js';
|
||||
export type { DropZoneProps } from './components/transfer/DropZone.js';
|
||||
export { TransferRow } from './components/transfer/TransferRow.js';
|
||||
export type { TransferRowProps } from './components/transfer/TransferRow.js';
|
||||
export { ThumbnailPreview } from './components/transfer/ThumbnailPreview.js';
|
||||
export type { ThumbnailPreviewProps } from './components/transfer/ThumbnailPreview.js';
|
||||
export {
|
||||
ShadeThumbnailProvider,
|
||||
useThumbnailCache,
|
||||
useThumbnail,
|
||||
} from './useShadeThumbnails.js';
|
||||
export type { ThumbnailProviderProps } from './useShadeThumbnails.js';
|
||||
export { ProgressBar } from './components/transfer/ProgressBar.js';
|
||||
export type { ProgressBarProps } from './components/transfer/ProgressBar.js';
|
||||
export { SpeedReadout, formatBytesPerSecond } from './components/transfer/SpeedReadout.js';
|
||||
@@ -51,3 +65,20 @@ export { ETAReadout, formatEta } from './components/transfer/ETAReadout.js';
|
||||
export type { ETAReadoutProps } from './components/transfer/ETAReadout.js';
|
||||
export { LaneIndicator } from './components/transfer/LaneIndicator.js';
|
||||
export type { LaneIndicatorProps } from './components/transfer/LaneIndicator.js';
|
||||
|
||||
// ─── Social Key Recovery widgets (V3.10) ────────────────
|
||||
export {
|
||||
RecoverySetup,
|
||||
formatRecoveryCard,
|
||||
} from './components/recovery/RecoverySetup.js';
|
||||
export type { RecoverySetupProps } from './components/recovery/RecoverySetup.js';
|
||||
export { RecoveryRequest } from './components/recovery/RecoveryRequest.js';
|
||||
export type { RecoveryRequestProps } from './components/recovery/RecoveryRequest.js';
|
||||
export {
|
||||
RecoveryApprove,
|
||||
createApprovalQueue,
|
||||
} from './components/recovery/RecoveryApprove.js';
|
||||
export type {
|
||||
RecoveryApproveProps,
|
||||
PendingApproval,
|
||||
} from './components/recovery/RecoveryApprove.js';
|
||||
|
||||
@@ -4,8 +4,12 @@ import type {
|
||||
TransferHandle,
|
||||
TransferOutput,
|
||||
TransferProgress,
|
||||
TransferResult,
|
||||
ThumbnailMime,
|
||||
} from '@shade/sdk';
|
||||
import { isAllowedThumbnailMime } from '@shade/sdk';
|
||||
import { useShadeRuntime } from './ShadeRuntimeProvider.js';
|
||||
import { useThumbnailCache } from './useShadeThumbnails.js';
|
||||
|
||||
export interface DownloadEntry {
|
||||
incoming: IncomingTransfer;
|
||||
@@ -43,6 +47,7 @@ export function useShadeDownload(
|
||||
options: UseShadeDownloadOptions = {},
|
||||
): UseShadeDownloadResult {
|
||||
const shade = useShadeRuntime();
|
||||
const thumbnailCache = useThumbnailCache();
|
||||
const [pending, setPending] = useState<DownloadEntry[]>([]);
|
||||
const [active, setActive] = useState<DownloadEntry[]>([]);
|
||||
|
||||
@@ -102,6 +107,39 @@ export function useShadeDownload(
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const cleanup = await shade.onIncomingTransfer(async (incoming) => {
|
||||
// V3.9 — auto-accept thumbnail streams into the in-memory cache.
|
||||
// Sender marks them with `userMetadata.shadeThumbnail === '1'`.
|
||||
if (incoming.metadata.userMetadata?.shadeThumbnail === '1') {
|
||||
const handle = await incoming.accept({ output: { kind: 'buffer' } });
|
||||
void handle
|
||||
.done()
|
||||
.then((res: TransferResult) => {
|
||||
const bytes = (res as TransferResult & { bytes?: Uint8Array }).bytes;
|
||||
const declaredMime = incoming.metadata.contentType;
|
||||
if (
|
||||
bytes !== undefined &&
|
||||
declaredMime !== undefined &&
|
||||
isAllowedThumbnailMime(declaredMime)
|
||||
) {
|
||||
thumbnailCache.put(
|
||||
incoming.streamId,
|
||||
bytes,
|
||||
declaredMime as ThumbnailMime,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* preview failures are non-fatal */
|
||||
});
|
||||
return;
|
||||
}
|
||||
// V3.9 — when the main `stream-init` arrives, register the
|
||||
// expected thumbnail hash so a previously-arrived (but not yet
|
||||
// verified) thumbnail can be matched + made visible to widgets.
|
||||
const fm = incoming.metadata.fileMetadata;
|
||||
if (fm?.thumbnailStreamId !== undefined && fm.thumbnailHash !== undefined) {
|
||||
thumbnailCache.setExpectedHash(fm.thumbnailStreamId, fm.thumbnailHash);
|
||||
}
|
||||
if (options.autoAccept !== undefined) {
|
||||
const output = await options.autoAccept(incoming);
|
||||
if (output !== null) {
|
||||
@@ -126,7 +164,7 @@ export function useShadeDownload(
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [shade, options.autoAccept, trackHandle]);
|
||||
}, [shade, options.autoAccept, trackHandle, thumbnailCache]);
|
||||
|
||||
const accept = useCallback(
|
||||
async (streamId: string, output: TransferOutput): Promise<TransferHandle> => {
|
||||
|
||||
88
packages/shade-widgets/src/useShadeThumbnails.ts
Normal file
88
packages/shade-widgets/src/useShadeThumbnails.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* V3.9 — thumbnail-cache hook for `<TransferRow showThumbnail />`.
|
||||
*
|
||||
* Wires a single shared `ShadeThumbnailCache` into the React tree, lazily
|
||||
* created on first use, and exposes a tracked `useThumbnail(streamId)`
|
||||
* helper that re-renders consumers when their thumbnail arrives.
|
||||
*
|
||||
* The cache itself lives outside React state so it survives Strict-Mode
|
||||
* double-mounts and unmounted-component thumbnail arrivals without a
|
||||
* memory leak — entries evict on LRU + size cap, not on component
|
||||
* lifecycle.
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
createElement,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { ShadeThumbnailCache, type ThumbnailHit } from '@shade/sdk';
|
||||
|
||||
const ThumbnailCacheContext = createContext<ShadeThumbnailCache | null>(null);
|
||||
|
||||
export interface ThumbnailProviderProps {
|
||||
/** Optional shared cache. When omitted the provider creates one. */
|
||||
cache?: ShadeThumbnailCache;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a shared `ShadeThumbnailCache` to the subtree. Place once near
|
||||
* the root of your app (typically just inside `<ShadeRuntimeProvider />`)
|
||||
* so every transfer row consults the same cache.
|
||||
*/
|
||||
export function ShadeThumbnailProvider({
|
||||
cache,
|
||||
children,
|
||||
}: ThumbnailProviderProps): ReactElement {
|
||||
const value = useMemo(() => cache ?? new ShadeThumbnailCache(), [cache]);
|
||||
return createElement(
|
||||
ThumbnailCacheContext.Provider,
|
||||
{ value },
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the in-context thumbnail cache, lazily constructing one if no
|
||||
* provider is mounted (so widgets don't have to demand the provider).
|
||||
*/
|
||||
export function useThumbnailCache(): ShadeThumbnailCache {
|
||||
const ctx = useContext(ThumbnailCacheContext);
|
||||
// Module-level fallback when no provider is mounted — single instance
|
||||
// shared across all `useThumbnail()` calls in that scenario.
|
||||
return ctx ?? fallbackCache();
|
||||
}
|
||||
|
||||
let fallback: ShadeThumbnailCache | null = null;
|
||||
function fallbackCache(): ShadeThumbnailCache {
|
||||
if (fallback === null) fallback = new ShadeThumbnailCache();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached thumbnail bytes + mime for `streamId`, or `null`
|
||||
* when nothing is cached yet. Subscribes the calling component to cache
|
||||
* changes so it re-renders on arrival.
|
||||
*/
|
||||
export function useThumbnail(
|
||||
streamId: string | null | undefined,
|
||||
expectedHash?: string,
|
||||
): ThumbnailHit | null {
|
||||
const cache = useThumbnailCache();
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (streamId === null || streamId === undefined) return;
|
||||
if (expectedHash !== undefined) cache.setExpectedHash(streamId, expectedHash);
|
||||
return cache.onChange((changed) => {
|
||||
if (changed === streamId) setTick((n) => n + 1);
|
||||
});
|
||||
}, [cache, streamId, expectedHash]);
|
||||
if (streamId === null || streamId === undefined) return null;
|
||||
return cache.get(streamId, expectedHash);
|
||||
}
|
||||
81
packages/shade-widgets/tests/recovery.test.ts
Normal file
81
packages/shade-widgets/tests/recovery.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { formatRecoveryCard } from '../src/components/recovery/RecoverySetup.js';
|
||||
import { createApprovalQueue } from '../src/components/recovery/RecoveryApprove.js';
|
||||
|
||||
describe('formatRecoveryCard', () => {
|
||||
test('renders all fields the user must keep', () => {
|
||||
const text = formatRecoveryCard(
|
||||
'alice@example',
|
||||
{
|
||||
setupId: 'sid-123',
|
||||
threshold: 3,
|
||||
guardianCount: 5,
|
||||
deliveries: [],
|
||||
allDelivered: true,
|
||||
setupFingerprint: '11111 22222 33333 44444 55555 66666 77777 88888 99999 00000 11111 22222',
|
||||
},
|
||||
['bob@example', 'carol@example', 'dan@example', 'eve@example', 'faythe@example'],
|
||||
);
|
||||
expect(text).toContain('alice@example');
|
||||
expect(text).toContain('Threshold: 3 of 5');
|
||||
expect(text).toContain('SetupId: sid-123');
|
||||
expect(text).toContain('Setup fingerprint:');
|
||||
expect(text).toContain('bob@example');
|
||||
expect(text).toContain('faythe@example');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createApprovalQueue', () => {
|
||||
test('queues approve calls and resolves on user decision', async () => {
|
||||
const q = createApprovalQueue();
|
||||
const approvePromise = q.approve({
|
||||
requesterAddress: 'alice2',
|
||||
originalAddress: 'alice',
|
||||
setupId: 'sid-1',
|
||||
requesterFingerprint: 'aaa',
|
||||
setupFingerprint: 'bbb',
|
||||
depositCreatedAt: 0,
|
||||
requestReceivedAt: 1,
|
||||
});
|
||||
const pending = q.pending();
|
||||
expect(pending.length).toBe(1);
|
||||
q.resolve(pending[0]!.id, true);
|
||||
const decision = await approvePromise;
|
||||
expect(decision).toBe(true);
|
||||
expect(q.pending().length).toBe(0);
|
||||
});
|
||||
|
||||
test('decline path resolves false', async () => {
|
||||
const q = createApprovalQueue();
|
||||
const p = q.approve({
|
||||
requesterAddress: 'eve',
|
||||
originalAddress: 'alice',
|
||||
setupId: 'sid-1',
|
||||
requesterFingerprint: 'aaa',
|
||||
setupFingerprint: 'bbb',
|
||||
depositCreatedAt: 0,
|
||||
requestReceivedAt: 1,
|
||||
});
|
||||
q.resolve(q.pending()[0]!.id, false);
|
||||
expect(await p).toBe(false);
|
||||
});
|
||||
|
||||
test('subscribe fires on new pending + resolve', async () => {
|
||||
const q = createApprovalQueue();
|
||||
const events: string[] = [];
|
||||
const unsub = q.subscribe(() => events.push('change'));
|
||||
q.approve({
|
||||
requesterAddress: 'a',
|
||||
originalAddress: 'b',
|
||||
setupId: 's',
|
||||
requesterFingerprint: 'x',
|
||||
setupFingerprint: 'y',
|
||||
depositCreatedAt: 0,
|
||||
requestReceivedAt: 1,
|
||||
}).catch(() => {});
|
||||
expect(events.length).toBe(1);
|
||||
q.resolve(q.pending()[0]!.id, false);
|
||||
expect(events.length).toBe(2);
|
||||
unsub();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user