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
24 changes: 20 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions sites/sh1pt.com/app/api/v1/coinpay/disconnect/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextResponse, type NextRequest } from 'next/server';
import { getSupabaseServerClient } from '@/lib/supabase/server';
import { getSupabaseServiceClient } from '@/lib/supabase/service';
import { coinpayConfig, disconnectCoinpay, supabaseCoinpayStore } from '@/lib/coinpay-signin';

export const dynamic = 'force-dynamic';

// POST /api/v1/coinpay/disconnect: the dashboard's form. Refused when CoinPay
// is the only way into the account (its auth email is a CoinPay placeholder).
export async function POST(request: NextRequest) {
const site = coinpayConfig()?.siteUrl ?? 'https://sh1pt.com';
if (!sameSite(request.headers.get('origin'), site)) return new NextResponse('Bad origin', { status: 403 });
const supabase = await getSupabaseServerClient();
const { data } = await supabase.auth.getUser();
if (!data?.user) return NextResponse.redirect(new URL('/login?next=/dashboard', site), 303);

const result = await disconnectCoinpay(
data.user.id,
data.user.email ?? null,
supabaseCoinpayStore(getSupabaseServiceClient()),
);
return NextResponse.redirect(
new URL(`/dashboard?coinpay=${result.ok ? 'disconnected' : 'only_sign_in'}#coinpay`, site),
303,
);
}

function sameSite(origin: string | null, site: string): boolean {
if (!origin) return true;
try {
return new URL(origin).host === new URL(site).host;
} catch {
return false;
}
}
91 changes: 91 additions & 0 deletions sites/sh1pt.com/app/api/v1/coinpay/oauth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { NextResponse, type NextRequest } from 'next/server';
import { finishSignIn } from '@profullstack/coinpay-onboard/oauth';
import { getSupabaseServerClient } from '@/lib/supabase/server';
import { getSupabaseServiceClient } from '@/lib/supabase/service';
import { onSignedIn } from '@/lib/credits';
import {
STATE_COOKIE,
STATE_COOKIE_PATH,
coinpayConfig,
mintMagicLinkHash,
resolveCoinpayUser,
supabaseCoinpayStore,
takeCallback,
} from '@/lib/coinpay-signin';

export const dynamic = 'force-dynamic';

// GET /api/v1/coinpay/oauth/callback?code=…&state=…
//
// Refused before the code is exchanged when the state is not this browser's,
// was already used, or (Connect) the signed-in user is not the one who asked.
// The exchange sends the PKCE verifier and the client secret; the id_token's
// iss/aud/exp/nonce are checked and userinfo's sub must match it. The account
// is chosen by sub only (lib/coinpay-signin.ts), then a normal Supabase
// session is minted server side.
export async function GET(request: NextRequest) {
const cfg = coinpayConfig();
const site = cfg?.siteUrl ?? 'https://sh1pt.com';
const to = (path: string) => {
const res = NextResponse.redirect(new URL(path, site));
res.cookies.set(STATE_COOKIE, '', { path: STATE_COOKIE_PATH, maxAge: 0 });
res.headers.set('cache-control', 'no-store');
return res;
};
if (!cfg) return to('/login?error=coinpay_off');

const url = new URL(request.url);
const supabase = await getSupabaseServerClient();
const { data: who } = await supabase.auth.getUser();
const currentUserId = who?.user?.id ?? null;

const admin = getSupabaseServiceClient();
const store = supabaseCoinpayStore(admin);
const checked = await takeCallback(store, {
state: url.searchParams.get('state'),
cookieState: request.cookies.get(STATE_COOKIE)?.value,
currentUserId,
});
if (!checked.ok) return to(`/login?error=coinpay_${checked.code}`);
const { pending } = checked;
const fail = (code: string) =>
to(pending.mode === 'connect' ? `/dashboard?coinpay=${code}#coinpay` : `/login?error=coinpay_${code}`);

const code = url.searchParams.get('code');
if (url.searchParams.get('error') || !code) return fail('cancelled');

let claims;
try {
const result = await finishSignIn({
code,
verifier: pending.verifier,
nonce: pending.nonce,
clientId: cfg.clientId,
clientSecret: cfg.clientSecret,
redirectUri: cfg.redirectUri,
});
claims = { sub: result.sub, email: result.email, name: result.name };
} catch (err) {
console.error('[coinpay] sign-in failed', err instanceof Error ? err.message : err);
return fail('failed');
}

const resolved = await resolveCoinpayUser({ mode: pending.mode, currentUserId, claims }, store);
if (!resolved.ok) return fail(resolved.code);
if (pending.mode === 'connect') return to('/dashboard?coinpay=connected#coinpay');

const minted = await mintMagicLinkHash(admin, resolved.userId);
if (!minted.ok) return fail('session');
const { error } = await supabase.auth.verifyOtp({ token_hash: minted.tokenHash, type: 'magiclink' });
if (error) {
console.error('[coinpay] verifyOtp', error.message);
return fail('session');
}

// Invite bonuses settle for accounts with a real address only: an account a
// CoinPay login just made has no proven email, so it neither accepts an
// invite nor earns one (see lib/credits.ts).
if (resolved.action === 'login') await onSignedIn(resolved.userId, null);

return to(resolved.action === 'created' ? '/dashboard?coinpay=welcome#coinpay' : pending.next);
}
63 changes: 63 additions & 0 deletions sites/sh1pt.com/app/api/v1/coinpay/oauth/start/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { NextResponse, type NextRequest } from 'next/server';
import { authorizeUrl, pkce, randomToken, SIGN_IN_SCOPES } from '@profullstack/coinpay-onboard/oauth';
import { getSupabaseServerClient } from '@/lib/supabase/server';
import { getSupabaseServiceClient } from '@/lib/supabase/service';
import { safeNextPath } from '@/lib/safe-next';
import {
PENDING_SECONDS,
STATE_COOKIE,
STATE_COOKIE_PATH,
coinpayConfig,
savePending,
supabaseCoinpayStore,
type OAuthMode,
} from '@/lib/coinpay-signin';

export const dynamic = 'force-dynamic';

// GET /api/v1/coinpay/oauth/start?next=/dashboard Sign in with CoinPay
// GET /api/v1/coinpay/oauth/start?mode=connect Connect CoinPay (signed in)
//
// PKCE S256 + state + nonce. The verifier and nonce stay server side in
// coinpay_pending (keyed by sha256(state)); state is pinned to this browser by
// an httpOnly cookie scoped to the OAuth paths.
export async function GET(request: NextRequest) {
const cfg = coinpayConfig();
if (!cfg) return NextResponse.redirect(new URL('/login?error=coinpay_off', cfgOrigin(request)));

const url = new URL(request.url);
const mode: OAuthMode = url.searchParams.get('mode') === 'connect' ? 'connect' : 'login';

let linkUserId: string | null = null;
if (mode === 'connect') {
// Connecting happens only from inside the account it joins.
const supabase = await getSupabaseServerClient();
const { data } = await supabase.auth.getUser();
if (!data?.user) return NextResponse.redirect(new URL('/login?next=/dashboard', cfg.siteUrl));
linkUserId = data.user.id;
}

const state = randomToken();
const nonce = randomToken();
const { verifier, challenge } = pkce();
const next = safeNextPath(url.searchParams.get('next'), '/dashboard');
await savePending(supabaseCoinpayStore(getSupabaseServiceClient()), state, { verifier, nonce, mode, next, linkUserId });

const res = NextResponse.redirect(
authorizeUrl({ clientId: cfg.clientId, redirectUri: cfg.redirectUri, state, challenge, nonce, scopes: SIGN_IN_SCOPES }),
);
res.headers.set('cache-control', 'no-store');
res.cookies.set(STATE_COOKIE, state, {
httpOnly: true,
secure: cfg.siteUrl.startsWith('https:'),
sameSite: 'lax',
path: STATE_COOKIE_PATH,
maxAge: PENDING_SECONDS,
});
return res;
}

function cfgOrigin(request: NextRequest): string {
const site = (process.env as Record<string, string | undefined>)['NEXT_PUBLIC_SITE_URL'];
return site || new URL(request.url).origin;
}
35 changes: 35 additions & 0 deletions sites/sh1pt.com/app/components/CoinpaySignIn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { coinpayConfig } from '@/lib/coinpay-signin';

// "Sign in with CoinPay": CoinPay is the fleet's master account, so it is
// offered first. Rendered only when the deployment has a CoinPay client.
export function CoinpaySignIn({ label = 'Sign in with CoinPay', next = '/dashboard' }: { label?: string; next?: string }) {
if (!coinpayConfig()) return null;
return (
<div style={{ marginTop: '1.5rem' }}>
<a
className="btn"
href={`/api/v1/coinpay/oauth/start?next=${encodeURIComponent(next)}`}
style={{ display: 'block', textAlign: 'center', width: '100%' }}
>
{label}
</a>
<p className="muted" style={{ fontSize: '0.85rem', marginTop: '0.5rem', textAlign: 'center' }}>
or use your email below
</p>
</div>
);
}

const COINPAY_ERRORS: Record<string, string> = {
coinpay_bad_state: "That CoinPay sign-in didn't start in this browser. Try again.",
coinpay_expired: 'That CoinPay sign-in expired or was already used. Try again.',
coinpay_wrong_session: 'Sign in to the account you want to connect, then try again.',
coinpay_cancelled: 'CoinPay sign-in was cancelled.',
coinpay_failed: "CoinPay sign-in couldn't be completed. Try again.",
coinpay_session: "Couldn't start your session after CoinPay. Try again.",
coinpay_off: 'Sign in with CoinPay is not available right now.',
};

export function coinpayLoginError(code: string | undefined): string | null {
return code ? (COINPAY_ERRORS[code] ?? null) : null;
}
55 changes: 53 additions & 2 deletions sites/sh1pt.com/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getSupabaseServerClient } from '@/lib/supabase/server';
import { getSupabaseServiceClient } from '@/lib/supabase/service';
import { planFor } from '@/lib/credits';
import { RedeemCodeForm } from './RedeemCodeForm';
import { coinpayConfig, isPlaceholderEmail, supabaseCoinpayStore } from '@/lib/coinpay-signin';

export const metadata = {
title: 'sh1pt — dashboard',
Expand All @@ -26,8 +27,22 @@ const INVITE_NOTICES: Record<string, string> = {
self: 'You cannot use your own invite.',
};

const COINPAY_NOTICES: Record<string, string> = {
welcome: 'Welcome. You signed in with CoinPay, which is how you get back in.',
connected: 'CoinPay connected. You can sign in with it from now on.',
disconnected: 'CoinPay disconnected.',
only_sign_in: 'CoinPay is the only way into this account, so it stays connected.',
linked_elsewhere: 'That CoinPay account is already connected to another sh1pt account.',
already_connected: 'This account is already connected to a different CoinPay account. Disconnect it first.',
not_signed_in: 'Sign in first, then connect CoinPay.',
cancelled: 'CoinPay was cancelled.',
failed: "CoinPay couldn't be reached. Try again.",
};

export default async function Dashboard({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
const inviteNotice = INVITE_NOTICES[(await searchParams).invite ?? ''] ?? null;
const params = await searchParams;
const inviteNotice = INVITE_NOTICES[params.invite ?? ''] ?? null;
const coinpayNotice = COINPAY_NOTICES[params.coinpay ?? ''] ?? null;
const supabase = await getSupabaseServerClient();
let user = null;
try {
Expand Down Expand Up @@ -66,6 +81,12 @@ export default async function Dashboard({ searchParams }: { searchParams: Promis
.in('installation_pk', installationIds)
: { count: 0 };
const installationCount = installationIds.length;
// Sign in with CoinPay: connected or not, and whether it may be dropped.
const coinpayIdentity = await supabaseCoinpayStore(adminClient)
.identityByUser(user.id)
.catch(() => null);
const coinpayOnly = isPlaceholderEmail(user.email);
const coinpayAvailable = coinpayConfig() !== null;
const plan = await planFor(user.id).catch((err) => {
console.error('[dashboard] plan', err);
return null;
Expand All @@ -87,7 +108,7 @@ export default async function Dashboard({ searchParams }: { searchParams: Promis
<div>
<h1 style={{ marginBottom: 0 }}>Your dashboard</h1>
<div className="muted" style={{ fontSize: '0.9rem' }}>
Signed in as <strong style={{ color: 'var(--fg)' }}>{profile.email}</strong>
Signed in as <strong style={{ color: 'var(--fg)' }}>{coinpayOnly ? 'your CoinPay account' : profile.email}</strong>
{profile.handle ? ` · ${profile.handle}` : null}
</div>
</div>
Expand All @@ -96,6 +117,36 @@ export default async function Dashboard({ searchParams }: { searchParams: Promis
</form>
</div>

<div className="card" id="coinpay" style={{ marginTop: '1.5rem' }}>
<div className="muted" style={{ fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
CoinPay
</div>
{coinpayNotice ? <p role="status" style={{ marginTop: '0.5rem' }}>{coinpayNotice}</p> : null}
{coinpayIdentity ? (
<>
<p style={{ marginTop: '0.5rem' }}>
Connected{coinpayIdentity.coinpay_email ? ` (${coinpayIdentity.coinpay_email})` : ''}. You can sign in with CoinPay.
</p>
{coinpayOnly ? (
<p className="muted" style={{ fontSize: '0.85rem' }}>
CoinPay is the only way into this account, so it can't be disconnected.
</p>
) : (
<form action="/api/v1/coinpay/disconnect" method="post">
<button className="btn secondary" type="submit" style={{ fontSize: '0.85rem' }}>Disconnect CoinPay</button>
</form>
)}
</>
) : coinpayAvailable ? (
<>
<p style={{ marginTop: '0.5rem' }}>Connect your CoinPay account to sign in with it.</p>
<a className="btn" href="/api/v1/coinpay/oauth/start?mode=connect">Connect CoinPay</a>
</>
) : (
<p className="muted" style={{ marginTop: '0.5rem' }}>Sign in with CoinPay is not available right now.</p>
)}
</div>

{inviteNotice ? (
<div className="card" role="status" style={{ marginTop: '1.5rem' }}>
{inviteNotice}
Expand Down
Loading
Loading