From 381597c96faf70ec7917bf8cb7548b82875cff56 Mon Sep 17 00:00:00 2001 From: SatsFaucet Date: Sun, 1 Mar 2026 01:24:51 +0100 Subject: [PATCH] Add Swagger docs at /docs and /openapi.json; frontend and backend updates Made-with: Cursor --- backend/package-lock.json | 79 +++++++++- backend/package.json | 6 +- backend/src/index.ts | 10 ++ backend/src/middleware/nip98.ts | 1 + backend/src/openapi/base.ts | 20 +++ backend/src/openapi/index.ts | 41 ++++++ backend/src/openapi/paths/auth.ts | 92 ++++++++++++ backend/src/openapi/paths/claim.ts | 148 +++++++++++++++++++ backend/src/openapi/paths/public.ts | 130 +++++++++++++++++ backend/src/openapi/paths/user.ts | 48 ++++++ backend/src/openapi/schemas.ts | 153 ++++++++++++++++++++ backend/src/services/eligibility.ts | 3 + backend/src/services/nostr.ts | 65 +++++++-- frontend/.env.example | 3 + frontend/src/App.tsx | 8 +- frontend/src/components/ClaimWizard.tsx | 144 +++++++++++++----- frontend/src/components/EligibilityStep.tsx | 32 +--- frontend/src/components/Header.tsx | 120 +++++++++++++-- frontend/src/hooks/useNostrProfile.ts | 62 ++++++++ frontend/src/styles/global.css | 147 +++++++++++++++++++ 20 files changed, 1214 insertions(+), 98 deletions(-) create mode 100644 backend/src/openapi/base.ts create mode 100644 backend/src/openapi/index.ts create mode 100644 backend/src/openapi/paths/auth.ts create mode 100644 backend/src/openapi/paths/claim.ts create mode 100644 backend/src/openapi/paths/public.ts create mode 100644 backend/src/openapi/paths/user.ts create mode 100644 backend/src/openapi/schemas.ts create mode 100644 frontend/src/hooks/useNostrProfile.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index b72e3b8..2434165 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,7 +15,9 @@ "express-rate-limit": "^7.4.1", "nostr-tools": "^2.4.4", "pg": "^8.13.1", - "uuid": "^10.0.0" + "swagger-ui-express": "^5.0.1", + "uuid": "^10.0.0", + "ws": "^8.19.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.11", @@ -23,7 +25,9 @@ "@types/express": "^4.17.21", "@types/node": "^22.9.0", "@types/pg": "^8.11.10", + "@types/swagger-ui-express": "^4.1.8", "@types/uuid": "^10.0.0", + "@types/ws": "^8.18.1", "tsx": "^4.19.2", "typescript": "^5.6.3" } @@ -509,6 +513,13 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@scure/base": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", @@ -695,6 +706,17 @@ "@types/node": "*" } }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -702,6 +724,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -2135,6 +2167,30 @@ "node": ">=0.10.0" } }, + "node_modules/swagger-ui-dist": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.0.tgz", + "integrity": "sha512-nKZB0OuDvacB0s/lC2gbge+RigYvGRGpLLMWMFxaTUwfM+CfndVk9Th2IaTinqXiz6Mn26GK2zriCpv6/+5m3Q==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -2290,6 +2346,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 8722af2..009e127 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,9 @@ "express-rate-limit": "^7.4.1", "nostr-tools": "^2.4.4", "pg": "^8.13.1", - "uuid": "^10.0.0" + "swagger-ui-express": "^5.0.1", + "uuid": "^10.0.0", + "ws": "^8.19.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.11", @@ -25,7 +27,9 @@ "@types/express": "^4.17.21", "@types/node": "^22.9.0", "@types/pg": "^8.11.10", + "@types/swagger-ui-express": "^4.1.8", "@types/uuid": "^10.0.0", + "@types/ws": "^8.18.1", "tsx": "^4.19.2", "typescript": "^5.6.3" } diff --git a/backend/src/index.ts b/backend/src/index.ts index d2f6e27..3c26f38 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,8 +1,14 @@ +import WebSocket from "ws"; +// @ts-expect-error Node 20 lacks global WebSocket; nostr-tools needs it +globalThis.WebSocket = WebSocket; + import express from "express"; import cors from "cors"; import rateLimit from "express-rate-limit"; +import swaggerUi from "swagger-ui-express"; import { config } from "./config.js"; import { getDb } from "./db/index.js"; +import { buildOpenApiSpec } from "./openapi/index.js"; import { startLnbitsDepositSync } from "./services/syncLnbitsDeposits.js"; import publicRoutes from "./routes/public.js"; import authRoutes from "./routes/auth.js"; @@ -39,6 +45,10 @@ async function main() { }) ); + const openapiUrl = config.publicBasePath ? `/${config.publicBasePath}/openapi.json` : "/openapi.json"; + app.get("/openapi.json", (_req, res) => res.json(buildOpenApiSpec())); + app.use("/docs", swaggerUi.serve, swaggerUi.setup(null, { swaggerUrl: openapiUrl })); + app.use("/", publicRoutes); app.use("/auth", authRoutes); app.use( diff --git a/backend/src/middleware/nip98.ts b/backend/src/middleware/nip98.ts index f551b42..a6bc780 100644 --- a/backend/src/middleware/nip98.ts +++ b/backend/src/middleware/nip98.ts @@ -93,6 +93,7 @@ export async function nip98Auth(req: Request, res: Response, next: NextFunction) res.status(401).json({ code: "invalid_nip98", message: "NIP-98 'u' tag does not match request URL.", + details: `signed=${u} server=${absoluteUrl}`, }); return; } diff --git a/backend/src/openapi/base.ts b/backend/src/openapi/base.ts new file mode 100644 index 0000000..f7e2b99 --- /dev/null +++ b/backend/src/openapi/base.ts @@ -0,0 +1,20 @@ +/** OpenAPI 3.0 base spec (info, servers, tags) */ +const base = { + openapi: "3.0.3", + info: { + title: "SatsFaucet API", + version: "1.0.0", + description: + "Bitcoin Lightning faucet API. Claim sats to your Lightning address after authenticating with Nostr (NIP-98 or npub).", + }, + servers: [{ url: "/", description: "API server" }], + tags: [ + { name: "Public", description: "Public endpoints (no auth)" }, + { name: "Auth", description: "Authentication" }, + { name: "Claim", description: "Claim sats flow" }, + { name: "User", description: "User profile" }, + ], + security: [], +}; + +export default base; diff --git a/backend/src/openapi/index.ts b/backend/src/openapi/index.ts new file mode 100644 index 0000000..ad6650d --- /dev/null +++ b/backend/src/openapi/index.ts @@ -0,0 +1,41 @@ +import { config } from "../config.js"; +import base from "./base.js"; +import schemas from "./schemas.js"; +import publicPaths from "./paths/public.js"; +import authPaths from "./paths/auth.js"; +import claimPaths from "./paths/claim.js"; +import userPaths from "./paths/user.js"; + +/** Build the full OpenAPI 3.0 spec by merging split files */ +export function buildOpenApiSpec(): Record { + const basePath = config.publicBasePath ? `/${config.publicBasePath.replace(/^\//, "")}` : ""; + const serverUrl = basePath || "/"; + + return { + ...base, + servers: [{ url: serverUrl, description: "API server" }], + paths: { + ...publicPaths, + ...authPaths, + ...claimPaths, + ...userPaths, + }, + components: { + schemas, + securitySchemes: { + Nostr: { + type: "http", + scheme: "Nostr", + description: + "NIP-98 HTTP Auth: base64-encoded signed Nostr event (kind 27235) with 'u' and 'method' tags matching the request URL and method.", + }, + BearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + description: "JWT obtained from /auth/login or /auth/login-npub", + }, + }, + }, + }; +} diff --git a/backend/src/openapi/paths/auth.ts b/backend/src/openapi/paths/auth.ts new file mode 100644 index 0000000..b385b7b --- /dev/null +++ b/backend/src/openapi/paths/auth.ts @@ -0,0 +1,92 @@ +/** Auth API paths */ +const paths: Record> = { + "/auth/login": { + post: { + tags: ["Auth"], + summary: "Sign in with NIP-98", + description: + "Authenticate using NIP-98 (Nostr HTTP Auth). Send a signed kind 27235 event in the Authorization header. Returns a JWT for subsequent requests.", + security: [{ Nostr: [] }], + responses: { + "200": { + description: "Login success", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/LoginResponse" }, + }, + }, + }, + "401": { + description: "Invalid NIP-98 (missing, malformed, or failed verification)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + }, + }, + }, + "/auth/login-npub": { + post: { + tags: ["Auth"], + summary: "Sign in with npub", + description: + "Authenticate with just an npub (no signature). Limited: cannot change Lightning address; payouts use profile address only.", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/LoginNpubRequest" }, + }, + }, + }, + responses: { + "200": { + description: "Login success", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/LoginResponse" }, + }, + }, + }, + "400": { + description: "Invalid or missing npub", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + }, + }, + }, + "/auth/me": { + get: { + tags: ["Auth"], + summary: "Current user", + description: "Return current user from JWT (Bearer only). Used to restore session.", + security: [{ BearerAuth: [] }], + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/MeResponse" }, + }, + }, + }, + "401": { + description: "Missing or invalid Bearer token", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + }, + }, + }, +}; + +export default paths; diff --git a/backend/src/openapi/paths/claim.ts b/backend/src/openapi/paths/claim.ts new file mode 100644 index 0000000..7860d26 --- /dev/null +++ b/backend/src/openapi/paths/claim.ts @@ -0,0 +1,148 @@ +/** Claim API paths (auth required: JWT or NIP-98) */ +const paths: Record> = { + "/claim/quote": { + post: { + tags: ["Claim"], + summary: "Get payout quote", + description: + "Get a quote for a payout. Requires JWT or NIP-98. If logged in with npub, lightning_address is taken from profile; otherwise provide it in the body.", + security: [{ BearerAuth: [] }, { Nostr: [] }], + requestBody: { + content: { + "application/json": { + schema: { $ref: "#/components/schemas/QuoteRequest" }, + }, + }, + }, + responses: { + "200": { + description: "Quote created", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/QuoteResponse" }, + }, + }, + }, + "400": { + description: "Invalid lightning_address or no profile address (npub login)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + "401": { + description: "Unauthorized", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + "403": { + description: "Not eligible or address locked (npub)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/EligibilityDeniedResponse" }, + }, + }, + }, + "429": { + description: "Rate limited", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/RateLimitedResponse" }, + }, + }, + }, + }, + }, + }, + "/claim/confirm": { + post: { + tags: ["Claim"], + summary: "Confirm payout", + description: + "Confirm and execute the payout using a quote_id from /claim/quote. Pays to the Lightning address from the quote.", + security: [{ BearerAuth: [] }, { Nostr: [] }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ConfirmRequest" }, + }, + }, + }, + responses: { + "200": { + description: "Payout success or already consumed", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ConfirmResponse" }, + }, + }, + }, + "400": { + description: "Invalid quote_id or quote expired", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + "401": { + description: "Unauthorized", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + "403": { + description: "Quote does not belong to this pubkey", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + "404": { + description: "Quote not found or expired", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + "429": { + description: "Rate limited", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/RateLimitedResponse" }, + }, + }, + }, + "502": { + description: "Lightning payment failed", + content: { + "application/json": { + schema: { + allOf: [ + { $ref: "#/components/schemas/ErrorResponse" }, + { + properties: { + code: { example: "payout_failed" }, + details: { type: "string" }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + }, +}; + +export default paths; diff --git a/backend/src/openapi/paths/public.ts b/backend/src/openapi/paths/public.ts new file mode 100644 index 0000000..164e56d --- /dev/null +++ b/backend/src/openapi/paths/public.ts @@ -0,0 +1,130 @@ +/** Public API paths (no auth) */ +const paths: Record> = { + "/health": { + get: { + tags: ["Public"], + summary: "Health check", + description: "Returns API health status.", + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/HealthResponse" }, + }, + }, + }, + }, + }, + }, + "/config": { + get: { + tags: ["Public"], + summary: "Get faucet config", + description: "Returns public faucet configuration (enabled, limits, eligibility thresholds).", + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ConfigResponse" }, + }, + }, + }, + }, + }, + }, + "/stats": { + get: { + tags: ["Public"], + summary: "Get stats", + description: "Returns faucet stats: balance, total paid, claims count, recent payouts and deposits.", + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/StatsResponse" }, + }, + }, + }, + "500": { + description: "Internal error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + }, + }, + }, + "/deposit": { + get: { + tags: ["Public"], + summary: "Get deposit info", + description: "Returns Lightning address and LNURLp for depositing to the faucet.", + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/DepositInfoResponse" }, + }, + }, + }, + }, + }, + }, + "/deposit/redeem-cashu": { + post: { + tags: ["Public"], + summary: "Redeem Cashu token", + description: "Redeem a Cashu token to the faucet's Lightning address. Records deposit on success.", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CashuRedeemRequest" }, + }, + }, + }, + responses: { + "200": { + description: "Redeem success", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CashuRedeemResponse" }, + }, + }, + }, + "400": { + description: "Invalid token or redeem failed", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CashuRedeemResponse" }, + }, + }, + }, + "502": { + description: "Redeem API error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + "503": { + description: "Deposit Lightning address not configured", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + }, + }, + }, +}; + +export default paths; diff --git a/backend/src/openapi/paths/user.ts b/backend/src/openapi/paths/user.ts new file mode 100644 index 0000000..3bc5e03 --- /dev/null +++ b/backend/src/openapi/paths/user.ts @@ -0,0 +1,48 @@ +/** User API paths (auth required: JWT or NIP-98) */ +const paths: Record> = { + "/user/refresh-profile": { + post: { + tags: ["User"], + summary: "Refresh Nostr profile", + description: + "Fetch Nostr profile (kind 0) and return cached lightning_address and name. Pre-fills the frontend and stores in DB.", + security: [{ BearerAuth: [] }, { Nostr: [] }], + responses: { + "200": { + description: "Profile refreshed", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/RefreshProfileResponse" }, + }, + }, + }, + "401": { + description: "Unauthorized", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + "429": { + description: "Rate limited", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/RateLimitedResponse" }, + }, + }, + }, + "500": { + description: "Profile fetch failed", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + }, + }, + }, +}; + +export default paths; diff --git a/backend/src/openapi/schemas.ts b/backend/src/openapi/schemas.ts new file mode 100644 index 0000000..c658914 --- /dev/null +++ b/backend/src/openapi/schemas.ts @@ -0,0 +1,153 @@ +/** Reusable OpenAPI component schemas */ +const schemas: Record; required?: string[] }> = { + ErrorResponse: { + type: "object", + properties: { + code: { type: "string", description: "Error code" }, + message: { type: "string", description: "Human-readable message" }, + details: { type: "string", description: "Additional details (optional)" }, + }, + required: ["code", "message"], + }, + RateLimitedResponse: { + type: "object", + properties: { + code: { type: "string", example: "rate_limited" }, + message: { type: "string", example: "Too many requests." }, + }, + required: ["code", "message"], + }, + HealthResponse: { + type: "object", + properties: { status: { type: "string", example: "ok" } }, + required: ["status"], + }, + ConfigResponse: { + type: "object", + properties: { + faucetEnabled: { type: "boolean" }, + emergencyStop: { type: "boolean" }, + cooldownDays: { type: "integer" }, + minAccountAgeDays: { type: "integer" }, + minActivityScore: { type: "integer" }, + faucetMinSats: { type: "integer" }, + faucetMaxSats: { type: "integer" }, + }, + required: ["faucetEnabled", "emergencyStop", "cooldownDays", "minAccountAgeDays", "minActivityScore", "faucetMinSats", "faucetMaxSats"], + }, + StatsResponse: { + type: "object", + properties: { + balanceSats: { type: "number" }, + totalPaidSats: { type: "number" }, + totalClaims: { type: "number" }, + claimsLast24h: { type: "number" }, + dailyBudgetSats: { type: "number" }, + spentTodaySats: { type: "number" }, + recentPayouts: { type: "array", items: { type: "object" } }, + recentDeposits: { type: "array", items: { type: "object" } }, + }, + required: ["balanceSats", "totalPaidSats", "totalClaims", "claimsLast24h", "dailyBudgetSats", "spentTodaySats", "recentPayouts", "recentDeposits"], + }, + DepositInfoResponse: { + type: "object", + properties: { + lightningAddress: { type: "string", nullable: true }, + lnurlp: { type: "string", nullable: true }, + }, + required: ["lightningAddress", "lnurlp"], + }, + CashuRedeemRequest: { + type: "object", + properties: { token: { type: "string", description: "Cashu token (cashuA... or cashuB...)" } }, + required: ["token"], + }, + CashuRedeemResponse: { + type: "object", + properties: { + success: { type: "boolean" }, + paid: { type: "boolean" }, + amount: { type: "number" }, + invoiceAmount: { type: "number" }, + netAmount: { type: "number" }, + to: { type: "string" }, + message: { type: "string" }, + error: { type: "string" }, + errorType: { type: "string" }, + }, + required: ["success"], + }, + LoginNpubRequest: { + type: "object", + properties: { npub: { type: "string", description: "NIP-19 npub-encoded public key" } }, + required: ["npub"], + }, + LoginResponse: { + type: "object", + properties: { + token: { type: "string", description: "JWT for subsequent requests" }, + pubkey: { type: "string" }, + method: { type: "string", enum: ["nip98", "npub"] }, + }, + required: ["token", "pubkey", "method"], + }, + MeResponse: { + type: "object", + properties: { + pubkey: { type: "string" }, + method: { type: "string", enum: ["nip98", "npub"] }, + }, + required: ["pubkey", "method"], + }, + QuoteRequest: { + type: "object", + properties: { + lightning_address: { type: "string", description: "user@domain (optional if npub login with profile address)" }, + }, + }, + QuoteResponse: { + type: "object", + properties: { + quote_id: { type: "string" }, + payout_sats: { type: "number" }, + expires_at: { type: "number", description: "Unix timestamp" }, + }, + required: ["quote_id", "payout_sats", "expires_at"], + }, + ConfirmRequest: { + type: "object", + properties: { quote_id: { type: "string" } }, + required: ["quote_id"], + }, + ConfirmResponse: { + type: "object", + properties: { + success: { type: "boolean" }, + payout_sats: { type: "number" }, + payment_hash: { type: "string" }, + next_eligible_at: { type: "number", description: "Unix timestamp" }, + already_consumed: { type: "boolean" }, + message: { type: "string" }, + }, + required: ["success"], + }, + RefreshProfileResponse: { + type: "object", + properties: { + lightning_address: { type: "string", nullable: true }, + name: { type: "string", nullable: true }, + }, + required: ["lightning_address", "name"], + }, + EligibilityDeniedResponse: { + type: "object", + properties: { + code: { type: "string" }, + message: { type: "string" }, + next_eligible_at: { type: "number", nullable: true }, + }, + required: ["code", "message"], + }, +}; + +export default schemas; diff --git a/backend/src/services/eligibility.ts b/backend/src/services/eligibility.ts index 1bb6b6d..ee6cd24 100644 --- a/backend/src/services/eligibility.ts +++ b/backend/src/services/eligibility.ts @@ -96,7 +96,10 @@ export async function checkEligibility(pubkey: string, ipHash: string): Promise< const profile = await fetchAndScorePubkey(pubkey); const minAgeSec = config.minAccountAgeDays * SECONDS_PER_DAY; const cutoff = now - minAgeSec; + console.log(`[eligibility] pubkey=${pubkey.slice(0, 8)}… firstSeen=${profile.nostrFirstSeenAt} cutoff=${cutoff} score=${profile.activityScore}`); if (profile.nostrFirstSeenAt === null || profile.nostrFirstSeenAt > cutoff) { + const ageDays = profile.nostrFirstSeenAt ? Math.floor((now - profile.nostrFirstSeenAt) / SECONDS_PER_DAY) : "null"; + console.log(`[eligibility] DENIED account_too_new: firstSeen age=${ageDays} days, required=${config.minAccountAgeDays}`); return { eligible: false, denialCode: "account_too_new", diff --git a/backend/src/services/nostr.ts b/backend/src/services/nostr.ts index 7a99c7a..6e6bd5e 100644 --- a/backend/src/services/nostr.ts +++ b/backend/src/services/nostr.ts @@ -19,6 +19,27 @@ function withTimeout(promise: Promise, ms: number): Promise { ]); } +/** + * pool.get resolves as soon as ONE relay responds (or null if none do). + * Much faster than querySync which waits for EOSE from ALL relays. + */ +async function probeOldEvent(pubkey: string, untilTimestamp: number): Promise { + try { + const ev = await withTimeout( + pool.get(config.nostrRelays, { + kinds: [0, 1, 3], + authors: [pubkey], + until: untilTimestamp, + limit: 1, + }), + config.relayTimeoutMs + ); + return ev ? ev.created_at : null; + } catch { + return null; + } +} + /** * Fetch events from relays in parallel (kinds 0, 1, 3), compute metrics, optionally cache. * When forceRefreshProfile is true, always fetch from relays (skip cache) so kind 0 is parsed and lightning_address/name updated. @@ -40,15 +61,22 @@ export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile = }; } - let events: { kind: number; created_at: number; content?: string; tags: string[][] }[] = []; - try { - const result = await withTimeout( - pool.querySync(config.nostrRelays, { kinds: [0, 1, 3], authors: [pubkey], limit: config.maxEventsFetch }), - config.relayTimeoutMs - ); - events = Array.isArray(result) ? result : []; - } catch (_) { - // Timeout or relay error: use cache if any; otherwise upsert minimal user so /refresh-profile returns a row + // Run the main activity query and the account-age probe in parallel. + // The age probe uses pool.get (resolves on first relay hit) so it's fast. + const ageCutoff = nowSec - config.minAccountAgeDays * 86400; + + const mainQueryPromise = withTimeout( + pool.querySync(config.nostrRelays, { kinds: [0, 1, 3], authors: [pubkey], limit: config.maxEventsFetch }), + config.relayTimeoutMs + ).then((r) => (Array.isArray(r) ? r : [])) + .catch((): { kind: number; created_at: number; content?: string; tags: string[][] }[] => []); + + const ageProbePromise = probeOldEvent(pubkey, ageCutoff); + + const [events, oldEventTimestamp] = await Promise.all([mainQueryPromise, ageProbePromise]); + + if (events.length === 0 && oldEventTimestamp === null) { + // Both queries failed or returned nothing if (cached) { return { nostrFirstSeenAt: cached.nostr_first_seen_at, @@ -83,10 +111,24 @@ export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile = const kind1 = events.filter((e) => e.kind === 1); const kind3 = events.filter((e) => e.kind === 3); - const earliestCreatedAt = events.length + // Determine earliest known timestamp from all sources + let earliestCreatedAt: number | null = events.length ? Math.min(...events.map((e) => e.created_at)) : null; + if (oldEventTimestamp !== null) { + earliestCreatedAt = earliestCreatedAt === null + ? oldEventTimestamp + : Math.min(earliestCreatedAt, oldEventTimestamp); + } + + // Never overwrite a known-older timestamp with a newer one + if (cached?.nostr_first_seen_at != null) { + earliestCreatedAt = earliestCreatedAt === null + ? cached.nostr_first_seen_at + : Math.min(earliestCreatedAt, cached.nostr_first_seen_at); + } + const lookbackSince = nowSec - config.activityLookbackDays * 86400; const notesInLookback = kind1.filter((e) => e.created_at >= lookbackSince).length; @@ -110,7 +152,6 @@ export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile = if (kind0.length > 0 && kind0[0].content) { try { const meta = JSON.parse(kind0[0].content) as Record; - // NIP-19 / common: lud16 is the Lightning address (user@domain). Fallbacks for other clients. for (const key of ["lud16", "lightning", "ln_address", "nip05"] as const) { const v = meta[key]; if (typeof v === "string") { @@ -129,6 +170,8 @@ export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile = const nostrFirstSeenAt = earliestCreatedAt; const lastMetadataFetchAt = Math.floor(Date.now() / 1000); + console.log(`[nostr] pubkey=${pubkey.slice(0, 8)}… events=${events.length} ageProbe=${oldEventTimestamp} firstSeen=${nostrFirstSeenAt} score=${score}`); + await db.upsertUser({ pubkey, nostr_first_seen_at: nostrFirstSeenAt, diff --git a/frontend/.env.example b/frontend/.env.example index 768805a..8cea093 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,3 +1,6 @@ # Backend API URL (required in dev when frontend runs on different port) # Leave empty if frontend is served from same origin as API VITE_API_URL=http://localhost:3001 + +# Nostr relays for fetching user profile metadata (comma-separated) +VITE_NOSTR_RELAYS=wss://relay.damus.io,wss://relay.nostr.band,wss://nos.lol diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3bac0cf..4f91538 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -49,6 +49,12 @@ export default function App() { setLoginMethod(pk ? (method ?? "nip98") : null); }, []); + const handleLogout = useCallback(() => { + clearToken(); + setPubkey(null); + setLoginMethod(null); + }, []); + const handleClaimSuccess = useCallback(() => { setStatsRefetchTrigger((t) => t + 1); }, []); @@ -56,7 +62,7 @@ export default function App() { return (
-
+
diff --git a/frontend/src/components/ClaimWizard.tsx b/frontend/src/components/ClaimWizard.tsx index ba947e7..35a6185 100644 --- a/frontend/src/components/ClaimWizard.tsx +++ b/frontend/src/components/ClaimWizard.tsx @@ -1,11 +1,15 @@ -import { useState, useEffect, useRef, useMemo } from "react"; +import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { postUserRefreshProfile, type UserProfile, type LoginMethod } from "../api"; import { useClaimFlow } from "../hooks/useClaimFlow"; +import { useNostrProfile } from "../hooks/useNostrProfile"; import { StepIndicator } from "./StepIndicator"; import { ConnectStep } from "./ConnectStep"; import { EligibilityStep } from "./EligibilityStep"; import { ConfirmStep } from "./ConfirmStep"; import { SuccessStep } from "./SuccessStep"; +import { ClaimDenialPanel } from "./ClaimDenialPanel"; +import { Modal } from "./Modal"; +import { ELIGIBILITY_PROGRESS_STEPS } from "../hooks/useClaimFlow"; const LIGHTNING_ADDRESS_REGEX = /^[^@]+@[^@]+$/; @@ -13,16 +17,6 @@ function isValidLightningAddress(addr: string): boolean { return LIGHTNING_ADDRESS_REGEX.test(addr.trim()); } -function getWizardStep( - hasPubkey: boolean, - claimState: ReturnType["claimState"] -): 1 | 2 | 3 | 4 { - if (!hasPubkey) return 1; - if (claimState === "success") return 4; - if (claimState === "quote_ready" || claimState === "confirming" || claimState === "error") return 3; - return 2; -} - interface ClaimWizardProps { pubkey: string | null; loginMethod: LoginMethod | null; @@ -34,41 +28,60 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces const [profile, setProfile] = useState(null); const [lightningAddress, setLightningAddress] = useState(""); const [lightningAddressTouched, setLightningAddressTouched] = useState(false); + const [claimModalOpen, setClaimModalOpen] = useState(false); const wizardRef = useRef(null); + const autoCheckRef = useRef(null); const claim = useClaimFlow(); + const nostrProfile = useNostrProfile(pubkey); - const currentStep = useMemo( - () => getWizardStep(!!pubkey, claim.claimState), - [pubkey, claim.claimState] - ); + const isConnected = !!pubkey; + const isBusy = claim.loading !== "idle"; + const hasResult = !!claim.quote || !!claim.denial || !!claim.success || !!claim.confirmError; useEffect(() => { if (!pubkey) { setProfile(null); setLightningAddress(""); + autoCheckRef.current = null; return; } postUserRefreshProfile() .then((p) => { setProfile(p); const addr = (p.lightning_address ?? "").trim(); - setLightningAddress(addr); + if (addr) setLightningAddress(addr); }) .catch(() => setProfile(null)); }, [pubkey]); useEffect(() => { - if (currentStep === 2 && pubkey && wizardRef.current) { - wizardRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" }); + if (!nostrProfile?.lud16 || !pubkey) return; + const relayAddr = nostrProfile.lud16.trim(); + if (relayAddr && isValidLightningAddress(relayAddr)) { + setLightningAddress(relayAddr); } - }, [currentStep, pubkey]); + }, [nostrProfile, pubkey]); useEffect(() => { - if (currentStep === 4 && wizardRef.current) { - wizardRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" }); + if ( + !pubkey || + autoCheckRef.current === pubkey || + claim.loading !== "idle" || + claim.claimState !== "connected_idle" + ) return; + const addr = lightningAddress.trim(); + if (!addr || !isValidLightningAddress(addr)) return; + autoCheckRef.current = pubkey; + setClaimModalOpen(true); + claim.checkEligibility(addr); + }, [pubkey, lightningAddress, claim.loading, claim.claimState, claim.checkEligibility]); + + useEffect(() => { + if (isBusy || hasResult) { + setClaimModalOpen(true); } - }, [currentStep]); + }, [isBusy, hasResult]); const handleDisconnect = () => { onPubkeyChange(null); @@ -77,10 +90,12 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces claim.resetSuccess(); claim.clearDenial(); claim.clearConfirmError(); + setClaimModalOpen(false); }; const handleDone = () => { claim.resetSuccess(); + setClaimModalOpen(false); onClaimSuccess?.(); }; @@ -89,26 +104,59 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces claim.clearDenial(); claim.clearConfirmError(); setLightningAddressTouched(false); - // Stay on step 2 (eligibility) + setClaimModalOpen(false); }; const handleCheckEligibility = () => { + setClaimModalOpen(true); claim.checkEligibility(lightningAddress); }; const handleCancelQuote = () => { claim.cancelQuote(); claim.clearConfirmError(); + setClaimModalOpen(false); + }; + + const handleCloseModal = useCallback(() => { + if (claim.loading !== "idle") return; + claim.cancelQuote(); + claim.clearDenial(); + claim.clearConfirmError(); + claim.resetSuccess(); + setClaimModalOpen(false); + }, [claim]); + + const handleDismissDenial = () => { + claim.clearDenial(); + setClaimModalOpen(false); + }; + + const handleCheckAgain = () => { + claim.clearDenial(); + setClaimModalOpen(false); }; const lightningAddressInvalid = lightningAddressTouched && lightningAddress.trim() !== "" && !isValidLightningAddress(lightningAddress); const fromProfile = - Boolean(profile?.lightning_address) && - lightningAddress.trim() === (profile?.lightning_address ?? "").trim(); + (Boolean(profile?.lightning_address) && + lightningAddress.trim() === (profile?.lightning_address ?? "").trim()) || + (Boolean(nostrProfile?.lud16) && + lightningAddress.trim() === (nostrProfile?.lud16 ?? "").trim()); const quoteExpired = claim.quote != null && claim.quote.expires_at <= Math.floor(Date.now() / 1000); + const modalTitle = useMemo(() => { + if (claim.success) return "Sats sent!"; + if (claim.confirmError) return "Something went wrong"; + if (claim.loading === "confirm") return "Sending sats…"; + if (claim.quote) return "Confirm payout"; + if (claim.denial) return "Not eligible"; + if (claim.loading === "quote") return "Checking eligibility"; + return "Claim"; + }, [claim.success, claim.confirmError, claim.loading, claim.quote, claim.denial]); + return (
@@ -126,20 +174,18 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces )}
- +
- {currentStep === 1 && ( + {!isConnected ? ( onPubkeyChange(pk, method)} onDisconnect={handleDisconnect} /> - )} - - {currentStep === 2 && ( + ) : ( { - claim.clearDenial(); - }} + /> + )} +
+
+ + +
+ {claim.loading === "quote" && ( +
+
+

+ {ELIGIBILITY_PROGRESS_STEPS[claim.eligibilityProgressStep ?? 0]} +

+
+ )} + + {claim.denial && ( + )} - {currentStep === 3 && claim.quote && ( + {(claim.claimState === "quote_ready" || claim.claimState === "confirming" || claim.claimState === "error") && claim.quote && ( )} - {currentStep === 4 && claim.success && ( + {claim.success && ( )}
-
+
); } diff --git a/frontend/src/components/EligibilityStep.tsx b/frontend/src/components/EligibilityStep.tsx index 2f15f86..2ede70f 100644 --- a/frontend/src/components/EligibilityStep.tsx +++ b/frontend/src/components/EligibilityStep.tsx @@ -1,7 +1,4 @@ import { useState } from "react"; -import { ClaimDenialPanel } from "./ClaimDenialPanel"; -import { ELIGIBILITY_PROGRESS_STEPS } from "../hooks/useClaimFlow"; -import type { DenialState } from "../hooks/useClaimFlow"; interface EligibilityStepProps { lightningAddress: string; @@ -12,11 +9,7 @@ interface EligibilityStepProps { fromProfile: boolean; readOnly?: boolean; loading: boolean; - eligibilityProgressStep: number | null; - denial: DenialState | null; onCheckEligibility: () => void; - onClearDenial: () => void; - onCheckAgain?: () => void; } const LIGHTNING_ADDRESS_REGEX = /^[^@]+@[^@]+$/; @@ -30,11 +23,7 @@ export function EligibilityStep({ fromProfile, readOnly, loading, - eligibilityProgressStep, - denial, onCheckEligibility, - onClearDenial, - onCheckAgain, }: EligibilityStepProps) { const [editing, setEditing] = useState(false); const canCheck = !loading && lightningAddress.trim() !== "" && LIGHTNING_ADDRESS_REGEX.test(lightningAddress.trim()); @@ -94,14 +83,7 @@ export function EligibilityStep({

)} - {noAddressForNpub ? null : loading ? ( -
-
-

- {ELIGIBILITY_PROGRESS_STEPS[eligibilityProgressStep ?? 0]} -

-
- ) : ( + {!noAddressForNpub && (
)} - - {denial && ( -
- -
- )}
); } diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 37f301c..72a3ca2 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,7 +1,50 @@ +import { useState, useRef, useEffect, useCallback } from "react"; import { Link, useLocation } from "react-router-dom"; +import { useNostrProfile } from "../hooks/useNostrProfile"; +import { nip19 } from "nostr-tools"; -export function Header() { +interface HeaderProps { + pubkey: string | null; + onLogout?: () => void; +} + +function truncatedNpub(pubkey: string): string { + const npub = nip19.npubEncode(pubkey); + return npub.slice(0, 12) + "..." + npub.slice(-4); +} + +export function Header({ pubkey, onLogout }: HeaderProps) { const location = useLocation(); + const profile = useNostrProfile(pubkey); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + + const displayName = profile?.display_name || profile?.name || (pubkey ? truncatedNpub(pubkey) : null); + + const handleToggle = useCallback(() => setMenuOpen((o) => !o), []); + + useEffect(() => { + if (!menuOpen) return; + function onClickOutside(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false); + } + } + function onEscape(e: KeyboardEvent) { + if (e.key === "Escape") setMenuOpen(false); + } + document.addEventListener("mousedown", onClickOutside); + document.addEventListener("keydown", onEscape); + return () => { + document.removeEventListener("mousedown", onClickOutside); + document.removeEventListener("keydown", onEscape); + }; + }, [menuOpen]); + + const handleLogout = () => { + setMenuOpen(false); + onLogout?.(); + }; return (
@@ -9,20 +52,67 @@ export function Header() { Sats Faucet - +
+ + {pubkey && ( +
+ + {menuOpen && ( +
+
+ {profile?.display_name || profile?.name || "Nostr User"} + {truncatedNpub(pubkey)} +
+
+ +
+ )} +
+ )} +
); diff --git a/frontend/src/hooks/useNostrProfile.ts b/frontend/src/hooks/useNostrProfile.ts new file mode 100644 index 0000000..0060ed6 --- /dev/null +++ b/frontend/src/hooks/useNostrProfile.ts @@ -0,0 +1,62 @@ +import { useState, useEffect, useRef } from "react"; +import { SimplePool } from "nostr-tools"; + +export interface NostrProfile { + name?: string; + display_name?: string; + picture?: string; + about?: string; + nip05?: string; + lud16?: string; +} + +const RELAYS = (import.meta.env.VITE_NOSTR_RELAYS as string || "wss://relay.damus.io,wss://nos.lol") + .split(",") + .map((r: string) => r.trim()) + .filter(Boolean); + +const cache = new Map(); + +export function useNostrProfile(pubkey: string | null): NostrProfile | null { + const [profile, setProfile] = useState(() => + pubkey ? cache.get(pubkey) ?? null : null, + ); + const poolRef = useRef(null); + + useEffect(() => { + if (!pubkey) { + setProfile(null); + return; + } + + const cached = cache.get(pubkey); + if (cached) { + setProfile(cached); + return; + } + + const pool = new SimplePool(); + poolRef.current = pool; + let cancelled = false; + + pool + .get(RELAYS, { kinds: [0], authors: [pubkey], limit: 1 }) + .then((ev) => { + if (cancelled || !ev) return; + try { + const meta = JSON.parse(ev.content) as NostrProfile; + cache.set(pubkey, meta); + setProfile(meta); + } catch { /* malformed content */ } + }) + .catch(() => {}); + + return () => { + cancelled = true; + pool.close(RELAYS); + poolRef.current = null; + }; + }, [pubkey]); + + return profile; +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index bceb98e..353bcf2 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -102,6 +102,149 @@ body { background: rgba(249, 115, 22, 0.1); } +/* Header layout: right side groups nav + user */ +.site-header-right { + display: flex; + align-items: center; + gap: 16px; +} +.header-user { + position: relative; + padding-left: 16px; + border-left: 1px solid var(--border); +} +.header-user-trigger { + display: flex; + align-items: center; + gap: 10px; + background: none; + border: none; + cursor: pointer; + padding: 4px 8px; + border-radius: 8px; + transition: background 0.15s; + color: var(--text); +} +.header-user-trigger:hover { + background: var(--bg-card-hover); +} +.header-user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + border: 2px solid var(--border); +} +.header-user-avatar--placeholder { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--bg-card-hover); + color: var(--text-muted); + font-size: 14px; + font-weight: 600; +} +.header-user-name { + font-size: 14px; + font-weight: 500; + color: var(--text); + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.header-user-chevron { + color: var(--text-muted); + transition: transform 0.2s; + flex-shrink: 0; +} +.header-user-chevron.open { + transform: rotate(180deg); +} + +/* User dropdown menu */ +.header-user-menu { + position: absolute; + top: calc(100% + 8px); + right: 0; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4); + min-width: 200px; + z-index: 100; + overflow: hidden; + animation: menu-in 0.15s ease-out; +} +@keyframes menu-in { + from { opacity: 0; transform: translateY(-4px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +.header-user-menu-info { + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 2px; +} +.header-user-menu-name { + font-size: 14px; + font-weight: 600; + color: var(--text); +} +.header-user-menu-npub { + font-size: 12px; + color: var(--text-muted); + font-family: monospace; +} +.header-user-menu-divider { + height: 1px; + background: var(--border); +} +.header-user-menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 16px; + background: none; + border: none; + color: var(--text-muted); + font-size: 14px; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.header-user-menu-item:hover { + background: var(--bg-card-hover); + color: var(--error); +} + +/* Claim modal body */ +.claim-modal-body { + padding: 24px; +} +.claim-modal-body .claim-wizard-step { + padding: 0; +} +.claim-modal-body .claim-denial-panel { + margin: 0; + border: none; + box-shadow: none; + background: transparent; + padding: 0; +} +.claim-modal-progress { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 24px 0; +} +.claim-modal-progress-text { + font-size: 14px; + color: var(--text-muted); +} + .topbar { background: linear-gradient(90deg, var(--accent) 0%, #fb923c 100%); height: 4px; @@ -2221,6 +2364,10 @@ h1 { .site-header { padding: 0 16px; } .site-header-inner { height: 52px; } .site-nav-link { padding: 8px 12px; font-size: 13px; } + .header-user-name { display: none; } + .header-user-chevron { display: none; } + .header-user { padding-left: 12px; } + .header-user-trigger { gap: 0; padding: 4px; } .site-footer { padding: 20px 16px 24px; } .site-footer-nav { flex-wrap: wrap; justify-content: center; gap: 16px; }