Skip to content
Draft
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ Recurring expenses require a PostgreSQL database with the `pg_cron` extension. W

Bank integration allows you to load transactions from providers like Plaid and convert them into expenses. This feature was provided by @alexanderwassbjer, who is currently maintaining related issues. See [docs/BANK_TRANSACTIONS.md](docs/BANK_TRANSACTIONS.md).

### 10) REST API

SplitPro exposes a REST API for integrations with external tools. Generate an API key in Account Settings, then authenticate with the `X-API-Key` header. The OpenAPI spec is available at `/api/openapi.json` and interactive docs at `/api/docs`.

```bash
# Example: list expenses
curl -H "X-API-Key: sp_YOUR_KEY" https://your-instance.com/api/v1/expenses
```

Endpoints cover users, friends, groups, balances, and full expense CRUD.

## Limitations and notes

- SplitPro computes balances from expenses on the fly using database views. Expenses are the source of truth, which keeps balances consistent and trustworthy. For self hosted deployments the efficiency of database aggregations is entirely sufficient, but please do report any performance issues.
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,11 @@
"sharp": "^0.34.5",
"sonner": "^1.4.0",
"superjson": "^2.2.1",
"trpc-to-openapi": "2.4.0",
"vaul": "^1.1.2",
"web-push": "^3.6.7",
"zod": "^3.22.4",
"zod-openapi": "4.2.4",
"zustand": "^4.5.0"
},
"devDependencies": {
Expand Down
273 changes: 273 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions prisma/migrations/20260701220715_add_api_keys/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "ApiKey" (
"id" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"keyHash" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"lastUsedAt" TIMESTAMP(3),

CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");

-- AddForeignKey
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
14 changes: 14 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ model User {
friendDefaultSplitsB FriendDefaultSplit[] @relation("FriendDefaultSplitUserB")
sessions Session[]
hiddenFriendIds Int[] @default([])
apiKeys ApiKey[]

@@schema("public")
}
Expand Down Expand Up @@ -270,6 +271,19 @@ model PushNotification {
@@schema("public")
}

model ApiKey {
id String @id @default(cuid())
userId Int
name String
keyHash String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@schema("public")
}

model AppMetadata {
key String @id
value String
Expand Down
17 changes: 17 additions & 0 deletions public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,23 @@
"support_us": "Sponsor us",
"title": "Account",
"write_review": "Write a review",
"api_keys": "API Keys",
"api_key": {
"title": "API Keys",
"description": "Generate API keys to access the SplitPro REST API. Use the X-API-Key header to authenticate.",
"generate": "Generate",
"name_placeholder": "Key name (e.g., Personal, Zapier)",
"name_required": "Please enter a key name",
"new_key": "New API Key",
"copy_warning": "Copy this now — you won't be able to see it again.",
"created": "Created",
"last_used": "Last used",
"never_used": "Never used",
"deleted": "API key deleted",
"delete_error": "Failed to delete API key",
"create_error": "Failed to create API key",
"no_keys": "No API keys yet"
},
"debug_info": "Debug info",
"debug_info_details": {
"title": "Debug Information",
Expand Down
155 changes: 155 additions & 0 deletions src/components/Account/ApiKeyManager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'next-i18next';
import { api } from '~/utils/api';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '../ui/alert-dialog';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { toast } from 'sonner';
import { Copy, Trash2 } from 'lucide-react';

export const ApiKeyManager: React.FC<React.PropsWithChildren> = ({ children }) => {
const { t } = useTranslation();
const utils = api.useUtils();
const [keyName, setKeyName] = useState('');
const [newKey, setNewKey] = useState<string | null>(null);

const getApiKeys = api.user.getApiKeys.useQuery();
const createApiKey = api.user.createApiKey.useMutation({
onSuccess: async (data) => {
setNewKey(data.key);
await utils.user.getApiKeys.refetch();
},
});
const deleteApiKey = api.user.deleteApiKey.useMutation({
onSuccess: async () => {
await utils.user.getApiKeys.refetch();
},
});

const onCreate = useCallback(async () => {
if (!keyName.trim()) {
toast.error(t('account.api_key.name_required'));
return;
}
try {
await createApiKey.mutateAsync({ name: keyName.trim() });
setKeyName('');
} catch {
toast.error(t('account.api_key.create_error'));
}
}, [createApiKey, keyName, t]);

const onDelete = useCallback(
async (id: string) => {
try {
await deleteApiKey.mutateAsync({ id });
toast.success(t('account.api_key.deleted'));
} catch {
toast.error(t('account.api_key.delete_error'));
}
},
[deleteApiKey, t],
);

const copyToClipboard = useCallback(
(text: string) => {
navigator.clipboard.writeText(text).catch(console.error);
toast.success(t('group_details.copied'));
},
[t],
);

return (
<AlertDialog>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent className="max-w-lg">
<AlertDialogHeader>
<AlertDialogTitle>{t('account.api_key.title')}</AlertDialogTitle>
<AlertDialogDescription>{t('account.api_key.description')}</AlertDialogDescription>
</AlertDialogHeader>

<div className="flex flex-col gap-4">
{newKey && (
<div className="rounded-md bg-green-50 p-3 text-sm text-green-800">
<p className="mb-1 font-semibold">{t('account.api_key.new_key')}:</p>
<div className="flex items-center gap-2">
<code className="flex-1 rounded bg-white px-2 py-1 font-mono text-xs break-all">
{newKey}
</code>
<Button size="icon" variant="ghost" onClick={() => copyToClipboard(newKey)}>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="mt-1 text-xs text-green-700">{t('account.api_key.copy_warning')}</p>
</div>
)}

<div className="flex gap-2">
<Input
placeholder={t('account.api_key.name_placeholder')}
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
onKeyDown={(e) => {
if ('Enter' === e.key) {
void onCreate();
}
}}
/>
<Button onClick={() => void onCreate()} loading={createApiKey.isPending}>
{t('account.api_key.generate')}
</Button>
</div>

<div className="flex flex-col gap-2">
{getApiKeys.data?.map((key) => (
<div key={key.id} className="flex items-center justify-between rounded-md border p-2">
<div className="flex flex-col">
<span className="text-sm font-medium">{key.name}</span>
<span className="text-xs text-gray-500">
{t('account.api_key.created')}: {new Date(key.createdAt).toLocaleDateString()}
{key.lastUsedAt
? ` · ${t('account.api_key.last_used')}: ${new Date(key.lastUsedAt).toLocaleDateString()}`
: ` · ${t('account.api_key.never_used')}`}
</span>
</div>
<Button
size="icon"
variant="ghost"
className="text-red-500"
onClick={() => void onDelete(key.id)}
loading={deleteApiKey.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{getApiKeys.data && 0 === getApiKeys.data.length && (
<p className="text-sm text-gray-500">{t('account.api_key.no_keys')}</p>
)}
</div>
</div>

<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setNewKey(null);
setKeyName('');
}}
>
{t('actions.close')}
</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
9 changes: 9 additions & 0 deletions src/pages/account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
DownloadCloud,
FileDown,
HeartHandshakeIcon,
Key,
Languages,
Star,
} from 'lucide-react';
Expand All @@ -17,6 +18,7 @@ import { useRouter } from 'next/router';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { AccountButton } from '~/components/Account/AccountButton';
import { ApiKeyManager } from '~/components/Account/ApiKeyManager';
import { DownloadAppDrawer } from '~/components/Account/DownloadAppDrawer';
import { LanguagePicker } from '~/components/Account/LanguagePicker';
import { SubmitFeedback } from '~/components/Account/SubmitFeedback';
Expand Down Expand Up @@ -155,6 +157,13 @@ const AccountPage: NextPageWithUser<{
{t('account.write_review')}
</AccountButton>

<ApiKeyManager>
<AccountButton>
<Key className="size-5 text-amber-500" />
{t('account.api_keys')}
</AccountButton>
</ApiKeyManager>

<DownloadAppDrawer>
<AccountButton>
<Download className="size-5 text-blue-500" />
Expand Down
32 changes: 32 additions & 0 deletions src/pages/api/docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { type NextApiRequest, type NextApiResponse } from 'next';

export default function handler(req: NextApiRequest, res: NextApiResponse) {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SplitPro API Documentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
body { margin: 0; padding: 0; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: '/api/openapi.json',
dom_id: '#swagger-ui',
layout: 'BaseLayout',
supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch'],
validatorUrl: null
});
</script>
</body>
</html>`;

res.setHeader('Content-Type', 'text/html');
res.status(200).send(html);
}
24 changes: 24 additions & 0 deletions src/pages/api/openapi.json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { type NextApiRequest, type NextApiResponse } from 'next';
import { generateOpenApiDocument } from 'trpc-to-openapi';

import { appRouter } from '~/server/api/root';

export default function handler(req: NextApiRequest, res: NextApiResponse) {
const openApiDocument = generateOpenApiDocument(appRouter, {
title: 'SplitPro REST API',
description: 'Official REST API for SplitPro. Authenticate with X-API-Key header.',
version: '1.0.0',
baseUrl: `${process.env.NEXTAUTH_URL ?? 'http://localhost:3000'}/api/v1`,
tags: ['User', 'Group', 'Expense'],
securitySchemes: {
apiKey: {
type: 'apiKey',
in: 'header',
name: 'X-API-Key',
},
},
});

res.setHeader('Content-Type', 'application/json');
res.status(200).send(JSON.stringify(openApiDocument, null, 2));
}
13 changes: 13 additions & 0 deletions src/pages/api/v1/[...trpc].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createOpenApiNextHandler } from 'trpc-to-openapi';
import { appRouter } from '~/server/api/root';
import { createOpenApiContext } from '~/server/api/trpc';

export default createOpenApiNextHandler({
router: appRouter,
createContext: createOpenApiContext,
onError: ({ path, error }) => {
if ('development' === process.env.NODE_ENV) {
console.error(`❌ OpenAPI failed on ${path ?? '<no-path>'}: ${error.message}`);
}
},
});
2 changes: 2 additions & 0 deletions src/server/api/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createTRPCRouter } from '~/server/api/trpc';
import { userRouter } from './routers/user';
import { bankTransactionsRouter } from './routers/bankTransactions';
import { expenseRouter } from './routers/expense';
import { openApiRouter } from './routers/openapi';

/**
* This is the primary router for your server.
Expand All @@ -15,6 +16,7 @@ export const appRouter = createTRPCRouter({
user: userRouter,
bankTransactions: bankTransactionsRouter,
expense: expenseRouter,
openApi: openApiRouter,
});

// export type definition of API
Expand Down
Loading