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
22 changes: 21 additions & 1 deletion .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,24 @@ ADMIN_REBUILD_COOLDOWN_MS=600000

# Bootstrap superadmin email. Must be a verified Google account.
# This value is server-only; do not expose it to the browser.
SUPER_ADMIN_EMAIL=your-superadmin@example.com
SUPER_ADMIN_EMAIL=your-superadmin@example.com

# --- QuickBooks Online integration (server-only — never NEXT_PUBLIC_*) ---
# OAuth + API credentials from the Intuit Developer Portal app. Use the
# app's *Development* keys with QBO_ENVIRONMENT=sandbox (local dev and
# Vercel Preview) and *Production* keys with QBO_ENVIRONMENT=production
# (Vercel Production only). See docs/operations/quickbooks.md.
# QBO_ENVIRONMENT=sandbox
# QBO_CLIENT_ID=
# QBO_CLIENT_SECRET=
# Exact callback URL registered in the Intuit app's redirect-URI list,
# e.g. https://<preview-host>/api/admin/quickbooks/callback (https
# required outside localhost).
# QBO_REDIRECT_URI=
# Verifier token from the Intuit app's webhook configuration — HMAC key
# for POST /api/webhooks/quickbooks.
# QBO_WEBHOOK_VERIFIER_TOKEN=
# AES-256-GCM key for OAuth token material at rest: 64 hex chars or a
# base64-encoded 32-byte value. Generate with:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# QBO_TOKEN_ENCRYPTION_KEY=
26 changes: 26 additions & 0 deletions app/(pages)/admin-quickbooks-fixture/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { AdminQuickbooksFixture } from "@/components/admin-quickbooks-fixture";

// Test-only rendering of the QuickBooks admin surface — same gate as
// /admin-fixture (ADMIN_A11Y_FIXTURE is set only by the Playwright webServer
// config; normal deployments 404). It grants no access to real data: the
// workspace's API calls are intercepted by Playwright route mocks.
export const dynamic = "force-dynamic";

export const metadata: Metadata = {
title: "Admin QuickBooks fixture",
robots: { index: false, follow: false },
};

export default function AdminQuickbooksFixturePage() {
if (process.env.ADMIN_A11Y_FIXTURE !== "1") {
notFound();
}

return (
<main id="main-content" tabIndex={-1} className="mx-auto max-w-300 px-6 pb-20 md:pb-30">
<AdminQuickbooksFixture />
</main>
);
}
19 changes: 19 additions & 0 deletions app/(pages)/admin/integrations/quickbooks/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { AdminQuickbooksPage } from "@/components/admin-quickbooks-page";

export const metadata: Metadata = {
title: "QuickBooks — Admin",
description: "Deep Dive Brewing Co QuickBooks Online integration.",
robots: {
index: false,
follow: false,
},
};

export default function AdminQuickbooksRoute() {
return (
<main id="main-content" tabIndex={-1} className="mx-auto max-w-300 px-6 pb-20 md:pb-30">
<AdminQuickbooksPage />
</main>
);
}
115 changes: 115 additions & 0 deletions app/api/admin/quickbooks/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { type NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-users";
import { getRequestId, logError, logWarn } from "@/lib/log";
import { QboError } from "@/lib/qbo-errors";
import {
completeQboAuthorization,
consumeQboOAuthState,
} from "@/lib/qbo";
import { QBO_OAUTH_COOKIE } from "@/lib/qbo-callback-cookie";

// Intuit OAuth redirect target (issue #161). This endpoint cannot require
// the admin bearer token — the browser arrives here from Intuit — so the
// trust boundary is the one-time OAuth state: it must match the httpOnly
// cookie set at connect time AND a persisted, unexpired, unconsumed record
// created by an authorized admin. A consumed state proves who initiated
// the flow — the initiating admin's record is re-checked below before any
// provider exchange, since they may have been disabled in between.
//
// The route never renders anything and never echoes provider data — it
// redirects back to the admin page with a coarse outcome parameter only.

const ADMIN_PAGE = "/admin/integrations/quickbooks";

function redirectToAdmin(req: NextRequest, params: Record<string, string>) {
const url = new URL(ADMIN_PAGE, req.url);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
const res = NextResponse.redirect(url);
res.cookies.delete(QBO_OAUTH_COOKIE);
return res;
}

export async function GET(req: NextRequest) {
const requestId = getRequestId(req.headers);
const params = req.nextUrl.searchParams;

const providerError = params.get("error");
if (providerError) {
// Admin declined consent or Intuit refused the request.
logWarn("qbo.oauth.failed", { requestId, reason: "provider_denied" });
return redirectToAdmin(req, { qbo: "error", qbo_reason: "denied" });
}

const code = params.get("code");
const state = params.get("state");
const realmId = params.get("realmId");
const cookieState = req.cookies.get(QBO_OAUTH_COOKIE)?.value;

if (!code || !state || !realmId) {
return redirectToAdmin(req, {
qbo: "error",
qbo_reason: "invalid_callback",
});
}

// The state must arrive via the same browser that started the flow —
// a state injected into someone else's callback fails here before any
// Firestore work happens.
if (!cookieState || cookieState !== state) {
logWarn("qbo.oauth.failed", { requestId, reason: "state_cookie_mismatch" });
return redirectToAdmin(req, {
qbo: "error",
qbo_reason: "state_invalid",
});
}

try {
const consumed = await consumeQboOAuthState(state);
if (consumed.verdict !== "ok") {
logWarn("qbo.oauth.failed", { requestId, reason: consumed.verdict });
return redirectToAdmin(req, {
qbo: "error",
qbo_reason:
consumed.verdict === "consumed"
? "state_replayed"
: consumed.verdict === "expired"
? "state_expired"
: "state_invalid",
});
}

// The consumed state identifies who started the flow; it does not
// prove they are still allowed to act. Re-verify the adminUsers
// record — an admin disabled or removed between connect and callback
// must not complete the connection. Both admin roles may connect, so
// any still-active record passes regardless of role.
const initiator = await getAdminUser(consumed.uid);
if (initiator?.status !== "active") {
logWarn("qbo.oauth.failed", {
requestId,
reason: "initiating_admin_inactive",
});
return redirectToAdmin(req, {
qbo: "error",
qbo_reason: "connect_failed",
});
}

await completeQboAuthorization({
code,
realmId,
uid: consumed.uid,
email: consumed.email,
});
return redirectToAdmin(req, { qbo: "connected" });
} catch (error) {
const reason =
error instanceof QboError && error.kind === "configuration"
? "not_configured"
: "connect_failed";
logError("qbo.oauth.failed", error, { requestId });
return redirectToAdmin(req, { qbo: "error", qbo_reason: reason });
}
}
44 changes: 44 additions & 0 deletions app/api/admin/quickbooks/connect/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { type NextRequest, NextResponse } from "next/server";
import { requireAdminActor } from "@/lib/admin-auth";
import { getBearerToken, unauthorizedResponse } from "@/lib/api-auth";
import { apiErrorResponse } from "@/lib/api-error";
import { getRequestId } from "@/lib/log";
import { createQboAuthorizationRequest } from "@/lib/qbo";
import { QBO_OAUTH_COOKIE } from "@/lib/qbo-callback-cookie";
import { QBO_OAUTH_STATE_TTL_MS } from "@/lib/qbo-protocol";

// Starts the QuickBooks OAuth flow (issue #161): the admin receives the
// Intuit authorization URL and a one-time state cookie. The cookie
// cross-checks the callback's `state` parameter against the persisted
// record so the flow is bound to this admin's browser.

export async function POST(req: NextRequest) {
const requestId = getRequestId(req.headers);
const idToken = getBearerToken(req);
if (!idToken) return unauthorizedResponse();

try {
const actor = await requireAdminActor(idToken);
const { authorizationUrl, state } =
await createQboAuthorizationRequest(actor);
const res = NextResponse.json({ ok: true, authorizationUrl });
// Lax so it rides along on Intuit's top-level redirect back to us;
// httpOnly so only the server sees it; Secure whenever this request
// itself arrived over https (every deployed environment — a plain
// http localhost dev flow keeps the cookie too).
res.cookies.set(QBO_OAUTH_COOKIE, state, {
httpOnly: true,
secure: req.nextUrl.protocol === "https:",
sameSite: "lax",
maxAge: Math.ceil(QBO_OAUTH_STATE_TTL_MS / 1000),
path: "/",
});
return res;
} catch (error) {
return apiErrorResponse(error, {
fallback: "Could not start the QuickBooks connection.",
event: "qbo.connect_failed",
context: { requestId },
});
}
}
46 changes: 46 additions & 0 deletions app/api/admin/quickbooks/disconnect/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { type NextRequest, NextResponse } from "next/server";
import { requireAdminActor } from "@/lib/admin-auth";
import {
badRequestResponse,
getBearerToken,
unauthorizedResponse,
} from "@/lib/api-auth";
import { apiErrorResponse } from "@/lib/api-error";
import { getRequestId } from "@/lib/log";
import { disconnectQbo, getQboAdminView } from "@/lib/qbo";

// Deliberate disconnect (issue #161): requires an explicit confirmation
// body, revokes the grant at Intuit, and destroys stored credentials while
// keeping the audit/history record.
export async function POST(req: NextRequest) {
const requestId = getRequestId(req.headers);
const idToken = getBearerToken(req);
if (!idToken) return unauthorizedResponse();

let body: unknown;
try {
body = await req.json();
} catch {
return badRequestResponse("Invalid JSON body.");
}
const confirm =
typeof body === "object" && body !== null
? (body as Record<string, unknown>).confirm
: undefined;
if (confirm !== true) {
return badRequestResponse("Disconnect requires explicit confirmation.");
}

try {
const actor = await requireAdminActor(idToken);
await disconnectQbo(actor);
const connection = await getQboAdminView();
return NextResponse.json({ ok: true, connection });
} catch (error) {
return apiErrorResponse(error, {
fallback: "Could not disconnect QuickBooks.",
event: "qbo.disconnect_failed",
context: { requestId },
});
}
}
45 changes: 45 additions & 0 deletions app/api/admin/quickbooks/entities/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { type NextRequest, NextResponse } from "next/server";
import { requireAdminActor } from "@/lib/admin-auth";
import {
badRequestResponse,
getBearerToken,
unauthorizedResponse,
} from "@/lib/api-auth";
import { apiErrorResponse } from "@/lib/api-error";
import { getRequestId } from "@/lib/log";
import { queryQboEntities } from "@/lib/qbo-api";
import { getQboEnvironment } from "@/lib/qbo-config";
import { getQuickBooksAccessToken } from "@/lib/qbo-tokens";
import { isQboDiscoveryEntityType } from "@/lib/qbo-common";

// Read-only entity discovery for the accounting-mapping surface
// (issue #161). Returns canonical safe summaries — ids, names, types —
// never raw QBO records.
export async function GET(req: NextRequest) {
const requestId = getRequestId(req.headers);
const idToken = getBearerToken(req);
if (!idToken) return unauthorizedResponse();

const type = req.nextUrl.searchParams.get("type");
if (!isQboDiscoveryEntityType(type)) {
return badRequestResponse("Unknown entity type.");
}

try {
await requireAdminActor(idToken);
const { accessToken, realmId } = await getQuickBooksAccessToken();
const entities = await queryQboEntities({
environment: getQboEnvironment(),
realmId,
accessToken,
type,
});
return NextResponse.json({ ok: true, entities });
} catch (error) {
return apiErrorResponse(error, {
fallback: "Could not load QuickBooks entities.",
event: "qbo.entities_failed",
context: { requestId },
});
}
}
56 changes: 56 additions & 0 deletions app/api/admin/quickbooks/mapping/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { type NextRequest, NextResponse } from "next/server";
import { requireAdminActor } from "@/lib/admin-auth";
import {
badRequestResponse,
getBearerToken,
unauthorizedResponse,
} from "@/lib/api-auth";
import { apiErrorResponse } from "@/lib/api-error";
import { getRequestId } from "@/lib/log";
import { getQboMappingView, saveQboMapping } from "@/lib/qbo-mapping";

// Accounting-mapping configuration (issue #161): which connected-company
// QBO entities future Stripe/tour revenue should post against. Save
// validates every id against a live entity query.
export async function GET(req: NextRequest) {
const requestId = getRequestId(req.headers);
const idToken = getBearerToken(req);
if (!idToken) return unauthorizedResponse();

try {
await requireAdminActor(idToken);
const mapping = await getQboMappingView();
return NextResponse.json({ ok: true, mapping });
} catch (error) {
return apiErrorResponse(error, {
fallback: "Could not load the QuickBooks mapping.",
event: "qbo.mapping_get_failed",
context: { requestId },
});
}
}

export async function PUT(req: NextRequest) {
const requestId = getRequestId(req.headers);
const idToken = getBearerToken(req);
if (!idToken) return unauthorizedResponse();

let body: unknown;
try {
body = await req.json();
} catch {
return badRequestResponse("Invalid JSON body.");
}

try {
const actor = await requireAdminActor(idToken);
const mapping = await saveQboMapping(actor, body);
return NextResponse.json({ ok: true, mapping });
} catch (error) {
return apiErrorResponse(error, {
fallback: "Could not save the QuickBooks mapping.",
event: "qbo.mapping_save_failed",
context: { requestId },
});
}
}
Loading
Loading