Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions app/api/account/coinpay/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { resolveAccountId, unauthorized } from "@/lib/api";
import { coinpayLinkStatus, getAccountById, unlinkCoinpay } from "@/lib/db";
import { coinpayConfigured, readSession, signSession, SESSION_COOKIE, sessionCookieOptions } from "@/lib/session";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/** CoinPay link status for the dashboard's Connect / Disconnect card. */
export async function GET(req: NextRequest) {
const id = await resolveAccountId(req);
if (!id) return unauthorized();
return NextResponse.json({ enabled: coinpayConfigured(), ...(await coinpayLinkStatus(id)) });
}

/** Disconnect CoinPay. Refused when it is the account's only way to sign in. */
export async function DELETE(req: NextRequest) {
const id = await resolveAccountId(req);
if (!id) return unauthorized();
const r = await unlinkCoinpay(id);
if (!r.ok) {
const error = r.reason === "only_login"
? "CoinPay is the only way into this account, so it cannot be disconnected."
: "CoinPay is not connected.";
return NextResponse.json({ error }, { status: 409 });
}
const res = NextResponse.json({ ok: true });
// A session that was a CoinPay sign-in would otherwise resolve to a fresh
// CoinPay-only account on the next request: move it onto the account itself.
const s = readSession(req.cookies.get(SESSION_COOKIE)?.value);
if (s && !s.sub.startsWith("acct:")) {
const acct = await getAccountById(id);
res.cookies.set(SESSION_COOKIE, signSession({ sub: `acct:${id}`, email: acct?.email ?? null, name: s.name ?? null }), sessionCookieOptions(req));
}
return res;
}
47 changes: 33 additions & 14 deletions app/auth/coinpay/callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { coinpayConfigured, signSession, SESSION_COOKIE, sessionCookieOptions } from "@/lib/session";
import { exchangeCode, fetchUserinfo } from "@/lib/oauth";
import { coinpayConfigured, readSession, signSession, SESSION_COOKIE, sessionCookieOptions } from "@/lib/session";
import { finishCoinpaySignIn, stateMatches, PENDING_COOKIES } from "@/lib/oauth";
import { requestHosts, resolveOrigin, redirectUriFor } from "@/lib/oauth-origin";
import { upsertUser, accountIdForCoinpay } from "@/lib/db";
import { upsertUser, accountIdForCoinpay, linkCoinpay } from "@/lib/db";
import { acceptInviteAndNotify, INVITE_COOKIE } from "@/lib/credits";

export const runtime = "nodejs";
Expand All @@ -19,43 +19,62 @@ function requestIsHttps(req: NextRequest): boolean {
return req.nextUrl.protocol === "https:" || (process.env.APP_BASE_URL || "").startsWith("https://");
}

function clearPending(res: NextResponse) {
for (const name of Object.values(PENDING_COOKIES)) res.cookies.delete(name);
}

export async function GET(req: NextRequest) {
if (!coinpayConfigured()) return new NextResponse("CoinPay login is not configured yet.", { status: 503 });
const sp = req.nextUrl.searchParams;
if (sp.get("error")) return fail(sp.get("error_description") || sp.get("error")!);
const code = sp.get("code");
if (!code) return fail("no authorization code");
const state = sp.get("state");
const cookieState = req.cookies.get("cp_state")?.value;
if (!state || state !== cookieState) return fail("state mismatch");
const verifier = req.cookies.get("cp_pkce")?.value;
if (!verifier) return fail("missing PKCE verifier (session expired)");
if (!stateMatches(req.cookies.get(PENDING_COOKIES.state)?.value, sp.get("state"))) return fail("state mismatch");
const verifier = req.cookies.get(PENDING_COOKIES.verifier)?.value;
const nonce = req.cookies.get(PENDING_COOKIES.nonce)?.value;
if (!verifier || !nonce) return fail("missing PKCE verifier (session expired)");
const mode = req.cookies.get(PENDING_COOKIES.mode)?.value === "connect" ? "connect" : "login";

// Same derivation as the authorize step, so the redirect_uri matches byte for
// byte -- the callback arrives on whatever host that step named.
const origin = resolveOrigin(requestHosts(req.headers), requestIsHttps(req));

let info: { sub: string; email: string | null; name: string | null };
try {
const tokens = await exchangeCode(code, verifier, redirectUriFor(origin));
const info = await fetchUserinfo(tokens.access_token);
if (!info?.sub) return fail("no subject in userinfo");
info = await finishCoinpaySignIn({ code, verifier, nonce, redirectUri: redirectUriFor(origin) });
} catch (err: any) {
console.error("[auth] callback error:", err?.message);
return fail("CoinPay sign-in could not be verified");
}

try {
if (mode === "connect") {
// Linking needs the password account to be signed in right now.
const s = readSession(req.cookies.get(SESSION_COOKIE)?.value);
if (!s?.sub?.startsWith("acct:")) return fail("sign in to your moshcoding account first");
const r = await linkCoinpay(s.sub.slice("acct:".length), info.sub);
const res = NextResponse.redirect(`${origin}/dashboard?coinpay=${r.ok ? "linked" : r.reason}`);
clearPending(res);
return res;
}

await upsertUser({ sub: info.sub, email: info.email, name: info.name });
// Bound by sub; a first sign-in gets its own account (never one found by email).
const accountId = await accountIdForCoinpay(info.sub);

// Arrived through an invite link (/i/<code> set the cookie): accept it on
// the account this CoinPay identity is bound to. Never blocks the sign-in.
const invite = req.cookies.get(INVITE_COOKIE)?.value;
let landing = `${origin}/`;
if (invite) {
const accountId = await accountIdForCoinpay(info.sub, info.email).catch(() => null);
const r = accountId ? await acceptInviteAndNotify(invite, accountId, origin).catch(() => null) : null;
landing = `${origin}/invites?invite=${encodeURIComponent(r ? (r.ok ? r.status : r.reason) : "no_account")}`;
}

// Land back on the host the user started from, where the session cookie applies.
const res = NextResponse.redirect(landing);
res.cookies.set(SESSION_COOKIE, signSession({ sub: info.sub, email: info.email ?? null, name: info.name ?? null }), sessionCookieOptions(req));
res.cookies.delete("cp_pkce");
res.cookies.delete("cp_state");
clearPending(res);
if (invite) res.cookies.delete(INVITE_COOKIE);
return res;
} catch (err: any) {
Expand Down
28 changes: 21 additions & 7 deletions app/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { coinpayConfigured } from "@/lib/session";
import { makePkce, authorizeUrl } from "@/lib/oauth";
import { coinpayConfigured, readSession, SESSION_COOKIE } from "@/lib/session";
import { makePkce, authorizeUrl, PENDING_COOKIES } from "@/lib/oauth";
import { requestHosts, resolveOrigin, redirectUriFor } from "@/lib/oauth-origin";

export const runtime = "nodejs";
Expand All @@ -13,18 +13,32 @@ function requestIsHttps(req: NextRequest): boolean {
return req.nextUrl.protocol === "https:" || (process.env.APP_BASE_URL || "").startsWith("https://");
}

/**
* Starts "Sign in with CoinPay". `?mode=connect` links CoinPay to the password
* account that is signed in right now, so it needs that session; it is the
* only way a CoinPay identity joins an existing account (never by email).
*/
export async function GET(req: NextRequest) {
if (!coinpayConfigured()) {
return new NextResponse("CoinPay login is not configured yet.", { status: 503 });
}
const mode = req.nextUrl.searchParams.get("mode") === "connect" ? "connect" : "login";
if (mode === "connect") {
const s = readSession(req.cookies.get(SESSION_COOKIE)?.value);
if (!s?.sub?.startsWith("acct:")) {
return NextResponse.redirect(new URL("/login", req.nextUrl.origin));
}
}
const secure = requestIsHttps(req);
// Send the IdP back to the host the user is actually on, so the host-only
// cp_pkce / cp_state cookies set below are still readable at the callback.
// pending cookies set below are still readable at the callback.
const origin = resolveOrigin(requestHosts(req.headers), secure);
const { verifier, challenge, state } = makePkce();
const res = NextResponse.redirect(authorizeUrl(challenge, state, redirectUriFor(origin)));
const { verifier, challenge, state, nonce } = makePkce();
const res = NextResponse.redirect(authorizeUrl(challenge, state, redirectUriFor(origin), nonce));
const opts = { httpOnly: true, sameSite: "lax" as const, secure, path: "/", maxAge: 600 };
res.cookies.set("cp_pkce", verifier, opts);
res.cookies.set("cp_state", state, opts);
res.cookies.set(PENDING_COOKIES.verifier, verifier, opts);
res.cookies.set(PENDING_COOKIES.state, state, opts);
res.cookies.set(PENDING_COOKIES.nonce, nonce, opts);
res.cookies.set(PENDING_COOKIES.mode, mode, opts);
return res;
}
50 changes: 50 additions & 0 deletions app/dashboard/[[...tab]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -575,10 +575,60 @@ function AccountPanel({ onError, onOk }: { onError: (m: string) => void; onOk: (
<input className="inp" placeholder="wallet address" value={wallet} onChange={(e) => setWallet(e.target.value)} />
<button className="btn2 ghost" onClick={saveWallet}>Save wallet</button>
</div>

<CoinpayCard onError={onError} onOk={onOk} />
</section>
);
}

const COINPAY_RESULT: Record<string, { t: string; ok: boolean }> = {
linked: { t: "CoinPay connected. You can sign in with it now.", ok: true },
taken: { t: "That CoinPay account is already linked to another moshcoding account.", ok: false },
already_linked: { t: "This account already has a different CoinPay account connected.", ok: false },
no_account: { t: "Sign in to your moshcoding account first.", ok: false },
};

/** Connect / Disconnect CoinPay. Connecting needs a password sign-in (never by email match). */
function CoinpayCard({ onError, onOk }: { onError: (m: string) => void; onOk: (m: string) => void }) {
const [st, setSt] = useState<{ enabled: boolean; linked: boolean; canDisconnect: boolean } | null>(null);
const load = () => fetch("/api/account/coinpay").then((r) => r.ok ? r.json() : null).then(setSt).catch(() => setSt(null));
useEffect(() => {
load();
const res = new URLSearchParams(window.location.search).get("coinpay");
const m = res ? COINPAY_RESULT[res] : null;
if (m) (m.ok ? onOk : onError)(m.t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!st?.enabled) return null;
const disconnect = async () => {
try {
const r = await fetch("/api/account/coinpay", { method: "DELETE" });
const d = await r.json();
if (!r.ok) throw new Error(d.error);
onOk("CoinPay disconnected.");
load();
} catch (e: any) { onError(e.message || "Couldn't disconnect."); }
};
return (
<>
<h3 className="ed-h">Sign in with CoinPay</h3>
{st.linked ? (
<div className="row">
<span className="pill">Connected</span>
{st.canDisconnect
? <button className="btn2 ghost" onClick={disconnect}>Disconnect CoinPay</button>
: <span className="muted">CoinPay is this account&apos;s only sign-in, so it stays connected.</span>}
</div>
) : (
<div className="row">
<a className="btn2 ghost" href="/auth/login?mode=connect">Connect CoinPay</a>
<span className="muted">Then sign in with CoinPay as well as your password.</span>
</div>
)}
</>
);
}

function VideosPanel({ onError, onOk }: { onError: (m: string) => void; onOk: (m: string) => void }) {
const [domains, setDomains] = useState<any[] | undefined>(undefined);
const [active, setActive] = useState<string | null>(null);
Expand Down
13 changes: 7 additions & 6 deletions app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ export default function LoginPage() {
</div>
{msg && <p className={`dash-msg ${msg.ok ? "ok" : "err"}`}>{msg.t}</p>}

{coinpay && (
<div className="card2">
<a className="btn2" href="/auth/login">Sign in with CoinPay</a>
<p className="sub" style={{ marginTop: 12 }}>— or with your email and password —</p>
</div>
)}

<form className="card2" onSubmit={submit}>
<div className="row"><input className="inp" type="email" placeholder="you@email.com" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} /></div>
<div className="row"><input className="inp" type="password" placeholder="Password" autoComplete="current-password" required value={password} onChange={(e) => setPassword(e.target.value)} /></div>
Expand All @@ -50,12 +57,6 @@ export default function LoginPage() {
<a className="btn2 ghost" href="/reset">Forgot password?</a>
</div>
<p className="sub" style={{ marginTop: 12 }}>No account? <a href="/signup">Claim your page — free</a></p>
{coinpay && (
<>
<p className="sub" style={{ marginTop: 12 }}>— or —</p>
<a className="btn2 ghost" href="/auth/login">Log in with CoinPay</a>
</>
)}
</form>
</div>
);
Expand Down
14 changes: 13 additions & 1 deletion app/signup/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { ProfileFields } from "@profullstack/profile/react";

// The shared Profullstack profile, minus email (this form has its own box).
Expand All @@ -25,6 +25,11 @@ export default function SignupPage() {
const [profileErrors, setProfileErrors] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null);
const [coinpay, setCoinpay] = useState(false);

useEffect(() => {
fetch("/api/me").then((r) => r.json()).then((m) => setCoinpay(Boolean(m?.coinpayEnabled))).catch(() => {});
}, []);

const setHandle = (k: string, v: string) => setHandles((h) => ({ ...h, [k]: v }));

Expand Down Expand Up @@ -57,6 +62,13 @@ export default function SignupPage() {
<a className="btn2 ghost" href="/">← home</a>
</div>

{coinpay && (
<div className="card2">
<a className="btn2" href="/auth/login">Sign up with CoinPay</a>
<p className="sub" style={{ marginTop: 12 }}>— or create a password account below —</p>
</div>
)}

<p className="earn-banner">🤘 Earn up to <b>80% commission</b> on all fees generated with your custom affiliate code.</p>
{msg && <p className={`dash-msg ${msg.ok ? "ok" : "err"}`}>{msg.t}</p>}

Expand Down
Loading
Loading