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
185 changes: 185 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions db/schema.pg.sql
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,16 @@ create index if not exists i_host on podcasts(host);
create index if not exists i_score on podcasts(score desc);
create index if not exists i_new on podcasts(newest_pubdate desc);
create index if not exists i_search on podcasts using gin (p0d_search_vector(title, host, author, description));

-- Sign in with CoinPay (src/lib/auth/coinpay.ts). Bound by the CoinPay `sub`
-- only, never by email; coinpay_email is unverified contact info. Also created
-- lazily by src/lib/auth/coinpay-store.ts.
create table if not exists coinpay_identities (
coinpay_sub text primary key,
user_id bigint not null unique references users(id) on delete cascade,
coinpay_email text,
coinpay_name text,
created_via text not null,
created_at bigint not null,
last_login_at bigint
);
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "p0dcasters",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"type": "module",
"scripts": {
Expand All @@ -13,6 +13,7 @@
},
"dependencies": {
"@logicsrc/openaccess": "0.3.1",
"@profullstack/coinpay-onboard": "0.3.1",
"@profullstack/footer": "0.3.0",
"@profullstack/libsql-pg": "^0.1.4",
"@profullstack/openprofile": "^0.2.0",
Expand Down
59 changes: 55 additions & 4 deletions src/app/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { COLUMNS } from "@profullstack/profile";
import { profileStore } from "@/lib/profile";
import Ad from "@/components/Ad";
import { AD_TEXT } from "@/lib/ads";
import { canDisconnect, coinpayConfig, coinpayMessage, isPlaceholderEmail } from "@/lib/auth/coinpay";
import { dbCoinpayStore } from "@/lib/auth/coinpay-store";

export const dynamic = "force-dynamic";

Expand All @@ -21,28 +23,38 @@ export const metadata: Metadata = {
robots: { index: false },
};

export default async function Account() {
export default async function Account({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const user = await currentUser();
if (!user) redirect("/login?next=/account");

const [passkeys, follows, mine, digest] = await Promise.all([
const [passkeys, follows, mine, digest, coinpay] = await Promise.all([
credentialsFor(user.id),
count("SELECT COUNT(*) n FROM follows WHERE user_id = ?", [user.id]),
// A missing table must not take the account page down with it.
profileStore().get(user.id).catch(() => null),
isSubscribed(user.id).catch(() => true),
dbCoinpayStore().identityByUser(user.id).catch(() => null),
]);
const placeholder = isPlaceholderEmail(user.email);
const coinpayNote = coinpayMessage(params.coinpay);
// Only the six fields cross to the client form, as plain strings.
const profileValues: Record<string, string | null> = Object.fromEntries(
COLUMNS.map((c) => [c, mine?.[c] == null ? null : String(mine[c])]),
);
if (!profileValues.email) profileValues.email = user.email;
// A CoinPay-made account has an undeliverable placeholder address; never
// prefill it (nor the unverified CoinPay email) as the reader's.
if (!profileValues.email && !placeholder) profileValues.email = user.email;

return (
<div className="wrap narrow">
<h1>Your account</h1>
<p className="muted">
Signed in as <b>{user.email}</b>. Joined{" "}
Signed in {placeholder ? <>with <b>CoinPay</b></> : <>as <b>{user.email}</b></>}. Joined{" "}
{new Date(user.created_at * 1000).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
Expand Down Expand Up @@ -92,6 +104,45 @@ export default async function Account() {
</p>
</section>

<section className="panel" id="coinpay">
<h2>CoinPay</h2>
{params.coinpay === "connected" && <p className="auth-note">CoinPay is connected.</p>}
{params.coinpay === "disconnected" && <p className="auth-note">CoinPay is disconnected.</p>}
{coinpayNote && <p className="auth-error">{coinpayNote}</p>}
{coinpay ? (
<>
<p className="muted">
Connected: you can sign in here with your CoinPay account
{coinpay.coinpay_email ? <> ({coinpay.coinpay_email})</> : null}.
</p>
{canDisconnect(user, passkeys.length) ? (
<form action="/api/v1/coinpay/disconnect" method="post">
<button className="btn" type="submit">
Disconnect CoinPay
</button>
</form>
) : (
<p className="muted small">
CoinPay is the only way into this account. Add a passkey below before
disconnecting it.
</p>
)}
</>
) : coinpayConfig() ? (
<>
<p className="muted">
Connect your CoinPay account to sign in here with it, the same account you
use across the Profullstack sites.
</p>
<a className="btn" href="/api/v1/coinpay/oauth/start?mode=connect">
Connect CoinPay
</a>
</>
) : (
<p className="muted">Connecting CoinPay is not available right now.</p>
)}
</section>

<section className="panel">
<h2>Passkeys</h2>
<p className="muted">
Expand Down
20 changes: 20 additions & 0 deletions src/app/api/v1/coinpay/disconnect/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { currentUser } from "@/lib/auth/session";
import { credentialsFor } from "@/lib/auth/passkey";
import { canDisconnect } from "@/lib/auth/coinpay";
import { dbCoinpayStore } from "@/lib/auth/coinpay-store";

export const dynamic = "force-dynamic";

const back = (path: string) => new Response(null, { status: 303, headers: { location: path } });

/** POST from /account. The session cookie is SameSite=Lax, so a cross-site form cannot drive it. */
export async function POST() {
const user = await currentUser();
if (!user) return back("/login?next=/account");
const store = dbCoinpayStore();
if (!(await store.identityByUser(user.id))) return back("/account");
const passkeys = await credentialsFor(user.id);
if (!canDisconnect(user, passkeys.length)) return back("/account?coinpay=only_way_in");
await store.unlink(user.id);
return back("/account?coinpay=disconnected");
}
66 changes: 66 additions & 0 deletions src/app/api/v1/coinpay/oauth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { cookies } from "next/headers";
import { finishSignIn, sameState } from "@profullstack/coinpay-onboard/oauth";
import { currentUser, startSession } from "@/lib/auth/session";
import {
PENDING_COOKIE,
PENDING_COOKIE_PATH,
claimsFrom,
coinpayConfig,
decodePending,
redirectUri,
resolveCoinpayUser,
} from "@/lib/auth/coinpay";
import { dbCoinpayStore } from "@/lib/auth/coinpay-store";

export const dynamic = "force-dynamic";

const back = (path: string) =>
new Response(null, { status: 302, headers: { location: path, "cache-control": "no-store" } });

/** CoinPay redirects here with ?code&state (or ?error). */
export async function GET(req: Request) {
const params = new URL(req.url).searchParams;
const jar = await cookies();
const pending = decodePending(jar.get(PENDING_COOKIE)?.value);
// Single use: the pending sign-in is spent whatever happens next.
jar.set(PENDING_COOKIE, "", { path: PENDING_COOKIE_PATH, maxAge: 0 });

const fail = (code: string) =>
back(pending?.mode === "connect" ? `/account?coinpay=${code}` : `/login?coinpay=${code}`);

const cfg = coinpayConfig();
if (!cfg) return fail("unavailable");
if (!pending) return fail("expired");
if (!sameState(pending.state, params.get("state"))) return fail("state");
if (params.get("error")) return fail("denied");
const code = params.get("code");
if (!code) return fail("denied");

let claims;
try {
const r = await finishSignIn({
code,
verifier: pending.verifier,
nonce: pending.nonce,
clientId: cfg.clientId,
clientSecret: cfg.clientSecret,
redirectUri: redirectUri(cfg),
base: cfg.base,
});
claims = claimsFrom(r);
} catch (err) {
console.error("coinpay sign-in failed:", err instanceof Error ? err.message : err);
return fail("failed");
}

const me = await currentUser();
const result = await resolveCoinpayUser(
{ mode: pending.mode, currentUserId: me?.id ?? null, claims },
dbCoinpayStore(),
);
if (!result.ok) return fail(result.code);

if (pending.mode === "connect") return back("/account?coinpay=connected");
await startSession(result.userId);
return back(pending.next || (result.action === "created" ? "/account" : "/following"));
}
40 changes: 40 additions & 0 deletions src/app/api/v1/coinpay/oauth/start/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { cookies } from "next/headers";
import { currentUser } from "@/lib/auth/session";
import { safeNext } from "@/lib/auth/next";
import {
PENDING_COOKIE,
PENDING_COOKIE_PATH,
PENDING_TTL,
beginSignIn,
coinpayConfig,
encodePending,
} from "@/lib/auth/coinpay";

export const dynamic = "force-dynamic";

// Relative Locations for our own pages: behind the proxy `req.url` is the
// internal bind address (see api/auth/logout).
const back = (path: string) => new Response(null, { status: 302, headers: { location: path } });

/** GET ?mode=login|connect&next=/path -> 302 to CoinPay's authorize page. */
export async function GET(req: Request) {
const params = new URL(req.url).searchParams;
const mode = params.get("mode") === "connect" ? "connect" : "login";
const next = safeNext(params.get("next"));
const cfg = coinpayConfig();
if (!cfg) return back(mode === "connect" ? "/account?coinpay=unavailable" : "/login?coinpay=unavailable");
// Connecting attaches CoinPay to the account you are in; there must be one.
if (mode === "connect" && !(await currentUser())) return back("/login?next=/account");

const { pending, url } = beginSignIn(cfg, mode, next);
const jar = await cookies();
jar.set(PENDING_COOKIE, encodePending(pending), {
httpOnly: true,
// lax: CoinPay's redirect back is a top-level GET navigation, which lax allows.
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: PENDING_COOKIE_PATH,
maxAge: PENDING_TTL,
});
return new Response(null, { status: 302, headers: { location: url, "cache-control": "no-store" } });
}
3 changes: 2 additions & 1 deletion src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import AuthForm from "@/components/AuthForm";
import { currentUser } from "@/lib/auth/session";
import { safeNext } from "@/lib/auth/next";
import { coinpayMessage } from "@/lib/auth/coinpay";

export const dynamic = "force-dynamic";

Expand All @@ -22,7 +23,7 @@ export default async function Login({
if (await currentUser()) redirect(next || "/following");
return (
<div className="wrap narrow">
<AuthForm mode="login" next={next} expired={params.expired === "1"} />
<AuthForm mode="login" next={next} expired={params.expired === "1"} coinpayError={coinpayMessage(params.coinpay)} />
</div>
);
}
3 changes: 2 additions & 1 deletion src/app/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import AuthForm from "@/components/AuthForm";
import { currentUser } from "@/lib/auth/session";
import { safeNext } from "@/lib/auth/next";
import { coinpayMessage } from "@/lib/auth/coinpay";

export const dynamic = "force-dynamic";

Expand All @@ -24,7 +25,7 @@ export default async function Signup({
if (await currentUser()) redirect(next || "/following");
return (
<div className="wrap narrow">
<AuthForm mode="signup" next={next} expired={false} />
<AuthForm mode="signup" next={next} expired={false} coinpayError={coinpayMessage(params.coinpay)} />
</div>
);
}
20 changes: 18 additions & 2 deletions src/components/AuthForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ export default function AuthForm({
mode,
next,
expired,
coinpayError = null,
}: {
mode: "login" | "signup";
next: string;
expired: boolean;
/** Why a CoinPay sign-in came back refused, already worded for a reader. */
coinpayError?: string | null;
}) {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
Expand All @@ -27,7 +30,7 @@ export default function AuthForm({
const [note, setNote] = useState<string | null>(
expired ? "That link had already been used, or it expired. Here is another." : null,
);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(coinpayError);
const [suggestion, setSuggestion] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const formRef = useRef<HTMLFormElement>(null);
Expand Down Expand Up @@ -161,6 +164,19 @@ export default function AuthForm({

{note && !sent && <p className="auth-note">{note}</p>}

{/* CoinPay is the house master account: first, and a full navigation
(the start route sets the PKCE/state cookie and 302s to CoinPay). */}
<a
className="btn primary wide"
href={`/api/v1/coinpay/oauth/start?mode=login${next ? `&next=${encodeURIComponent(next)}` : ""}`}
>
{mode === "signup" ? "Sign up with CoinPay" : "Sign in with CoinPay"}
</a>

<div className="auth-or">
<span>or use your email</span>
</div>

<form ref={formRef} onSubmit={submit}>
<label htmlFor="email">Email address</label>
<input
Expand Down Expand Up @@ -218,7 +234,7 @@ export default function AuthForm({
</label>
)}

<button className="btn primary wide" type="submit" disabled={busy || !email}>
<button className="btn wide" type="submit" disabled={busy || !email}>
{busy ? "Sending…" : "Email me a link"}
</button>
</form>
Expand Down
Loading
Loading