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
90 changes: 82 additions & 8 deletions src/app/api/coinpay/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
supabaseCoinpayStore,
takeCallback,
} from "@/lib/coinpay/signin";
import { generateApiKey, hashApiKey } from "@/lib/api-auth";
import { GROUPCALL_API_KEY_NAME, GROUPCALL_BASE_URL } from "@/lib/groupcall";

export const dynamic = "force-dynamic";

Expand All @@ -23,7 +25,8 @@ export const dynamic = "force-dynamic";
* 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); a sign-in then gets a normal
* Supabase session minted server side. OAuth tokens are never stored.
* Supabase session minted server side. OAuth tokens are never stored; group.call
* modes forward the access token once over HTTPS server-to-server.
*/
export async function GET(request: NextRequest) {
const site = getSiteUrl();
Expand Down Expand Up @@ -51,31 +54,102 @@ export async function GET(request: NextRequest) {
});
if (!checked.ok) return to(`/login?error=coinpay_${checked.code}`);
const { pending } = checked;
const fail = (reason: string) =>
to(pending.mode === "connect" ? `/settings?coinpay=error&reason=${reason}` : `/login?error=coinpay_${reason}`);
const groupcallAction = pending.mode === "connect" && pending.next === "/settings?groupcall=connect"
? "connect"
: pending.mode === "connect" && pending.next === "/settings?groupcall=disconnect"
? "disconnect"
: null;
const fail = (reason: string) => {
if (groupcallAction) {
return to(`/settings?groupcall=error&reason=${reason}`);
}
return to(pending.mode === "connect" ? `/settings?coinpay=error&reason=${reason}` : `/login?error=coinpay_${reason}`);
};

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

let claims;
let result: Awaited<ReturnType<typeof finishSignIn>>;
try {
const result = await finishSignIn({
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("token_exchange");
}

const resolved = await resolveCoinpayUser({ mode: pending.mode, currentUserId: current?.id ?? null, claims }, store);
const claims = { sub: result.sub, email: result.email, name: result.name };
const resolved = await resolveCoinpayUser({
mode: pending.mode === "login" ? "login" : "connect",
currentUserId: current?.id ?? null,
claims,
}, store);
if (!resolved.ok) return fail(resolved.code);
if (pending.mode === "connect") return to("/settings?coinpay=connected");
if (pending.mode === "connect" && !groupcallAction) return to("/settings?coinpay=connected");

if (groupcallAction) {
const groupcallHeaders: HeadersInit = {
authorization: `Bearer ${result.tokens.accessToken}`,
...(groupcallAction === "connect" ? { "content-type": "application/json" } : {}),
};

if (groupcallAction === "disconnect") {
let response: Response;
try {
response = await fetch(`${GROUPCALL_BASE_URL}/api/integrations/smshub`, {
method: "DELETE",
headers: groupcallHeaders,
signal: AbortSignal.timeout(15_000),
});
} catch {
return fail("groupcall_unavailable");
}
if (!response.ok) return fail("groupcall_unlink");
const { error } = await admin.from("api_keys").delete().eq("user_id", resolved.userId).eq("name", GROUPCALL_API_KEY_NAME);
if (error) return fail("smshub_cleanup");
return to("/settings?groupcall=disconnected");
}

const rawKey = generateApiKey();
const { data: created, error: createError } = await admin
.from("api_keys")
.insert({ user_id: resolved.userId, key_hash: hashApiKey(rawKey), name: GROUPCALL_API_KEY_NAME })
.select("id")
.single();
if (createError || !created) return fail("key_create");

let response: Response;
try {
response = await fetch(`${GROUPCALL_BASE_URL}/api/integrations/smshub`, {
method: "POST",
headers: groupcallHeaders,
body: JSON.stringify({ key: rawKey }),
signal: AbortSignal.timeout(15_000),
});
} catch {
await admin.from("api_keys").delete().eq("id", created.id).eq("user_id", resolved.userId);
return fail("groupcall_unavailable");
}
if (!response.ok) {
await admin.from("api_keys").delete().eq("id", created.id).eq("user_id", resolved.userId);
return fail("groupcall_link");
}

const { error: cleanupError } = await admin
.from("api_keys")
.delete()
.eq("user_id", resolved.userId)
.eq("name", GROUPCALL_API_KEY_NAME)
.neq("id", created.id);
if (cleanupError) console.error("[group.call] old integration key cleanup failed");
return to("/settings?groupcall=connected");
}

const minted = await mintMagicLinkHash(admin, resolved.userId);
if (!minted.ok) return fail("session");
Expand Down
15 changes: 15 additions & 0 deletions src/app/api/coinpay/groupcall/connect/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getSiteUrl } from "@/lib/site-url";
import { startCoinpay } from "@/lib/coinpay/start";

export const dynamic = "force-dynamic";

/** Start CoinPay authorization for linking the signed-in SMSHub account to group.call. */
export async function POST(request: NextRequest) {
const origin = request.headers.get("origin");
if (!origin || origin !== new URL(getSiteUrl()).origin) {
return NextResponse.json({ error: "Invalid request origin" }, { status: 403 });
}
return startCoinpay(request, "connect", "/settings?groupcall=connect", true);
}
15 changes: 15 additions & 0 deletions src/app/api/coinpay/groupcall/disconnect/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getSiteUrl } from "@/lib/site-url";
import { startCoinpay } from "@/lib/coinpay/start";

export const dynamic = "force-dynamic";

/** Reauthorize with CoinPay before removing the group.call connection. */
export async function POST(request: NextRequest) {
const origin = request.headers.get("origin");
if (!origin || origin !== new URL(getSiteUrl()).origin) {
return NextResponse.json({ error: "Invalid request origin" }, { status: 403 });
}
return startCoinpay(request, "connect", "/settings?groupcall=disconnect", true);
}
30 changes: 30 additions & 0 deletions src/app/api/integrations/groupcall/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { GROUPCALL_API_KEY_NAME } from "@/lib/groupcall";

export const dynamic = "force-dynamic";

/** Connection status only; the integration credential is never returned. */
export async function GET() {
const supabase = await createServerSupabaseClient();
const {
data: { user },
} = await supabase.auth.getUser();

if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

const { data, error } = await supabase
.from("api_keys")
.select("id, created_at")
.eq("user_id", user.id)
.eq("name", GROUPCALL_API_KEY_NAME)
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle();

if (error) return NextResponse.json({ error: "Internal server error" }, { status: 500 });

return NextResponse.json({ connected: Boolean(data), connected_at: data?.created_at ?? null }, {
headers: { "cache-control": "no-store" },
});
}
22 changes: 22 additions & 0 deletions src/app/api/v1/integrations/groupcall/identity/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { resolveUser } from "@/lib/request-user";
import { createServiceClient } from "@/lib/supabase/server";

/** Return only the CoinPay subject bound to the API key owner for account-link verification. */
export async function GET(request: Request) {
const who = await resolveUser(request, { allowSession: false });
if ("error" in who) return NextResponse.json({ error: who.error }, { status: who.status });

const { data, error } = await createServiceClient()
.from("coinpay_connections")
.select("coinpay_user_id")
.eq("user_id", who.user.userId)
.maybeSingle();

if (error) return NextResponse.json({ error: "Internal server error" }, { status: 500 });
if (!data?.coinpay_user_id) {
return NextResponse.json({ error: "Connect CoinPay in SMSHub Settings first" }, { status: 409 });
}

return NextResponse.json({ coinpay_sub: data.coinpay_user_id }, { headers: { "cache-control": "no-store" } });
}
2 changes: 2 additions & 0 deletions src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useToast } from "@/contexts/toast-context";
import { ProfileCard } from "@/components/profile-card";
import { RedeemCard } from "@/components/redeem-card";
import { CoinpayCard } from "@/components/coinpay-card";
import { GroupCallIntegrationCard } from "@/components/groupcall-integration-card";
import { KeyVaultCard } from "@/components/key-vault-card";
import {
ProviderTestPanel,
Expand Down Expand Up @@ -215,6 +216,7 @@ export default function SettingsPage() {
<ProfileCard />

<CoinpayCard />
<GroupCallIntegrationCard />

<RedeemCard />

Expand Down
74 changes: 74 additions & 0 deletions src/components/groupcall-integration-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"use client";

import { useEffect, useState } from "react";

type Status = { connected: boolean; connected_at: string | null };

const connectionErrors: Record<string, string> = {
groupcall_unavailable: "group.call did not answer. Try connecting again in a minute.",
groupcall_link: "group.call could not link this SMSHub account. Try again.",
groupcall_unlink: "group.call could not remove the connection. Try again.",
key_create: "SMSHub could not create the private connection. Try again.",
smshub_cleanup: "group.call disconnected, but SMSHub could not finish cleanup. Reload this page.",
token_exchange: "CoinPay sign-in did not finish. Try again.",
linked_elsewhere: "That CoinPay account is linked to a different SMSHub account.",
already_connected: "This SMSHub account is linked to a different CoinPay account.",
wrong_session: "Your SMSHub session changed during sign-in. Start again from Settings.",
};

export function GroupCallIntegrationCard() {
const [status, setStatus] = useState<Status | null>(null);
const [error, setError] = useState("");

const load = async () => {
try {
const response = await fetch("/api/integrations/groupcall", { cache: "no-store" });
if (response.ok) setStatus(await response.json());
} catch {
setError("Could not load group.call connection status.");
}
};

useEffect(() => {
void load();
const params = new URLSearchParams(window.location.search);
if (params.get("groupcall") === "error") {
setError(connectionErrors[params.get("reason") || ""] || "Could not connect group.call. Try again.");
}
}, []);

return (
<section className="bg-gray-900 rounded-lg p-4 border border-gray-800 space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="font-semibold">group.call</h2>
<p className="text-sm text-gray-400">
Send group.call room texts through your SMSHub number. CoinPay links the same account on both services.
</p>
<p className="text-xs text-gray-500 mt-1">
SMSHub creates and sends the integration credential privately; it is never shown or copied to your browser.
</p>
</div>
<div className="flex gap-3 shrink-0">
<form action="/api/coinpay/groupcall/connect" method="POST">
<button
type="submit"
className="rounded-lg px-4 py-2 bg-blue-600 hover:bg-blue-700 text-sm font-medium text-white"
>
{status?.connected ? "Reconnect with CoinPay" : "Connect with CoinPay"}
</button>
</form>
{status?.connected && (
<form action="/api/coinpay/groupcall/disconnect" method="POST">
<button type="submit" className="rounded-lg px-4 py-2 bg-gray-800 hover:bg-gray-700 text-sm font-medium text-gray-200">
Disconnect
</button>
</form>
)}
</div>
</div>
{status?.connected && <p className="text-sm text-green-400">Connected to group.call.</p>}
{error && <p role="alert" className="text-sm text-red-400">{error}</p>}
</section>
);
}
20 changes: 12 additions & 8 deletions src/lib/coinpay/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,36 +23,39 @@ const CONNECT_SCOPES = [...SIGN_IN_SCOPES, "wallet:read"];
* host first hops to smshub.dev: the state cookie has to live where the
* callback lands.
*/
export async function startCoinpay(request: NextRequest, mode: OAuthMode) {
export async function startCoinpay(request: NextRequest, mode: OAuthMode, fallbackNext?: string, minimalScopes = false) {
const site = getSiteUrl();
const here = new URL(request.url);
const forwardedHost = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
if (forwardedHost && forwardedHost !== new URL(site).host) {
return NextResponse.redirect(new URL(here.pathname + here.search, site));
const canonical = new URL(here.pathname + here.search, site);
if (fallbackNext && !canonical.searchParams.has("next")) canonical.searchParams.set("next", fallbackNext);
return NextResponse.redirect(canonical, { status: 303 });
}

const cfg = coinpaySignInConfig(site);
if (!cfg) {
return NextResponse.redirect(
new URL(mode === "connect" ? "/settings?coinpay=error&reason=not_configured" : "/login?error=coinpay_off", site)
new URL(mode === "login" ? "/login?error=coinpay_off" : "/settings?coinpay=error&reason=not_configured", site),
{ status: 303 }
);
}

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

const state = randomToken();
const nonce = randomToken();
const { verifier, challenge } = pkce();
const next = safeNextPath(here.searchParams.get("next"), mode === "connect" ? "/settings" : "/inbox");
const next = safeNextPath(here.searchParams.get("next"), fallbackNext ?? (mode === "login" ? "/inbox" : "/settings"));
await savePending(supabaseCoinpayStore(createServiceClient()), state, { verifier, nonce, mode, next, linkUserId });

const res = NextResponse.redirect(
Expand All @@ -62,8 +65,9 @@ export async function startCoinpay(request: NextRequest, mode: OAuthMode) {
state,
challenge,
nonce,
scopes: mode === "connect" ? CONNECT_SCOPES : SIGN_IN_SCOPES,
})
scopes: mode === "login" || minimalScopes ? SIGN_IN_SCOPES : CONNECT_SCOPES,
}),
{ status: 303 }
);
res.headers.set("cache-control", "no-store");
res.cookies.set(STATE_COOKIE, state, {
Expand Down
4 changes: 4 additions & 0 deletions src/lib/groupcall.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const GROUPCALL_API_KEY_NAME = "group.call integration";

export const GROUPCALL_BASE_URL =
((process.env as Record<string, string | undefined>).GROUPCALL_URL || "https://group.call.pit.world").replace(/\/+$/, "");
Loading