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:
@@ -4,15 +4,17 @@ import { config } from "../config.js";
|
|||||||
const HEADER = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
const HEADER = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
||||||
const SEP = ".";
|
const SEP = ".";
|
||||||
|
|
||||||
export function signJwt(pubkey: string): string {
|
export type LoginMethod = "nip98" | "npub";
|
||||||
|
|
||||||
|
export function signJwt(pubkey: string, method: LoginMethod = "nip98"): string {
|
||||||
const exp = Math.floor(Date.now() / 1000) + config.jwtExpiresInSeconds;
|
const exp = Math.floor(Date.now() / 1000) + config.jwtExpiresInSeconds;
|
||||||
const payload = Buffer.from(JSON.stringify({ pubkey, exp })).toString("base64url");
|
const payload = Buffer.from(JSON.stringify({ pubkey, exp, method })).toString("base64url");
|
||||||
const message = `${HEADER}${SEP}${payload}`;
|
const message = `${HEADER}${SEP}${payload}`;
|
||||||
const sig = createHmac("sha256", config.jwtSecret).update(message).digest("base64url");
|
const sig = createHmac("sha256", config.jwtSecret).update(message).digest("base64url");
|
||||||
return `${message}${SEP}${sig}`;
|
return `${message}${SEP}${sig}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyJwt(token: string): { pubkey: string } | null {
|
export function verifyJwt(token: string): { pubkey: string; method: LoginMethod } | null {
|
||||||
try {
|
try {
|
||||||
const parts = token.split(SEP);
|
const parts = token.split(SEP);
|
||||||
if (parts.length !== 3) return null;
|
if (parts.length !== 3) return null;
|
||||||
@@ -22,10 +24,11 @@ export function verifyJwt(token: string): { pubkey: string } | null {
|
|||||||
if (sigB64 !== expected) return null;
|
if (sigB64 !== expected) return null;
|
||||||
const payload = JSON.parse(
|
const payload = JSON.parse(
|
||||||
Buffer.from(payloadB64, "base64url").toString("utf-8")
|
Buffer.from(payloadB64, "base64url").toString("utf-8")
|
||||||
) as { pubkey?: string; exp?: number };
|
) as { pubkey?: string; exp?: number; method?: string };
|
||||||
if (!payload.pubkey || typeof payload.pubkey !== "string") return null;
|
if (!payload.pubkey || typeof payload.pubkey !== "string") return null;
|
||||||
if (!payload.exp || payload.exp < Math.floor(Date.now() / 1000)) return null;
|
if (!payload.exp || payload.exp < Math.floor(Date.now() / 1000)) return null;
|
||||||
return { pubkey: payload.pubkey };
|
const method: LoginMethod = payload.method === "npub" ? "npub" : "nip98";
|
||||||
|
return { pubkey: payload.pubkey, method };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { nip98Auth } from "./nip98.js";
|
|||||||
const BEARER_PREFIX = "Bearer ";
|
const BEARER_PREFIX = "Bearer ";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept either Bearer JWT or NIP-98. Sets req.nostr = { pubkey }.
|
* Accept either Bearer JWT or NIP-98. Sets req.nostr = { pubkey, method }.
|
||||||
*/
|
*/
|
||||||
export function authOrNip98(req: Request, res: Response, next: NextFunction): void {
|
export function authOrNip98(req: Request, res: Response, next: NextFunction): void {
|
||||||
const auth = req.headers.authorization;
|
const auth = req.headers.authorization;
|
||||||
@@ -13,7 +13,7 @@ export function authOrNip98(req: Request, res: Response, next: NextFunction): vo
|
|||||||
const token = auth.slice(BEARER_PREFIX.length).trim();
|
const token = auth.slice(BEARER_PREFIX.length).trim();
|
||||||
const payload = verifyJwt(token);
|
const payload = verifyJwt(token);
|
||||||
if (payload) {
|
if (payload) {
|
||||||
req.nostr = { pubkey: payload.pubkey, eventId: "" };
|
req.nostr = { pubkey: payload.pubkey, eventId: "", method: payload.method };
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const AUTH_SCHEME = "Nostr ";
|
|||||||
export interface NostrAuthPayload {
|
export interface NostrAuthPayload {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
method?: "nip98" | "npub";
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Router, Request, Response } from "express";
|
import { Router, Request, Response } from "express";
|
||||||
|
import { nip19 } from "nostr-tools";
|
||||||
import { nip98Auth } from "../middleware/nip98.js";
|
import { nip98Auth } from "../middleware/nip98.js";
|
||||||
import { signJwt, verifyJwt } from "../auth/jwt.js";
|
import { signJwt, verifyJwt } from "../auth/jwt.js";
|
||||||
|
|
||||||
@@ -7,8 +8,29 @@ const router = Router();
|
|||||||
/** Sign in with NIP-98 once; returns a JWT for subsequent requests. */
|
/** Sign in with NIP-98 once; returns a JWT for subsequent requests. */
|
||||||
router.post("/login", nip98Auth, (req: Request, res: Response) => {
|
router.post("/login", nip98Auth, (req: Request, res: Response) => {
|
||||||
const pubkey = req.nostr!.pubkey;
|
const pubkey = req.nostr!.pubkey;
|
||||||
const token = signJwt(pubkey);
|
const token = signJwt(pubkey, "nip98");
|
||||||
res.json({ token, pubkey });
|
res.json({ token, pubkey, method: "nip98" });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Sign in with just an npub (no signature). Limited: cannot change Lightning address. */
|
||||||
|
router.post("/login-npub", (req: Request, res: Response) => {
|
||||||
|
const raw = typeof req.body?.npub === "string" ? req.body.npub.trim() : "";
|
||||||
|
if (!raw) {
|
||||||
|
res.status(400).json({ code: "invalid_npub", message: "npub is required." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(raw);
|
||||||
|
if (decoded.type !== "npub") {
|
||||||
|
res.status(400).json({ code: "invalid_npub", message: "Expected an npub-encoded public key." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pubkey = decoded.data as string;
|
||||||
|
const token = signJwt(pubkey, "npub");
|
||||||
|
res.json({ token, pubkey, method: "npub" });
|
||||||
|
} catch {
|
||||||
|
res.status(400).json({ code: "invalid_npub", message: "Invalid npub format." });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Return current user from JWT (Bearer only). Used to restore session. */
|
/** Return current user from JWT (Bearer only). Used to restore session. */
|
||||||
@@ -23,7 +45,7 @@ router.get("/me", (req: Request, res: Response) => {
|
|||||||
res.status(401).json({ code: "invalid_token", message: "Invalid or expired token." });
|
res.status(401).json({ code: "invalid_token", message: "Invalid or expired token." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.json({ pubkey: payload.pubkey });
|
res.json({ pubkey: payload.pubkey, method: payload.method });
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -27,7 +27,29 @@ function parseLightningAddress(body: unknown): string | null {
|
|||||||
router.post("/quote", authOrNip98, async (req: Request, res: Response) => {
|
router.post("/quote", authOrNip98, async (req: Request, res: Response) => {
|
||||||
const pubkey = req.nostr!.pubkey;
|
const pubkey = req.nostr!.pubkey;
|
||||||
const ipHash = req.ipHash!;
|
const ipHash = req.ipHash!;
|
||||||
const lightningAddress = parseLightningAddress(req.body);
|
let lightningAddress = parseLightningAddress(req.body);
|
||||||
|
|
||||||
|
if (req.nostr!.method === "npub") {
|
||||||
|
const db = getDb();
|
||||||
|
const user = await db.getUser(pubkey);
|
||||||
|
const profileAddr = user?.lightning_address?.trim() || null;
|
||||||
|
if (!profileAddr) {
|
||||||
|
res.status(400).json({
|
||||||
|
code: "no_profile_address",
|
||||||
|
message: "No Lightning address found in your Nostr profile. Log in with nsec or extension to enter one manually.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lightningAddress && lightningAddress !== profileAddr) {
|
||||||
|
res.status(403).json({
|
||||||
|
code: "address_locked",
|
||||||
|
message: "Public-key login restricts payouts to your profile Lightning address.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lightningAddress = profileAddr;
|
||||||
|
}
|
||||||
|
|
||||||
if (!lightningAddress) {
|
if (!lightningAddress) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
code: "invalid_lightning_address",
|
code: "invalid_lightning_address",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
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 { Header } from "./components/Header";
|
||||||
import { Footer } from "./components/Footer";
|
import { Footer } from "./components/Footer";
|
||||||
import { ClaimWizard } from "./components/ClaimWizard";
|
import { ClaimWizard } from "./components/ClaimWizard";
|
||||||
@@ -26,19 +26,29 @@ const FaucetSvg = () => (
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [pubkey, setPubkey] = useState<string | null>(null);
|
const [pubkey, setPubkey] = useState<string | null>(null);
|
||||||
|
const [loginMethod, setLoginMethod] = useState<LoginMethod | null>(null);
|
||||||
const [statsRefetchTrigger, setStatsRefetchTrigger] = useState(0);
|
const [statsRefetchTrigger, setStatsRefetchTrigger] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
getAuthMe()
|
getAuthMe()
|
||||||
.then((r) => setPubkey(r.pubkey))
|
.then((r) => {
|
||||||
|
setPubkey(r.pubkey);
|
||||||
|
setLoginMethod(r.method ?? "nip98");
|
||||||
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
clearToken();
|
clearToken();
|
||||||
setPubkey(null);
|
setPubkey(null);
|
||||||
|
setLoginMethod(null);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handlePubkeyChange = useCallback((pk: string | null, method?: LoginMethod) => {
|
||||||
|
setPubkey(pk);
|
||||||
|
setLoginMethod(pk ? (method ?? "nip98") : null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleClaimSuccess = useCallback(() => {
|
const handleClaimSuccess = useCallback(() => {
|
||||||
setStatsRefetchTrigger((t) => t + 1);
|
setStatsRefetchTrigger((t) => t + 1);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -74,7 +84,7 @@ export default function App() {
|
|||||||
<FaucetSvg />
|
<FaucetSvg />
|
||||||
<h1>Sats Faucet</h1>
|
<h1>Sats Faucet</h1>
|
||||||
</div>
|
</div>
|
||||||
<ClaimWizard pubkey={pubkey} onPubkeyChange={setPubkey} onClaimSuccess={handleClaimSuccess} />
|
<ClaimWizard pubkey={pubkey} loginMethod={loginMethod} onPubkeyChange={handlePubkeyChange} onClaimSuccess={handleClaimSuccess} />
|
||||||
</main>
|
</main>
|
||||||
<aside className="sidebar sidebar-right">
|
<aside className="sidebar sidebar-right">
|
||||||
<StatsSection refetchTrigger={statsRefetchTrigger} />
|
<StatsSection refetchTrigger={statsRefetchTrigger} />
|
||||||
|
|||||||
@@ -231,8 +231,18 @@ export async function postAuthLogin(): Promise<{ token: string; pubkey: string }
|
|||||||
return requestWithNip98<{ token: string; pubkey: string }>("POST", "/auth/login", {});
|
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). */
|
/** 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();
|
const token = getToken();
|
||||||
if (!token) throw new Error("Not logged in");
|
if (!token) throw new Error("Not logged in");
|
||||||
const url = apiUrl("/auth/me");
|
const url = apiUrl("/auth/me");
|
||||||
@@ -241,7 +251,7 @@ export async function getAuthMe(): Promise<{ pubkey: string }> {
|
|||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(data.message ?? "Session invalid");
|
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> {
|
export async function getConfig(): Promise<FaucetConfig> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from "react";
|
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 { useClaimFlow } from "../hooks/useClaimFlow";
|
||||||
import { StepIndicator } from "./StepIndicator";
|
import { StepIndicator } from "./StepIndicator";
|
||||||
import { ConnectStep } from "./ConnectStep";
|
import { ConnectStep } from "./ConnectStep";
|
||||||
@@ -25,11 +25,12 @@ function getWizardStep(
|
|||||||
|
|
||||||
interface ClaimWizardProps {
|
interface ClaimWizardProps {
|
||||||
pubkey: string | null;
|
pubkey: string | null;
|
||||||
onPubkeyChange: (pk: string | null) => void;
|
loginMethod: LoginMethod | null;
|
||||||
|
onPubkeyChange: (pk: string | null, method?: LoginMethod) => void;
|
||||||
onClaimSuccess?: () => 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 [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
const [lightningAddress, setLightningAddress] = useState("");
|
const [lightningAddress, setLightningAddress] = useState("");
|
||||||
const [lightningAddressTouched, setLightningAddressTouched] = useState(false);
|
const [lightningAddressTouched, setLightningAddressTouched] = useState(false);
|
||||||
@@ -133,7 +134,7 @@ export function ClaimWizard({ pubkey, onPubkeyChange, onClaimSuccess }: ClaimWiz
|
|||||||
<ConnectStep
|
<ConnectStep
|
||||||
pubkey={pubkey}
|
pubkey={pubkey}
|
||||||
displayName={profile?.name}
|
displayName={profile?.name}
|
||||||
onConnect={(pk) => onPubkeyChange(pk)}
|
onConnect={(pk, method) => onPubkeyChange(pk, method)}
|
||||||
onDisconnect={handleDisconnect}
|
onDisconnect={handleDisconnect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -146,6 +147,7 @@ export function ClaimWizard({ pubkey, onPubkeyChange, onClaimSuccess }: ClaimWiz
|
|||||||
setLightningAddressTouched={setLightningAddressTouched}
|
setLightningAddressTouched={setLightningAddressTouched}
|
||||||
invalid={lightningAddressInvalid}
|
invalid={lightningAddressInvalid}
|
||||||
fromProfile={fromProfile}
|
fromProfile={fromProfile}
|
||||||
|
readOnly={loginMethod === "npub"}
|
||||||
loading={claim.loading === "quote"}
|
loading={claim.loading === "quote"}
|
||||||
eligibilityProgressStep={claim.eligibilityProgressStep}
|
eligibilityProgressStep={claim.eligibilityProgressStep}
|
||||||
denial={claim.denial}
|
denial={claim.denial}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { clearToken } from "../api";
|
import { clearToken, type LoginMethod } from "../api";
|
||||||
import { nip19 } from "nostr-tools";
|
import { nip19 } from "nostr-tools";
|
||||||
import { LoginModal } from "./LoginModal";
|
import { LoginModal } from "./LoginModal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pubkey: string | null;
|
pubkey: string | null;
|
||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
onConnect: (pubkey: string) => void;
|
onConnect: (pubkey: string, method?: LoginMethod) => void;
|
||||||
onDisconnect: () => void;
|
onDisconnect: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ export function ConnectNostr({ pubkey, displayName, onConnect, onDisconnect }: P
|
|||||||
<LoginModal
|
<LoginModal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onClose={() => setModalOpen(false)}
|
onClose={() => setModalOpen(false)}
|
||||||
onSuccess={onConnect}
|
onSuccess={(pubkey, method) => onConnect(pubkey, method)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ConnectNostr } from "./ConnectNostr";
|
import { ConnectNostr } from "./ConnectNostr";
|
||||||
import { getConfig, type FaucetConfig } from "../api";
|
import { getConfig, type FaucetConfig, type LoginMethod } from "../api";
|
||||||
|
|
||||||
interface ConnectStepProps {
|
interface ConnectStepProps {
|
||||||
pubkey: string | null;
|
pubkey: string | null;
|
||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
onConnect: (pubkey: string) => void;
|
onConnect: (pubkey: string, method?: LoginMethod) => void;
|
||||||
onDisconnect: () => void;
|
onDisconnect: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface EligibilityStepProps {
|
|||||||
setLightningAddressTouched: (t: boolean) => void;
|
setLightningAddressTouched: (t: boolean) => void;
|
||||||
invalid: boolean;
|
invalid: boolean;
|
||||||
fromProfile: boolean;
|
fromProfile: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
eligibilityProgressStep: number | null;
|
eligibilityProgressStep: number | null;
|
||||||
denial: DenialState | null;
|
denial: DenialState | null;
|
||||||
@@ -27,6 +28,7 @@ export function EligibilityStep({
|
|||||||
setLightningAddressTouched,
|
setLightningAddressTouched,
|
||||||
invalid,
|
invalid,
|
||||||
fromProfile,
|
fromProfile,
|
||||||
|
readOnly,
|
||||||
loading,
|
loading,
|
||||||
eligibilityProgressStep,
|
eligibilityProgressStep,
|
||||||
denial,
|
denial,
|
||||||
@@ -38,27 +40,37 @@ export function EligibilityStep({
|
|||||||
const canCheck = !loading && lightningAddress.trim() !== "" && LIGHTNING_ADDRESS_REGEX.test(lightningAddress.trim());
|
const canCheck = !loading && lightningAddress.trim() !== "" && LIGHTNING_ADDRESS_REGEX.test(lightningAddress.trim());
|
||||||
const showProfileCard = fromProfile && !editing;
|
const showProfileCard = fromProfile && !editing;
|
||||||
|
|
||||||
|
const noAddressForNpub = readOnly && !lightningAddress.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="claim-wizard-step claim-wizard-step-eligibility">
|
<div className="claim-wizard-step claim-wizard-step-eligibility">
|
||||||
<h3 className="claim-wizard-step-title">Check eligibility</h3>
|
<h3 className="claim-wizard-step-title">Check eligibility</h3>
|
||||||
<p className="claim-wizard-step-desc">
|
<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>
|
</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">
|
||||||
<div className="claim-wizard-profile-card-icon" aria-hidden>⚡</div>
|
<div className="claim-wizard-profile-card-icon" aria-hidden>⚡</div>
|
||||||
<div className="claim-wizard-profile-card-body">
|
<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-label">Lightning address from profile</span>
|
||||||
<span className="claim-wizard-profile-card-address">{lightningAddress}</span>
|
<span className="claim-wizard-profile-card-address">{lightningAddress}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{!readOnly && (
|
||||||
type="button"
|
<button
|
||||||
className="btn-secondary claim-wizard-profile-card-edit"
|
type="button"
|
||||||
onClick={() => setEditing(true)}
|
className="btn-secondary claim-wizard-profile-card-edit"
|
||||||
>
|
onClick={() => setEditing(true)}
|
||||||
Edit
|
>
|
||||||
</button>
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="claim-wizard-address-row">
|
<div className="claim-wizard-address-row">
|
||||||
@@ -70,7 +82,7 @@ export function EligibilityStep({
|
|||||||
onChange={(e) => onLightningAddressChange(e.target.value)}
|
onChange={(e) => onLightningAddressChange(e.target.value)}
|
||||||
onBlur={() => setLightningAddressTouched(true)}
|
onBlur={() => setLightningAddressTouched(true)}
|
||||||
placeholder="you@wallet.com"
|
placeholder="you@wallet.com"
|
||||||
disabled={loading}
|
disabled={loading || readOnly}
|
||||||
aria-invalid={invalid || undefined}
|
aria-invalid={invalid || undefined}
|
||||||
aria-describedby={invalid ? "wizard-lightning-hint" : undefined}
|
aria-describedby={invalid ? "wizard-lightning-hint" : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -82,7 +94,7 @@ export function EligibilityStep({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{noAddressForNpub ? null : loading ? (
|
||||||
<div className="claim-wizard-progress" role="status" aria-live="polite">
|
<div className="claim-wizard-progress" role="status" aria-live="polite">
|
||||||
<div className="claim-wizard-progress-bar" />
|
<div className="claim-wizard-progress-bar" />
|
||||||
<p className="claim-wizard-progress-text">
|
<p className="claim-wizard-progress-text">
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { BunkerSigner, parseBunkerInput } from "nostr-tools/nip46";
|
|||||||
import {
|
import {
|
||||||
postAuthLoginWithSigner,
|
postAuthLoginWithSigner,
|
||||||
postAuthLogin,
|
postAuthLogin,
|
||||||
|
postAuthLoginNpub,
|
||||||
postUserRefreshProfile,
|
postUserRefreshProfile,
|
||||||
setToken,
|
setToken,
|
||||||
hasNostr,
|
hasNostr,
|
||||||
type ApiError,
|
type ApiError,
|
||||||
|
type LoginMethod,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
import { RemoteSignerQR } from "./RemoteSignerQR";
|
import { RemoteSignerQR } from "./RemoteSignerQR";
|
||||||
@@ -29,7 +31,7 @@ function getDefaultRemoteSignerMode(): RemoteSignerMode {
|
|||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSuccess: (pubkey: string) => void;
|
onSuccess: (pubkey: string, method: LoginMethod) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginModal({ open, onClose, onSuccess }: Props) {
|
export function LoginModal({ open, onClose, onSuccess }: Props) {
|
||||||
@@ -58,16 +60,15 @@ export function LoginModal({ open, onClose, onSuccess }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const runLogin = useCallback(
|
const runLogin = useCallback(
|
||||||
async (sign: () => Promise<{ token: string; pubkey: string }>) => {
|
async (sign: () => Promise<{ token: string; pubkey: string; method?: LoginMethod }>) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setPasteInlineError(null);
|
setPasteInlineError(null);
|
||||||
try {
|
try {
|
||||||
const { token, pubkey } = await sign();
|
const { token, pubkey, method } = await sign();
|
||||||
setToken(token);
|
setToken(token);
|
||||||
// Trigger profile fetch so backend has token before ClaimFlow effect runs (helps remote signer login)
|
|
||||||
postUserRefreshProfile().catch(() => {});
|
postUserRefreshProfile().catch(() => {});
|
||||||
onSuccess(pubkey);
|
onSuccess(pubkey, method ?? "nip98");
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : (e as ApiError)?.message ?? "Login failed";
|
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 {
|
try {
|
||||||
const decoded = nip19.decode(raw);
|
const decoded = nip19.decode(raw);
|
||||||
if (decoded.type === "npub") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (decoded.type !== "nsec") {
|
if (decoded.type !== "nsec") {
|
||||||
@@ -295,7 +296,7 @@ export function LoginModal({ open, onClose, onSuccess }: Props) {
|
|||||||
{tab === "nsec" && (
|
{tab === "nsec" && (
|
||||||
<div className="login-method">
|
<div className="login-method">
|
||||||
<p className="login-method-desc">
|
<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>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
className="login-modal-textarea"
|
className="login-modal-textarea"
|
||||||
@@ -311,7 +312,7 @@ export function LoginModal({ open, onClose, onSuccess }: Props) {
|
|||||||
onClick={handleNsecNpub}
|
onClick={handleNsecNpub}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? "Signing in…" : "Login with nsec"}
|
{loading ? "Signing in…" : "Login"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user