Add public key (npub) login support
- Backend: POST /auth/login-npub endpoint, JWT carries login method - Backend: Enforce Lightning address lock for npub logins in claim/quote - Frontend: LoginModal supports npub via new endpoint, clear copy - Frontend: Track loginMethod, lock LN address editing for npub users - Handle missing Lightning address for npub users with helpful message Made-with: Cursor
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { postUserRefreshProfile, type UserProfile } from "../api";
|
||||
import { postUserRefreshProfile, type UserProfile, type LoginMethod } from "../api";
|
||||
import { useClaimFlow } from "../hooks/useClaimFlow";
|
||||
import { StepIndicator } from "./StepIndicator";
|
||||
import { ConnectStep } from "./ConnectStep";
|
||||
@@ -25,11 +25,12 @@ function getWizardStep(
|
||||
|
||||
interface ClaimWizardProps {
|
||||
pubkey: string | null;
|
||||
onPubkeyChange: (pk: string | null) => void;
|
||||
loginMethod: LoginMethod | null;
|
||||
onPubkeyChange: (pk: string | null, method?: LoginMethod) => void;
|
||||
onClaimSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function ClaimWizard({ pubkey, onPubkeyChange, onClaimSuccess }: ClaimWizardProps) {
|
||||
export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSuccess }: ClaimWizardProps) {
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [lightningAddress, setLightningAddress] = useState("");
|
||||
const [lightningAddressTouched, setLightningAddressTouched] = useState(false);
|
||||
@@ -133,7 +134,7 @@ export function ClaimWizard({ pubkey, onPubkeyChange, onClaimSuccess }: ClaimWiz
|
||||
<ConnectStep
|
||||
pubkey={pubkey}
|
||||
displayName={profile?.name}
|
||||
onConnect={(pk) => onPubkeyChange(pk)}
|
||||
onConnect={(pk, method) => onPubkeyChange(pk, method)}
|
||||
onDisconnect={handleDisconnect}
|
||||
/>
|
||||
)}
|
||||
@@ -146,6 +147,7 @@ export function ClaimWizard({ pubkey, onPubkeyChange, onClaimSuccess }: ClaimWiz
|
||||
setLightningAddressTouched={setLightningAddressTouched}
|
||||
invalid={lightningAddressInvalid}
|
||||
fromProfile={fromProfile}
|
||||
readOnly={loginMethod === "npub"}
|
||||
loading={claim.loading === "quote"}
|
||||
eligibilityProgressStep={claim.eligibilityProgressStep}
|
||||
denial={claim.denial}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from "react";
|
||||
import { clearToken } from "../api";
|
||||
import { clearToken, type LoginMethod } from "../api";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { LoginModal } from "./LoginModal";
|
||||
|
||||
interface Props {
|
||||
pubkey: string | null;
|
||||
displayName?: string | null;
|
||||
onConnect: (pubkey: string) => void;
|
||||
onConnect: (pubkey: string, method?: LoginMethod) => void;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export function ConnectNostr({ pubkey, displayName, onConnect, onDisconnect }: P
|
||||
<LoginModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSuccess={onConnect}
|
||||
onSuccess={(pubkey, method) => onConnect(pubkey, method)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ConnectNostr } from "./ConnectNostr";
|
||||
import { getConfig, type FaucetConfig } from "../api";
|
||||
import { getConfig, type FaucetConfig, type LoginMethod } from "../api";
|
||||
|
||||
interface ConnectStepProps {
|
||||
pubkey: string | null;
|
||||
displayName?: string | null;
|
||||
onConnect: (pubkey: string) => void;
|
||||
onConnect: (pubkey: string, method?: LoginMethod) => void;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ interface EligibilityStepProps {
|
||||
setLightningAddressTouched: (t: boolean) => void;
|
||||
invalid: boolean;
|
||||
fromProfile: boolean;
|
||||
readOnly?: boolean;
|
||||
loading: boolean;
|
||||
eligibilityProgressStep: number | null;
|
||||
denial: DenialState | null;
|
||||
@@ -27,6 +28,7 @@ export function EligibilityStep({
|
||||
setLightningAddressTouched,
|
||||
invalid,
|
||||
fromProfile,
|
||||
readOnly,
|
||||
loading,
|
||||
eligibilityProgressStep,
|
||||
denial,
|
||||
@@ -38,27 +40,37 @@ export function EligibilityStep({
|
||||
const canCheck = !loading && lightningAddress.trim() !== "" && LIGHTNING_ADDRESS_REGEX.test(lightningAddress.trim());
|
||||
const showProfileCard = fromProfile && !editing;
|
||||
|
||||
const noAddressForNpub = readOnly && !lightningAddress.trim();
|
||||
|
||||
return (
|
||||
<div className="claim-wizard-step claim-wizard-step-eligibility">
|
||||
<h3 className="claim-wizard-step-title">Check eligibility</h3>
|
||||
<p className="claim-wizard-step-desc">
|
||||
Enter your Lightning address. We'll verify cooldown and calculate your payout.
|
||||
{readOnly
|
||||
? "Logged in with public key \u2014 payout goes to your profile\u2019s Lightning address."
|
||||
: "Enter your Lightning address. We\u2019ll verify cooldown and calculate your payout."}
|
||||
</p>
|
||||
|
||||
{showProfileCard ? (
|
||||
{noAddressForNpub ? (
|
||||
<p className="claim-wizard-input-hint" role="alert">
|
||||
No Lightning address found in your Nostr profile. Log in with nsec or extension to enter one manually.
|
||||
</p>
|
||||
) : showProfileCard ? (
|
||||
<div className="claim-wizard-profile-card">
|
||||
<div className="claim-wizard-profile-card-icon" aria-hidden>⚡</div>
|
||||
<div className="claim-wizard-profile-card-body">
|
||||
<span className="claim-wizard-profile-card-label">Lightning address from profile</span>
|
||||
<span className="claim-wizard-profile-card-address">{lightningAddress}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary claim-wizard-profile-card-edit"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary claim-wizard-profile-card-edit"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="claim-wizard-address-row">
|
||||
@@ -70,7 +82,7 @@ export function EligibilityStep({
|
||||
onChange={(e) => onLightningAddressChange(e.target.value)}
|
||||
onBlur={() => setLightningAddressTouched(true)}
|
||||
placeholder="you@wallet.com"
|
||||
disabled={loading}
|
||||
disabled={loading || readOnly}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={invalid ? "wizard-lightning-hint" : undefined}
|
||||
/>
|
||||
@@ -82,7 +94,7 @@ export function EligibilityStep({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
{noAddressForNpub ? null : loading ? (
|
||||
<div className="claim-wizard-progress" role="status" aria-live="polite">
|
||||
<div className="claim-wizard-progress-bar" />
|
||||
<p className="claim-wizard-progress-text">
|
||||
|
||||
@@ -4,10 +4,12 @@ import { BunkerSigner, parseBunkerInput } from "nostr-tools/nip46";
|
||||
import {
|
||||
postAuthLoginWithSigner,
|
||||
postAuthLogin,
|
||||
postAuthLoginNpub,
|
||||
postUserRefreshProfile,
|
||||
setToken,
|
||||
hasNostr,
|
||||
type ApiError,
|
||||
type LoginMethod,
|
||||
} from "../api";
|
||||
import { Modal } from "./Modal";
|
||||
import { RemoteSignerQR } from "./RemoteSignerQR";
|
||||
@@ -29,7 +31,7 @@ function getDefaultRemoteSignerMode(): RemoteSignerMode {
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (pubkey: string) => void;
|
||||
onSuccess: (pubkey: string, method: LoginMethod) => void;
|
||||
}
|
||||
|
||||
export function LoginModal({ open, onClose, onSuccess }: Props) {
|
||||
@@ -58,16 +60,15 @@ export function LoginModal({ open, onClose, onSuccess }: Props) {
|
||||
};
|
||||
|
||||
const runLogin = useCallback(
|
||||
async (sign: () => Promise<{ token: string; pubkey: string }>) => {
|
||||
async (sign: () => Promise<{ token: string; pubkey: string; method?: LoginMethod }>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setPasteInlineError(null);
|
||||
try {
|
||||
const { token, pubkey } = await sign();
|
||||
const { token, pubkey, method } = await sign();
|
||||
setToken(token);
|
||||
// Trigger profile fetch so backend has token before ClaimFlow effect runs (helps remote signer login)
|
||||
postUserRefreshProfile().catch(() => {});
|
||||
onSuccess(pubkey);
|
||||
onSuccess(pubkey, method ?? "nip98");
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : (e as ApiError)?.message ?? "Login failed";
|
||||
@@ -146,7 +147,7 @@ export function LoginModal({ open, onClose, onSuccess }: Props) {
|
||||
try {
|
||||
const decoded = nip19.decode(raw);
|
||||
if (decoded.type === "npub") {
|
||||
setError("Npub (public key) cannot sign. Paste your nsec to log in and claim, or use Extension / Remote signer.");
|
||||
await runLogin(() => postAuthLoginNpub(raw));
|
||||
return;
|
||||
}
|
||||
if (decoded.type !== "nsec") {
|
||||
@@ -295,7 +296,7 @@ export function LoginModal({ open, onClose, onSuccess }: Props) {
|
||||
{tab === "nsec" && (
|
||||
<div className="login-method">
|
||||
<p className="login-method-desc">
|
||||
Paste your <strong>nsec</strong> to sign in (kept in this tab only). Npub alone cannot sign.
|
||||
Paste your <strong>nsec</strong> for full access, or <strong>npub</strong> to claim using your profile's Lightning address.
|
||||
</p>
|
||||
<textarea
|
||||
className="login-modal-textarea"
|
||||
@@ -311,7 +312,7 @@ export function LoginModal({ open, onClose, onSuccess }: Props) {
|
||||
onClick={handleNsecNpub}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Signing in…" : "Login with nsec"}
|
||||
{loading ? "Signing in…" : "Login"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user