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:
Michaël
2026-02-27 17:56:15 -03:00
parent 5b516f02cb
commit 9fff2d427f
12 changed files with 127 additions and 44 deletions

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { getToken, getAuthMe, clearToken } from "./api";
import { getToken, getAuthMe, clearToken, type LoginMethod } from "./api";
import { Header } from "./components/Header";
import { Footer } from "./components/Footer";
import { ClaimWizard } from "./components/ClaimWizard";
@@ -26,19 +26,29 @@ const FaucetSvg = () => (
export default function App() {
const [pubkey, setPubkey] = useState<string | null>(null);
const [loginMethod, setLoginMethod] = useState<LoginMethod | null>(null);
const [statsRefetchTrigger, setStatsRefetchTrigger] = useState(0);
useEffect(() => {
const token = getToken();
if (!token) return;
getAuthMe()
.then((r) => setPubkey(r.pubkey))
.then((r) => {
setPubkey(r.pubkey);
setLoginMethod(r.method ?? "nip98");
})
.catch(() => {
clearToken();
setPubkey(null);
setLoginMethod(null);
});
}, []);
const handlePubkeyChange = useCallback((pk: string | null, method?: LoginMethod) => {
setPubkey(pk);
setLoginMethod(pk ? (method ?? "nip98") : null);
}, []);
const handleClaimSuccess = useCallback(() => {
setStatsRefetchTrigger((t) => t + 1);
}, []);
@@ -74,7 +84,7 @@ export default function App() {
<FaucetSvg />
<h1>Sats Faucet</h1>
</div>
<ClaimWizard pubkey={pubkey} onPubkeyChange={setPubkey} onClaimSuccess={handleClaimSuccess} />
<ClaimWizard pubkey={pubkey} loginMethod={loginMethod} onPubkeyChange={handlePubkeyChange} onClaimSuccess={handleClaimSuccess} />
</main>
<aside className="sidebar sidebar-right">
<StatsSection refetchTrigger={statsRefetchTrigger} />

View File

@@ -231,8 +231,18 @@ export async function postAuthLogin(): Promise<{ token: string; pubkey: string }
return requestWithNip98<{ token: string; pubkey: string }>("POST", "/auth/login", {});
}
export type LoginMethod = "nip98" | "npub";
/** Login with just an npub (no signature). Restricted: cannot change Lightning address. */
export async function postAuthLoginNpub(npub: string): Promise<{ token: string; pubkey: string; method: LoginMethod }> {
return request<{ token: string; pubkey: string; method: LoginMethod }>("/auth/login-npub", {
method: "POST",
body: JSON.stringify({ npub: npub.trim() }),
});
}
/** Get current user from session (Bearer). */
export async function getAuthMe(): Promise<{ pubkey: string }> {
export async function getAuthMe(): Promise<{ pubkey: string; method: LoginMethod }> {
const token = getToken();
if (!token) throw new Error("Not logged in");
const url = apiUrl("/auth/me");
@@ -241,7 +251,7 @@ export async function getAuthMe(): Promise<{ pubkey: string }> {
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message ?? "Session invalid");
return data as { pubkey: string };
return data as { pubkey: string; method: LoginMethod };
}
export async function getConfig(): Promise<FaucetConfig> {

View File

@@ -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}

View File

@@ -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)}
/>
</>
);

View File

@@ -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;
}

View File

@@ -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>&#9889;</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">

View File

@@ -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>
)}