feat: MCP server, API keys, OAuth, and KPI dashboard (#72)
* fix: prevent Chrome auto-translate from crashing React during onboarding
Chrome auto-translate modifies DOM text nodes when it detects a Swedish
page (lang="sv") in a browser set to English. React does not expect
external DOM mutations and throws, crashing the entire component tree
into global-error.tsx on every step transition.
Add translate="no" and <meta name="google" content="notranslate"> to
suppress browser translation. Also fix timezone-unsafe date parsing in
fiscal period validation (new Date("YYYY-MM-DD") + getDate() returns
local-timezone values, shifting dates by -1 day in Western timezones).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add notranslate meta tag to global-error.tsx for consistency
Per review feedback — global-error.tsx renders its own <html> document,
so it needs the same <meta name="google" content="notranslate"> tag as
layout.tsx to fully suppress Chrome translation on error pages.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add MCP server extension with OAuth, API keys, and KPI dashboard
Let users do bookkeeping through Claude Desktop, Claude Code, or any
MCP-compatible client. "Show my uncategorized transactions." "Book that
as office supplies." "Invoice Acme for 15,000 kr."
MCP server (extension):
- 10 tools: transactions, categorization, customers, invoices,
trial balance, VAT report, KPI report, income statement
- JSON-RPC 2.0 protocol (no SDK dependency, works in serverless)
- Tool annotations, pagination, input validation per MCP best practices
- Same engine as web UI (VAT rules, exchange rates, event emission)
API key infrastructure (core):
- api_keys table with RLS, rate limiting (100 RPM), scopes column
- Atomic rate limit via DB RPC (validate_and_increment_api_key)
- Key management API routes + settings UI panel
OAuth 2.1 for Claude Desktop connectors:
- .well-known/oauth-protected-resource + oauth-authorization-server
- Authorization endpoint with consent page
- Token endpoint with PKCE verification
- Stateless encrypted auth codes (AES-256-GCM, no DB storage)
- Dynamic client registration
KPI dashboard:
- /nyckeltal page with hero cards, operational grid, trend chart
- GET /api/reports/kpi endpoint
- Gross margin, cash position, expense ratio, avg payment days,
VAT liability, revenue/expense trend
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address OAuth security vulnerabilities from code review
Critical fixes:
- Auth code replay: Track used codes in oauth_used_codes table with
unique constraint. Codes are single-use per OAuth 2.1 §4.1.2.
- Open redirect: Validate redirect_uri against hardcoded allowlist
of known Claude callback URLs + localhost for dev.
P1 fixes:
- Move API key creation from /authorize to /token endpoint. Keys are
only created after PKCE verification, preventing orphaned keys on
abandoned OAuth flows.
- Add ensureInitialized() to MCP server so event handlers load and
transaction.categorized events reach extensions.
P2 fixes:
- Remove 'plain' from PKCE methods — only S256 is advertised and
accepted.
- Fix extension count in sectors test (10 → 11 for mcp-server).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove duplicate ensureInitialized() that caused circular import
The extension router (ext/[...path]/route.ts) already calls
ensureInitialized() before dispatching to handlers. The duplicate
call in server.ts created a circular import that Turbopack couldn't
resolve, breaking the Vercel build.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2b75ef6538
commit
5d66dd6bfc
@@ -0,0 +1,214 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createAuthCode } from '@/lib/auth/oauth-codes'
|
||||
|
||||
/**
|
||||
* OAuth 2.0 Authorization Endpoint.
|
||||
*
|
||||
* GET → show consent page (or redirect to login)
|
||||
* POST → process consent, create auth code, redirect to callback
|
||||
*
|
||||
* The API key is NOT created here — it's created in the token endpoint
|
||||
* after PKCE verification, preventing orphaned keys on abandoned flows.
|
||||
*/
|
||||
|
||||
// Known Claude callback URLs — reject all others to prevent open redirect
|
||||
const ALLOWED_REDIRECT_PATTERNS = [
|
||||
/^https:\/\/claude\.ai\/api\/mcp\/auth_callback$/,
|
||||
/^https:\/\/claude\.com\/api\/mcp\/auth_callback$/,
|
||||
/^http:\/\/localhost(:\d+)?\//, // Local development
|
||||
/^http:\/\/127\.0\.0\.1(:\d+)?\//, // Local development
|
||||
]
|
||||
|
||||
function isAllowedRedirectUri(uri: string): boolean {
|
||||
return ALLOWED_REDIRECT_PATTERNS.some((pattern) => pattern.test(uri))
|
||||
}
|
||||
|
||||
function buildLoginRedirect(request: Request): Response {
|
||||
const url = new URL(request.url)
|
||||
const next = `${url.pathname}${url.search}`
|
||||
return NextResponse.redirect(
|
||||
new URL(`/login?next=${encodeURIComponent(next)}`, url.origin)
|
||||
)
|
||||
}
|
||||
|
||||
function errorRedirect(redirectUri: string, state: string | null, error: string, desc: string): Response {
|
||||
const url = new URL(redirectUri)
|
||||
url.searchParams.set('error', error)
|
||||
url.searchParams.set('error_description', desc)
|
||||
if (state) url.searchParams.set('state', state)
|
||||
return NextResponse.redirect(url.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mcp-oauth/authorize — show consent page
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url)
|
||||
const redirectUri = url.searchParams.get('redirect_uri')
|
||||
const state = url.searchParams.get('state')
|
||||
const codeChallenge = url.searchParams.get('code_challenge')
|
||||
const codeChallengeMethod = url.searchParams.get('code_challenge_method') || 'S256'
|
||||
const responseType = url.searchParams.get('response_type')
|
||||
|
||||
if (responseType !== 'code') {
|
||||
return NextResponse.json(
|
||||
{ error: 'unsupported_response_type' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!redirectUri) {
|
||||
return NextResponse.json(
|
||||
{ error: 'invalid_request', error_description: 'redirect_uri is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate redirect_uri against allowlist (prevents open redirect)
|
||||
if (!isAllowedRedirectUri(redirectUri)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'invalid_request', error_description: 'redirect_uri is not allowed' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (codeChallengeMethod !== 'S256') {
|
||||
return NextResponse.json(
|
||||
{ error: 'invalid_request', error_description: 'Only S256 code_challenge_method is supported' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user is logged in
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return buildLoginRedirect(request)
|
||||
}
|
||||
|
||||
// Get company name for the consent page
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_name')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const companyName = settings?.company_name || user.email
|
||||
|
||||
// Render consent page
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="sv">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="translate" content="no">
|
||||
<title>Anslut MCP-klient — gnubok</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: #fafafa; color: #111; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 1rem; }
|
||||
.card { background: white; border-radius: 12px; border: 1px solid #e5e5e5; padding: 2rem; max-width: 400px; width: 100%; }
|
||||
h1 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; }
|
||||
p { font-size: 0.875rem; color: #666; line-height: 1.5; margin-bottom: 1rem; }
|
||||
.account { font-size: 0.875rem; color: #111; font-weight: 500; background: #f5f5f5; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1.5rem; }
|
||||
.permissions { font-size: 0.8125rem; color: #444; margin-bottom: 1.5rem; }
|
||||
.permissions li { margin-bottom: 0.25rem; }
|
||||
.actions { display: flex; gap: 0.75rem; }
|
||||
button { flex: 1; padding: 0.625rem 1rem; border-radius: 8px; font-size: 0.875rem; font-weight: 500; cursor: pointer; border: 1px solid #e5e5e5; }
|
||||
.allow { background: #111; color: white; border-color: #111; }
|
||||
.allow:hover { background: #333; }
|
||||
.deny { background: white; color: #111; }
|
||||
.deny:hover { background: #f5f5f5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Anslut MCP-klient</h1>
|
||||
<p>En extern applikation vill ansluta till ditt gnubok-konto.</p>
|
||||
<div class="account">${escapeHtml(companyName)}</div>
|
||||
<ul class="permissions">
|
||||
<li>Visa och kategorisera transaktioner</li>
|
||||
<li>Skapa och visa fakturor</li>
|
||||
<li>Visa kunder och rapporter</li>
|
||||
<li>Skapa verifikationer</li>
|
||||
</ul>
|
||||
<div class="actions">
|
||||
<form method="POST" action="${url.pathname}${url.search}" style="flex:1;display:flex;">
|
||||
<input type="hidden" name="consent" value="deny">
|
||||
<button type="submit" class="deny" style="width:100%;">Neka</button>
|
||||
</form>
|
||||
<form method="POST" action="${url.pathname}${url.search}" style="flex:1;display:flex;">
|
||||
<input type="hidden" name="consent" value="allow">
|
||||
<button type="submit" class="allow" style="width:100%;">Tillåt</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return new Response(html, {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mcp-oauth/authorize — process consent, issue auth code
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const url = new URL(request.url)
|
||||
const redirectUri = url.searchParams.get('redirect_uri')
|
||||
const state = url.searchParams.get('state')
|
||||
const codeChallenge = url.searchParams.get('code_challenge') || ''
|
||||
const codeChallengeMethod = url.searchParams.get('code_challenge_method') || 'S256'
|
||||
|
||||
if (!redirectUri) {
|
||||
return NextResponse.json({ error: 'invalid_request' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!isAllowedRedirectUri(redirectUri)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'invalid_request', error_description: 'redirect_uri is not allowed' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check auth
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return buildLoginRedirect(request)
|
||||
}
|
||||
|
||||
// Parse form body
|
||||
const formData = await request.formData()
|
||||
const consent = formData.get('consent')
|
||||
|
||||
if (consent !== 'allow') {
|
||||
return errorRedirect(redirectUri, state, 'access_denied', 'User denied the request')
|
||||
}
|
||||
|
||||
// Create auth code with userId (NO API key — that's created at /token after PKCE)
|
||||
const code = createAuthCode({
|
||||
userId: user.id,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
redirectUri,
|
||||
})
|
||||
|
||||
// Redirect to callback with the code
|
||||
const callbackUrl = new URL(redirectUri)
|
||||
callbackUrl.searchParams.set('code', code)
|
||||
if (state) callbackUrl.searchParams.set('state', state)
|
||||
|
||||
return NextResponse.redirect(callbackUrl.toString())
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import crypto from 'crypto'
|
||||
|
||||
/**
|
||||
* RFC 7591 — Dynamic Client Registration.
|
||||
* Claude Desktop registers itself as an OAuth client before starting the auth flow.
|
||||
* We accept any registration and return a client_id.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid_request' }, { status: 400 })
|
||||
}
|
||||
|
||||
const clientId = crypto.randomUUID()
|
||||
|
||||
return NextResponse.json({
|
||||
client_id: clientId,
|
||||
client_name: (body.client_name as string) || 'MCP Client',
|
||||
redirect_uris: body.redirect_uris || [],
|
||||
grant_types: ['authorization_code'],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: 'none',
|
||||
}, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { decryptAuthCode, verifyPkce, hashAuthCode } from '@/lib/auth/oauth-codes'
|
||||
import { generateApiKey } from '@/lib/auth/api-keys'
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
|
||||
/**
|
||||
* OAuth 2.0 Token Endpoint.
|
||||
*
|
||||
* Exchanges an authorization code for an API key (access token).
|
||||
* 1. Decrypts the stateless auth code
|
||||
* 2. Checks for replay (single-use enforcement per OAuth 2.1 §4.1.2)
|
||||
* 3. Verifies PKCE (S256 only)
|
||||
* 4. Creates the API key (deferred from /authorize to prevent orphaned keys)
|
||||
* 5. Returns the key as a bearer token
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
let params: URLSearchParams
|
||||
|
||||
const contentType = request.headers.get('content-type') || ''
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const text = await request.text()
|
||||
params = new URLSearchParams(text)
|
||||
} else if (contentType.includes('application/json')) {
|
||||
const json = await request.json()
|
||||
params = new URLSearchParams(json as Record<string, string>)
|
||||
} else {
|
||||
return NextResponse.json({ error: 'unsupported_content_type' }, { status: 400 })
|
||||
}
|
||||
|
||||
const grantType = params.get('grant_type')
|
||||
const code = params.get('code')
|
||||
const codeVerifier = params.get('code_verifier')
|
||||
const redirectUri = params.get('redirect_uri')
|
||||
|
||||
if (grantType !== 'authorization_code') {
|
||||
return NextResponse.json(
|
||||
{ error: 'unsupported_grant_type', error_description: 'Only authorization_code is supported' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json(
|
||||
{ error: 'invalid_request', error_description: 'Missing code parameter' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Decrypt the auth code
|
||||
const payload = decryptAuthCode(code)
|
||||
if (!payload) {
|
||||
return NextResponse.json(
|
||||
{ error: 'invalid_grant', error_description: 'Invalid or expired authorization code' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify redirect_uri matches
|
||||
if (redirectUri && redirectUri !== payload.redirectUri) {
|
||||
return NextResponse.json(
|
||||
{ error: 'invalid_grant', error_description: 'redirect_uri mismatch' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify PKCE (S256 only)
|
||||
if (!codeVerifier) {
|
||||
return NextResponse.json(
|
||||
{ error: 'invalid_request', error_description: 'code_verifier is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!verifyPkce(codeVerifier, payload.codeChallenge)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'invalid_grant', error_description: 'PKCE verification failed' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Single-use enforcement: check and mark code as used (atomically via unique constraint)
|
||||
const codeHash = hashAuthCode(code)
|
||||
const supabase = createServiceClientNoCookies()
|
||||
|
||||
const { error: replayError } = await supabase
|
||||
.from('oauth_used_codes')
|
||||
.insert({ code_hash: codeHash })
|
||||
|
||||
if (replayError) {
|
||||
// Unique constraint violation = code already used
|
||||
return NextResponse.json(
|
||||
{ error: 'invalid_grant', error_description: 'Authorization code already used' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Clean up expired codes (non-blocking, best-effort)
|
||||
supabase
|
||||
.from('oauth_used_codes')
|
||||
.delete()
|
||||
.lt('created_at', new Date(Date.now() - 10 * 60 * 1000).toISOString())
|
||||
.then(() => {})
|
||||
|
||||
// Create the API key now (after PKCE verification — prevents orphaned keys)
|
||||
const { key, hash, prefix } = generateApiKey()
|
||||
|
||||
const { error: insertError } = await supabase
|
||||
.from('api_keys')
|
||||
.insert({
|
||||
user_id: payload.userId,
|
||||
key_hash: hash,
|
||||
key_prefix: prefix,
|
||||
name: 'MCP-klient (OAuth)',
|
||||
})
|
||||
|
||||
if (insertError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'server_error', error_description: 'Failed to create API key' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
access_token: key,
|
||||
token_type: 'Bearer',
|
||||
scope: 'mcp',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { generateARLedger } from '@/lib/reports/ar-ledger'
|
||||
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
|
||||
import {
|
||||
calculateGrossMargin,
|
||||
calculateCashPosition,
|
||||
calculateRevenueGrowth,
|
||||
calculateExpenseRatio,
|
||||
calculateAvgPaymentDays,
|
||||
} from '@/lib/reports/kpi'
|
||||
import type { KPIReport } from '@/types'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodId = searchParams.get('period_id')
|
||||
if (!periodId) {
|
||||
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch fiscal period info
|
||||
const { data: period, error: periodError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', periodId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (periodError || !period) {
|
||||
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Run independent queries in parallel
|
||||
const [
|
||||
incomeStatement,
|
||||
trialBalanceResult,
|
||||
arLedger,
|
||||
monthlyBreakdown,
|
||||
paidInvoicesResult,
|
||||
] = await Promise.all([
|
||||
generateIncomeStatement(supabase, user.id, periodId),
|
||||
generateTrialBalance(supabase, user.id, periodId),
|
||||
generateARLedger(supabase, user.id),
|
||||
generateMonthlyBreakdown(supabase, user.id, periodId),
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('invoice_date, paid_at')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'paid')
|
||||
.not('paid_at', 'is', null)
|
||||
.gte('invoice_date', period.period_start)
|
||||
.lte('invoice_date', period.period_end),
|
||||
])
|
||||
|
||||
const paidInvoices = (paidInvoicesResult.data || []) as { invoice_date: string; paid_at: string }[]
|
||||
|
||||
// Calculate VAT liability from trial balance (output VAT - input VAT)
|
||||
const vatOutputAccounts = ['2611', '2621', '2631']
|
||||
const vatInputAccounts = ['2641', '2645']
|
||||
const outputVat = trialBalanceResult.rows
|
||||
.filter((r) => vatOutputAccounts.includes(r.account_number))
|
||||
.reduce((sum, r) => sum + (r.closing_credit - r.closing_debit), 0)
|
||||
const inputVat = trialBalanceResult.rows
|
||||
.filter((r) => vatInputAccounts.includes(r.account_number))
|
||||
.reduce((sum, r) => sum + (r.closing_debit - r.closing_credit), 0)
|
||||
const vatLiability = Math.round((outputVat - inputVat) * 100) / 100
|
||||
|
||||
// Revenue growth: only for closed periods, compare with previous period
|
||||
let revenueGrowth: number | null = null
|
||||
if (period.is_closed && period.previous_period_id) {
|
||||
const prevStatement = await generateIncomeStatement(
|
||||
supabase,
|
||||
user.id,
|
||||
period.previous_period_id
|
||||
)
|
||||
revenueGrowth = calculateRevenueGrowth(
|
||||
incomeStatement.total_revenue,
|
||||
prevStatement.total_revenue
|
||||
)
|
||||
}
|
||||
|
||||
const report: KPIReport = {
|
||||
grossMargin: calculateGrossMargin(incomeStatement),
|
||||
netResult: incomeStatement.net_result,
|
||||
cashPosition: calculateCashPosition(trialBalanceResult.rows),
|
||||
outstandingReceivables: arLedger.total_outstanding,
|
||||
overdueReceivables: arLedger.total_overdue,
|
||||
revenueGrowth,
|
||||
expenseRatio: calculateExpenseRatio(incomeStatement),
|
||||
avgPaymentDays: calculateAvgPaymentDays(paidInvoices),
|
||||
paidInvoiceCount: paidInvoices.length,
|
||||
vatLiability,
|
||||
totalRevenue: incomeStatement.total_revenue,
|
||||
totalExpenses: incomeStatement.total_expenses,
|
||||
periodComplete: period.is_closed,
|
||||
months: monthlyBreakdown.months,
|
||||
period: { start: period.period_start, end: period.period_end },
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: report })
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* DELETE /api/settings/api-keys/[id] — Revoke an API key (soft delete)
|
||||
*/
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('api_keys')
|
||||
.update({ revoked_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.is('revoked_at', null)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateApiKey, hashApiKey } from '@/lib/auth/api-keys'
|
||||
|
||||
/**
|
||||
* GET /api/settings/api-keys — List user's API keys (never exposes the key itself)
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
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, key_prefix, name, scopes, rate_limit_rpm, last_used_at, revoked_at, created_at')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/settings/api-keys — Create a new API key
|
||||
* Returns the full key ONCE. After this, only the prefix is available.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
let name = 'Unnamed key'
|
||||
try {
|
||||
const body = await request.json()
|
||||
if (body.name && typeof body.name === 'string') {
|
||||
name = body.name.slice(0, 100)
|
||||
}
|
||||
} catch {
|
||||
// Empty body is fine, use default name
|
||||
}
|
||||
|
||||
// Limit to 10 active keys per user
|
||||
const { count } = await supabase
|
||||
.from('api_keys')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
.is('revoked_at', null)
|
||||
|
||||
if (count !== null && count >= 10) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Maximum 10 active API keys allowed' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { key, hash, prefix } = generateApiKey()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('api_keys')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
key_hash: hash,
|
||||
key_prefix: prefix,
|
||||
name,
|
||||
})
|
||||
.select('id, key_prefix, name, created_at')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Return the full key exactly once
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...data,
|
||||
key, // Only time the full key is returned
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user